дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-12 20:56:00 +02:00
Порівняти коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
4d596c7943 |
No files matched your search
Створено
-18
@@ -4294,22 +4294,18 @@ dependencies = [
|
||||
name = "ide-db"
|
||||
version = "0.1.43"
|
||||
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",
|
||||
@@ -4323,7 +4319,6 @@ dependencies = [
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4448,7 +4443,6 @@ dependencies = [
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-web",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
@@ -4607,7 +4601,6 @@ dependencies = [
|
||||
"tree-sitter-nix",
|
||||
"tree-sitter-ocaml",
|
||||
"tree-sitter-php",
|
||||
"tree-sitter-prisma-io",
|
||||
"tree-sitter-proto",
|
||||
"tree-sitter-python",
|
||||
"tree-sitter-ruby",
|
||||
@@ -4733,7 +4726,6 @@ name = "ide-remote"
|
||||
version = "0.1.43"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -10445,16 +10437,6 @@ 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"
|
||||
|
||||
@@ -1343,15 +1343,6 @@ impl AgentManager {
|
||||
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 at = self.task(id)?.base();
|
||||
@@ -1362,14 +1353,9 @@ impl AgentManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
/// 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<()> {
|
||||
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"));
|
||||
@@ -1380,10 +1366,8 @@ impl AgentManager {
|
||||
(s.before.clone(), s.saved.as_ref().map(|name| self.shared.paths.store_dir.join(name)))
|
||||
};
|
||||
let restamped = blocking(move || {
|
||||
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 changes = at.changes(&before)?;
|
||||
let rev = at.rev()?;
|
||||
let sources = match rev.as_deref() {
|
||||
Some(rev) => worktree::rename_sources(at.cwd(), rev)?,
|
||||
None => Default::default(),
|
||||
|
||||
@@ -484,8 +484,7 @@ pub struct NewAgentTask {
|
||||
pub base: Option<String>,
|
||||
}
|
||||
|
||||
/// A file the task changed: against [`AgentTask::base_commit`], or against `HEAD` in the
|
||||
/// grouped view.
|
||||
/// A file the task changed, relative to [`AgentTask::base_commit`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentChange {
|
||||
@@ -494,44 +493,6 @@ 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`).
|
||||
|
||||
@@ -2853,14 +2853,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()]), AgentChangesView::SinceStart).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, Some(vec!["other.txt".into()])).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, AgentChangesView::SinceStart).await.unwrap();
|
||||
mgr.rollback_changes(&task.id, None).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());
|
||||
@@ -2973,7 +2973,7 @@ async fn work_uncommitted_by_a_reset_during_a_local_task_is_not_rolled_back() {
|
||||
|
||||
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();
|
||||
mgr.rollback_changes(&task.id, None).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:?}");
|
||||
@@ -2996,7 +2996,7 @@ async fn files_a_mixed_reset_during_a_local_task_leaves_behind_are_not_rolled_ba
|
||||
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();
|
||||
mgr.rollback_changes(&task.id, None).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:?}");
|
||||
@@ -3023,7 +3023,7 @@ async fn a_branch_switched_to_during_a_local_task_is_left_alone() {
|
||||
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();
|
||||
mgr.rollback_changes(&task.id, None).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:?}");
|
||||
@@ -3048,7 +3048,7 @@ async fn commits_made_during_a_local_task_are_kept_by_its_rollback() {
|
||||
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();
|
||||
mgr.rollback_changes(&task.id, None).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:?}");
|
||||
@@ -3069,7 +3069,7 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
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();
|
||||
mgr.rollback_changes(&task.id, Some(listed)).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:?}");
|
||||
@@ -3082,27 +3082,6 @@ async fn rolling_back_a_rename_brings_the_old_file_back() {
|
||||
}
|
||||
}
|
||||
|
||||
#[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 {
|
||||
@@ -3113,7 +3092,7 @@ async fn a_local_task_in_a_repository_without_commits_lists_and_rolls_back_its_c
|
||||
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();
|
||||
mgr.rollback_changes(&task.id, None).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:?}");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! Task worktrees and the changes made in them.
|
||||
|
||||
use crate::model::{AgentChange, AgentChanges, AgentCommit};
|
||||
use crate::model::AgentChange;
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_vcs::{ChangeKind, LogQuery, Repo};
|
||||
use ide_vcs::{ChangeKind, Repo};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, UNIX_EPOCH};
|
||||
@@ -35,14 +35,6 @@ 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)?;
|
||||
@@ -119,14 +111,7 @@ pub(crate) fn changes(cwd: &Path, base: &str) -> IdeResult<Vec<AgentChange>> {
|
||||
.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,
|
||||
old_path: f.old_path,
|
||||
}
|
||||
AgentChange { path: IpcPath(root.join(&f.path)), rel_path: f.path, kind: f.kind, added, removed }
|
||||
})
|
||||
.collect();
|
||||
let tracked: std::collections::HashSet<String> = out.iter().map(|c| c.rel_path.clone()).collect();
|
||||
@@ -155,7 +140,6 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
|
||||
kind: ChangeKind::Deleted,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
old_path: None,
|
||||
};
|
||||
Some((f.path, source))
|
||||
})
|
||||
@@ -164,57 +148,7 @@ pub(crate) fn rename_sources(cwd: &Path, rev: &str) -> IdeResult<BTreeMap<String
|
||||
|
||||
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)
|
||||
AgentChange { path, rel_path, kind: ChangeKind::Added, added, removed: 0 }
|
||||
}
|
||||
|
||||
/// Whether two paths name one file (a case-insensitive disk resolving both spellings).
|
||||
@@ -309,34 +243,6 @@ impl TaskBase {
|
||||
.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.
|
||||
@@ -412,47 +318,24 @@ pub(crate) fn accept(
|
||||
reason: "the checkout has changes of its own to it; accept it by hand".to_owned(),
|
||||
};
|
||||
for change in changes {
|
||||
// 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:
|
||||
// the old one's unchanged-ness (it is the file the checkout had) decides —
|
||||
// then the task's version lands at the new name and the old one goes.
|
||||
let old = renamed_from.get(&change.rel_path);
|
||||
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 {
|
||||
if !apply(old)? {
|
||||
skipped.push(skip(old));
|
||||
continue;
|
||||
}
|
||||
// `apply(old)` removed the old file (the task's tree has it only at
|
||||
// the new name); now write that version under the new name. `apply`
|
||||
// can't do it — it reads the new name's own base/absence — so the
|
||||
// write is open-coded from the same task bytes `apply` would use.
|
||||
if let Ok(bytes) = std::fs::read(repo.root().join(&change.rel_path)) {
|
||||
if let Some(parent) = new_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&new_path, bytes)?;
|
||||
}
|
||||
accepted.push(change.rel_path.clone());
|
||||
continue;
|
||||
}
|
||||
// A case-only rename on a case-insensitive disk: both names reach the same file, so
|
||||
// rename it in place before bringing it to the task's version.
|
||||
if let Some(old) = renamed_from.get(&change.rel_path)
|
||||
&& old != &change.rel_path
|
||||
&& old.to_lowercase() == change.rel_path.to_lowercase()
|
||||
&& local_root.join(old).exists()
|
||||
&& same_file(&local_root.join(old), &local_root.join(&change.rel_path))
|
||||
{
|
||||
std::fs::rename(local_root.join(old), local_root.join(&change.rel_path))?;
|
||||
}
|
||||
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
|
||||
// A rename also takes the file away from its old path (unless only its case changed).
|
||||
if let Some(old) = renamed_from.get(&change.rel_path)
|
||||
&& old.to_lowercase() != change.rel_path.to_lowercase()
|
||||
&& !apply(old)?
|
||||
{
|
||||
skipped.push(skip(old));
|
||||
@@ -667,18 +550,7 @@ 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",
|
||||
// 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(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.current_dir(dir)
|
||||
.status()
|
||||
@@ -726,88 +598,6 @@ 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();
|
||||
@@ -844,9 +634,6 @@ mod tests {
|
||||
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", "."]);
|
||||
|
||||
@@ -204,7 +204,7 @@ pub fn parse_settings(text: &str) -> Settings {
|
||||
}
|
||||
|
||||
/// `a/b/../c` → `a/c`, without touching the filesystem.
|
||||
pub(crate) fn normalize(p: &Path) -> PathBuf {
|
||||
fn normalize(p: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
|
||||
@@ -122,14 +122,6 @@ 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,
|
||||
|
||||
@@ -106,9 +106,7 @@ 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` (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.
|
||||
/// Maven launcher: the nearest `mvnw` up to the project root (absolute), else `mvn`.
|
||||
pub command: String,
|
||||
/// Options before the goals (`-s`, `-Dmaven.repo.local=`); filled in by the shell from settings.
|
||||
pub options: Vec<String>,
|
||||
@@ -1034,6 +1032,10 @@ 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`
|
||||
@@ -1047,29 +1049,18 @@ pub fn is_maven_project(root: &Path) -> bool {
|
||||
!top_level_poms(root).is_empty()
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The `mvnw` nearest to `dir`, looking up to `root`.
|
||||
fn wrapper(root: &Path, dir: &Path) -> String {
|
||||
let name = if cfg!(windows) { "mvnw.cmd" } else { "mvnw" };
|
||||
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());
|
||||
let mut cur = Some(dir);
|
||||
while let Some(d) = cur {
|
||||
if d.join(name).is_file() {
|
||||
return d.join(name).to_string_lossy().into_owned();
|
||||
}
|
||||
if !external && d == root {
|
||||
if d == root {
|
||||
break;
|
||||
}
|
||||
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);
|
||||
cur = d.parent();
|
||||
}
|
||||
if cfg!(windows) { "mvn.cmd".into() } else { "mvn".into() }
|
||||
}
|
||||
@@ -1113,118 +1104,24 @@ pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[St
|
||||
load_with_linked(root, local_repo, profiles, &[])
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The POMs of `linked` (project-relative, from Settings) that exist inside `root`.
|
||||
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 Some(path) = dunce::canonicalize(base.join(pom.trim())).ok() else { continue };
|
||||
if is_pom_file(&path) && !out.contains(&path) {
|
||||
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) {
|
||||
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()
|
||||
@@ -1233,9 +1130,7 @@ 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, 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.
|
||||
/// top-level projects. A linked POM that turns out to be a module of the root reactor stays that module.
|
||||
pub fn load_with_linked(
|
||||
root: &Path,
|
||||
local_repo: Option<PathBuf>,
|
||||
@@ -1244,10 +1139,7 @@ pub fn load_with_linked(
|
||||
) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
// 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 seen: HashSet<PathBuf> = HashSet::new();
|
||||
let mut tops = top_level_poms(root);
|
||||
for pom in linked_poms(root, linked) {
|
||||
if !tops.contains(&pom) {
|
||||
@@ -1255,31 +1147,17 @@ pub fn load_with_linked(
|
||||
}
|
||||
}
|
||||
// (pom, aggregated); the root reactor and its modules come before linked POMs.
|
||||
// (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();
|
||||
let mut stack: Vec<(PathBuf, bool)> = tops.into_iter().rev().map(|p| (p, false)).collect();
|
||||
while let Some((path, aggregated)) = stack.pop() {
|
||||
if model.projects.len() >= MAX_PROJECTS {
|
||||
break;
|
||||
}
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
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;
|
||||
}
|
||||
if !seen.insert(canonical) {
|
||||
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);
|
||||
@@ -1290,7 +1168,7 @@ pub fn load_with_linked(
|
||||
}
|
||||
let mut modules: Vec<PathBuf> = Vec::new();
|
||||
for m in module_names {
|
||||
let mut mp = crate::gradle::normalize(&dir.join(i(&m)));
|
||||
let mut mp = dir.join(i(&m));
|
||||
if mp.is_dir() || mp.extension().is_none_or(|e| e != "xml") {
|
||||
mp.push("pom.xml");
|
||||
}
|
||||
@@ -1354,39 +1232,28 @@ 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: linked_ids.get(&canonical).cloned().unwrap_or_else(|| project_rel(&canonical_root, &canonical)),
|
||||
dir: project_rel(&canonical_root, &dir_canon),
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
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: module_ids,
|
||||
modules: modules.iter().map(|m| rel_path(root, m)).collect(),
|
||||
aggregated,
|
||||
plugins,
|
||||
dependencies,
|
||||
profiles: ctx.profiles.iter().map(|(id, _)| id.clone()).collect(),
|
||||
java_version: jv,
|
||||
command: wrapper(&canonical_root, &dir_canon),
|
||||
command: wrapper(root, &dir),
|
||||
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, Some((origin, inside))));
|
||||
stack.push((m, true));
|
||||
}
|
||||
}
|
||||
model
|
||||
@@ -1474,27 +1341,17 @@ impl UnlinkedProjects {
|
||||
|
||||
fn normalized_pom(pom: &str) -> String {
|
||||
let pom = pom.replace('\\', "/");
|
||||
let unc = pom.starts_with("//");
|
||||
let absolute = pom.starts_with('/');
|
||||
let mut parts: Vec<&str> = Vec::new();
|
||||
let mut parts = Vec::new();
|
||||
for part in pom.split('/') {
|
||||
match part {
|
||||
"" | "." => {}
|
||||
// Keep a drive letter (`C:`) and a leading `..` that has nothing to climb.
|
||||
".." if parts.last().is_some_and(|p| *p != ".." && !p.ends_with(':')) => {
|
||||
".." if parts.last().is_some_and(|p| *p != "..") => {
|
||||
parts.pop();
|
||||
}
|
||||
part => parts.push(part),
|
||||
}
|
||||
}
|
||||
let joined = parts.join("/");
|
||||
if unc {
|
||||
format!("//{joined}")
|
||||
} else if absolute {
|
||||
format!("/{joined}")
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
parts.join("/")
|
||||
}
|
||||
|
||||
/// Every POM of the model (for change detection).
|
||||
@@ -2290,169 +2147,6 @@ 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();
|
||||
|
||||
@@ -78,10 +78,6 @@ 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,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ impl Default for EditorAppearanceSettings {
|
||||
rainbow_indent_guides: false,
|
||||
hard_wrap_guides: true,
|
||||
intention_bulb: true,
|
||||
render_doc_comments: false,
|
||||
distraction_free_width: 80,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,19 +194,6 @@ 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
|
||||
@@ -570,19 +557,13 @@ 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,
|
||||
@@ -1201,9 +1182,7 @@ pub struct MavenSettings {
|
||||
pub runner_jre: String,
|
||||
/// Project-relative root POMs removed from the Maven project model.
|
||||
pub unlinked_projects: Vec<String>,
|
||||
/// 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.
|
||||
/// Project-relative POMs outside the root reactor added as independent top-level projects.
|
||||
pub linked_projects: Vec<String>,
|
||||
/// Maven home (or its `mvn` executable); empty = the project's `mvnw`, else `mvn` on `PATH`.
|
||||
pub home: String,
|
||||
@@ -1443,19 +1422,6 @@ 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}}"#] {
|
||||
|
||||
@@ -192,33 +192,6 @@ 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")]
|
||||
@@ -230,8 +203,6 @@ 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 {
|
||||
@@ -248,7 +219,6 @@ impl Default for ConsoleSettings {
|
||||
"at node:internal/".into(),
|
||||
"<frozen importlib._bootstrap".into(),
|
||||
],
|
||||
log_colors: LogLevelColors::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,6 @@ 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"] }
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
//! 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,7 +238,6 @@ pub enum Pool {
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OraclePool),
|
||||
Snowflake(crate::snowflake::SnowflakePool),
|
||||
}
|
||||
|
||||
pub(crate) fn db_err(e: sqlx::Error) -> IdeError {
|
||||
@@ -284,16 +283,6 @@ 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`.
|
||||
@@ -704,9 +693,6 @@ 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));
|
||||
@@ -724,7 +710,6 @@ impl Pool {
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
Pool::Oracle(_) => DbDriver::Oracle,
|
||||
Pool::Snowflake(_) => DbDriver::Snowflake,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -746,9 +731,6 @@ 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).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.
|
||||
@@ -894,7 +876,9 @@ impl Pool {
|
||||
if !source.database.is_empty() {
|
||||
config.database(&source.database);
|
||||
}
|
||||
apply_sqlserver_ssl(&mut config, source.ssl);
|
||||
if matches!(source.ssl, DbSslMode::Disable | DbSslMode::Prefer) {
|
||||
config.trust_cert();
|
||||
}
|
||||
let manager = MssqlConnectionManager(config);
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(MAX)
|
||||
@@ -907,7 +891,7 @@ impl Pool {
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
DbDriver::Oracle | DbDriver::Snowflake => unreachable!(),
|
||||
DbDriver::Oracle => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,7 +899,6 @@ 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),
|
||||
@@ -927,7 +910,6 @@ 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),
|
||||
@@ -939,9 +921,6 @@ 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;
|
||||
@@ -966,9 +945,6 @@ 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,
|
||||
@@ -986,7 +962,6 @@ 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?;
|
||||
@@ -1068,8 +1043,6 @@ 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 {
|
||||
@@ -1086,12 +1059,6 @@ 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1136,11 +1103,6 @@ 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,
|
||||
@@ -1196,7 +1158,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(_) | Held::Snowflake(..) => unreachable!(),
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.after_statement(sql);
|
||||
@@ -1218,12 +1180,6 @@ 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() {
|
||||
@@ -1237,9 +1193,7 @@ 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(_) | Held::Snowflake(..) => {
|
||||
unreachable!()
|
||||
}
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -1264,7 +1218,7 @@ impl Held {
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => {}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
@@ -1294,9 +1248,6 @@ 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.
|
||||
@@ -1341,7 +1292,7 @@ impl Held {
|
||||
}
|
||||
}
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
|
||||
Held::Clickhouse(_) | Held::Snowflake(..) => unreachable!(),
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
self.after_statement(sql);
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
@@ -1364,7 +1315,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 | DbDriver::Snowflake => "BEGIN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1380,33 +1331,6 @@ 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());
|
||||
|
||||
+14
-207
@@ -2,7 +2,6 @@
|
||||
//! 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;
|
||||
@@ -10,20 +9,17 @@ 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, DbSnowflake, DbSnowflakeAuth, DbSsh,
|
||||
DbSshAuth, DbSslMode, DbTxMode,
|
||||
DataSource, DbColor, DbDriver, DbIntrospection, DbOptions, DbProperty, DbSsh, DbSshAuth, DbSslMode, DbTxMode,
|
||||
};
|
||||
pub use sql::{CellValue, DbRowEdit, SortKey};
|
||||
|
||||
@@ -115,21 +111,6 @@ 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)]
|
||||
@@ -225,18 +206,6 @@ 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")]
|
||||
@@ -261,8 +230,6 @@ 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.
|
||||
@@ -374,9 +341,6 @@ 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)>();
|
||||
{
|
||||
@@ -396,7 +360,6 @@ impl DbManager {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
global: None,
|
||||
history,
|
||||
catalogs,
|
||||
recorder,
|
||||
root,
|
||||
pools: Mutex::default(),
|
||||
@@ -460,10 +423,6 @@ 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)
|
||||
@@ -498,10 +457,9 @@ 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), Self::sso_credential(&o.id)];
|
||||
let keys = [o.id.clone(), secrets::ssh_key(&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);
|
||||
@@ -655,9 +613,7 @@ impl DbManager {
|
||||
ssh_secret: Option<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
// 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 {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && source.driver != DbDriver::Sqlite) else {
|
||||
return Ok((source.clone(), None));
|
||||
};
|
||||
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
|
||||
@@ -711,10 +667,7 @@ impl DbManager {
|
||||
None => self.password(source).await,
|
||||
};
|
||||
let (target, tunnel) = self.endpoint(source, ssh_secret, false).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 = connected.map_err(|e| {
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| {
|
||||
through_tunnel(
|
||||
e,
|
||||
source,
|
||||
@@ -727,16 +680,10 @@ 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 strict = matches!(source.driver, DbDriver::Clickhouse | DbDriver::Snowflake);
|
||||
let rows = if strict { result? } else { result.unwrap_or_default() };
|
||||
let rows = if source.driver == DbDriver::Clickhouse { 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()))
|
||||
}
|
||||
@@ -763,10 +710,7 @@ impl DbManager {
|
||||
let source = self.source(id)?;
|
||||
let password = self.password(&source).await;
|
||||
let (target, tunnel) = self.endpoint(&source, None, true).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 {
|
||||
let pool = match Pool::connect(&target, password.as_deref(), Some(&self.root)).await {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(e) => {
|
||||
self.drop_unused_tunnel(source_id);
|
||||
@@ -779,10 +723,9 @@ 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) {
|
||||
// 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) {
|
||||
if Arc::ptr_eq(&kept, &pool)
|
||||
&& let Some(secs) = source.options.keep_alive_secs.filter(|s| *s > 0)
|
||||
{
|
||||
keep_alive(&pool, Duration::from_secs(secs.into()));
|
||||
}
|
||||
Ok(kept)
|
||||
@@ -832,23 +775,6 @@ 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"));
|
||||
}
|
||||
@@ -863,95 +789,17 @@ 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()
|
||||
@@ -965,9 +813,6 @@ 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)
|
||||
@@ -995,7 +840,7 @@ impl DbManager {
|
||||
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
|
||||
}
|
||||
}
|
||||
let fk_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
|
||||
@@ -1012,7 +857,7 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
let trigger_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
let trigger_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
@@ -1039,9 +884,6 @@ 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) {
|
||||
@@ -1091,14 +933,6 @@ 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] {
|
||||
@@ -1199,26 +1033,6 @@ 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() {
|
||||
@@ -1267,14 +1081,7 @@ impl DbManager {
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let mut tables: Vec<CompletionTable> = Vec::new();
|
||||
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 {
|
||||
for r in pool.rows(sql::completion_columns(driver), MAX_META_ROWS).await? {
|
||||
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());
|
||||
@@ -1299,7 +1106,7 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
|
||||
let fk_rows = if matches!(driver, DbDriver::Clickhouse | DbDriver::Snowflake) {
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
|
||||
@@ -39,7 +39,6 @@ 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}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,10 +77,6 @@ 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,42 +197,6 @@ 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| {
|
||||
@@ -418,61 +377,4 @@ 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), format!("{id}#sso")] {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
|
||||
@@ -1,121 +0,0 @@
|
||||
//! 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/");
|
||||
}
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,161 +0,0 @@
|
||||
//! 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<_>>());
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,265 +0,0 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -1,511 +0,0 @@
|
||||
//! 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"]);
|
||||
}
|
||||
}
|
||||
@@ -1,929 +0,0 @@
|
||||
//! 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 their connection settings, so targets 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>>,
|
||||
}
|
||||
|
||||
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) -> 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).
|
||||
let key = blake3::hash(
|
||||
format!(
|
||||
"{}\0{user}\0{:?}\0{}\0{}\0{}\0{}\0{:?}\0{:?}",
|
||||
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.
|
||||
if first_word(sql) != "USE" {
|
||||
self.enter(schema, cancel).await?;
|
||||
}
|
||||
// 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, 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;
|
||||
match self.statement(&wanted, 0, 0, cancel).await {
|
||||
Ok(_) => {}
|
||||
// The session's role may not use this database or schema (after `USE ROLE`, or a
|
||||
// schema that is gone). The statement still runs, in the session's own context:
|
||||
// it may be the `USE` that leads back, or name its objects in full. Asked again
|
||||
// once a statement moves the session.
|
||||
Err(refused) if refused.kind == ErrorKind::InvalidInput => {
|
||||
tracing::warn!("Snowflake: the session did not enter its database and schema");
|
||||
}
|
||||
Err(error) => return Err(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, &cancel).await?;
|
||||
let changes = async {
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in statements {
|
||||
let changed = self.statement(sql, 0, 0, &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, &cancel).await?;
|
||||
Ok(total)
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = self.statement("ROLLBACK", 0, 0, &cancel).await;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One statement on the session; the caller holds the session's turn.
|
||||
async fn statement(
|
||||
&self,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max_rows: usize,
|
||||
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;
|
||||
tokens = if gone { self.login().await? } else { self.renew(&tokens).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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -1,210 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -1,921 +0,0 @@
|
||||
//! The driver against an in-process HTTP server that plays Snowflake's part.
|
||||
|
||||
use super::*;
|
||||
use crate::conn::Pool;
|
||||
use crate::source::{DbDriver, DbOptions, DbProperty, DbSnowflake, DbSslMode};
|
||||
use base64::Engine;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const KEY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/snowflake/rsa_key.p8");
|
||||
const ENCRYPTED_KEY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/snowflake/rsa_key_encrypted.p8");
|
||||
const FINGERPRINT: &str = "SHA256:ajjL6Bf6l1FIIyTcJli4i2AWPYDVJGQnSZr+9q9ClgI=";
|
||||
pub(super) const LOGIN_OK: &str =
|
||||
r#"{"success":true,"code":null,"message":null,"data":{"token":"session-token-1","masterToken":"master-token-1"}}"#;
|
||||
pub(super) const CLOSED_OK: &str = r#"{"success":true,"data":null}"#;
|
||||
|
||||
pub(super) struct Reply {
|
||||
status: &'static str,
|
||||
body: Vec<u8>,
|
||||
/// Answer only after this long (a statement that is still running).
|
||||
delay: Duration,
|
||||
}
|
||||
|
||||
pub(super) fn ok(body: impl Into<String>) -> Reply {
|
||||
Reply { status: "200 OK", body: body.into().into_bytes(), delay: Duration::ZERO }
|
||||
}
|
||||
|
||||
/// A recorded request: its request line and headers, and its body.
|
||||
pub(super) struct Seen {
|
||||
head: String,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Seen {
|
||||
pub(super) fn json(&self) -> Value {
|
||||
serde_json::from_str(&self.body).unwrap()
|
||||
}
|
||||
|
||||
fn line(&self) -> &str {
|
||||
self.head.lines().next().unwrap()
|
||||
}
|
||||
|
||||
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_request(stream: &mut TcpStream) -> Seen {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 8192];
|
||||
loop {
|
||||
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
|
||||
assert!(count > 0, "request ended early");
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|s| s == 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:").map(|v| v.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
return Seen { head, body: String::from_utf8_lossy(&bytes[end + 4..]).into_owned() };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn bind() -> (TcpListener, u16) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(listener, port)
|
||||
}
|
||||
|
||||
/// Answers one request per reply, in order, and returns what it received.
|
||||
pub(super) fn serve(listener: TcpListener, replies: Vec<Reply>) -> tokio::task::JoinHandle<Vec<Seen>> {
|
||||
tokio::spawn(async move {
|
||||
let mut seen = Vec::new();
|
||||
let mut held = Vec::new();
|
||||
for reply in replies {
|
||||
let (mut stream, _) =
|
||||
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
seen.push(read_request(&mut stream).await);
|
||||
let respond = async move {
|
||||
tokio::time::sleep(reply.delay).await;
|
||||
let head = format!(
|
||||
"HTTP/1.1 {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
reply.status,
|
||||
reply.body.len()
|
||||
);
|
||||
let _ = stream.write_all(head.as_bytes()).await;
|
||||
let _ = stream.write_all(&reply.body).await;
|
||||
};
|
||||
if reply.delay.is_zero() {
|
||||
respond.await;
|
||||
} else {
|
||||
held.push(tokio::spawn(respond));
|
||||
}
|
||||
}
|
||||
seen
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn source(port: u16) -> DataSource {
|
||||
DataSource {
|
||||
id: "sf".into(),
|
||||
name: "sf".into(),
|
||||
driver: DbDriver::Snowflake,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "jane".into(),
|
||||
database: "ANALYTICS".into(),
|
||||
ssl: DbSslMode::Disable,
|
||||
options: DbOptions { default_schema: "PUBLIC".into(), ..Default::default() },
|
||||
snowflake: Some(DbSnowflake {
|
||||
warehouse: "COMPUTE_WH".into(),
|
||||
role: "ANALYST".into(),
|
||||
private_key_file: KEY.into(),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn result(rowtype: Value, rowset: Value) -> String {
|
||||
json!({ "success": true, "code": null, "data": { "rowtype": rowtype, "rowset": rowset, "queryResultFormat": "json",
|
||||
"statementTypeId": 4096, "total": 0, "chunks": [] } })
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn one() -> String {
|
||||
result(json!([{ "name": "N", "type": "fixed", "precision": 1, "scale": 0 }]), json!([["1"]]))
|
||||
}
|
||||
|
||||
async fn failure(source: &DataSource, secret: Option<&str>) -> IdeError {
|
||||
match Pool::connect(source, secret, None).await {
|
||||
Ok(_) => panic!("the connection was accepted"),
|
||||
Err(error) => error,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_login_then_a_statement_on_the_session() {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(one()), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.options.time_zone = "Europe/Kyiv".into();
|
||||
source.properties = vec![
|
||||
DbProperty { name: "query_tag".into(), value: "ide".into() },
|
||||
DbProperty { name: "statement_timeout_in_seconds".into(), value: "60".into() },
|
||||
DbProperty { name: "client_session_keep_alive".into(), value: "true".into() },
|
||||
];
|
||||
let pool = Pool::connect(&source, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let rows = pool.execute("select 1", 10, &cancel).await.unwrap();
|
||||
assert_eq!(rows.rows, vec![vec![Some("1".to_owned())]]);
|
||||
assert_eq!(rows.columns[0].name, "N");
|
||||
assert_eq!(rows.columns[0].type_name, "NUMBER(1,0)");
|
||||
assert_eq!(rows.affected, None);
|
||||
assert!(!rows.has_more);
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
let seen = server.await.unwrap();
|
||||
|
||||
let login = &seen[0];
|
||||
assert!(login.line().starts_with("POST /session/v1/login-request?"), "{}", login.line());
|
||||
for parameter in
|
||||
["databaseName=ANALYTICS", "schemaName=PUBLIC", "warehouse=COMPUTE_WH", "roleName=ANALYST", "requestId="]
|
||||
{
|
||||
assert!(login.line().contains(parameter), "{parameter}: {}", login.line());
|
||||
}
|
||||
assert_eq!(login.header("accept"), Some("application/snowflake"));
|
||||
assert_eq!(login.header("authorization"), None);
|
||||
let data = &login.json()["data"];
|
||||
assert_eq!(data["AUTHENTICATOR"], "SNOWFLAKE_JWT");
|
||||
assert_eq!(data["LOGIN_NAME"], "jane");
|
||||
assert_eq!(data["ACCOUNT_NAME"], "127");
|
||||
assert_eq!(data["CLIENT_APP_ID"], "OpenIDEA");
|
||||
assert!(data.get("PASSWORD").is_none());
|
||||
assert_eq!(
|
||||
data["SESSION_PARAMETERS"],
|
||||
json!({ "QUERY_TAG": "ide", "STATEMENT_TIMEOUT_IN_SECONDS": 60, "CLIENT_SESSION_KEEP_ALIVE": true, "TIMEZONE": "Europe/Kyiv" })
|
||||
);
|
||||
let token = data["TOKEN"].as_str().unwrap();
|
||||
let claims: Value = serde_json::from_slice(
|
||||
&base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token.split('.').nth(1).unwrap()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(claims["sub"], "127.JANE");
|
||||
assert_eq!(claims["iss"], format!("127.JANE.{FINGERPRINT}"));
|
||||
|
||||
let query = &seen[1];
|
||||
assert!(query.line().starts_with("POST /queries/v1/query-request?requestId="), "{}", query.line());
|
||||
assert_eq!(query.header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
let body = query.json();
|
||||
assert_eq!(body["sqlText"], "select 1");
|
||||
assert_eq!(body["asyncExec"], false);
|
||||
assert_eq!(body["parameters"]["ROWS_PER_RESULTSET"], 11);
|
||||
assert_eq!(body["sequenceId"], 1);
|
||||
|
||||
let close = &seen[2];
|
||||
assert!(close.line().starts_with("POST /session?delete=true"), "{}", close.line());
|
||||
assert_eq!(close.header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rejected_key_explains_itself_and_shows_the_fingerprint() {
|
||||
let (listener, port) = bind().await;
|
||||
let refused = r#"{"success":false,"code":"390144","message":"JWT token is invalid. [0ce9eb56-821d-4ca9-a774-04ae89a0cf5a]","data":{"authnMethod":"SNOWFLAKE_JWT"}}"#;
|
||||
let server = serve(listener, vec![ok(refused)]);
|
||||
let error = failure(&source(port), None).await;
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(error.message.starts_with("Snowflake [390144]: JWT token is invalid."), "{}", error.message);
|
||||
assert!(error.message.contains("ALTER USER"), "{}", error.message);
|
||||
assert!(error.message.ends_with(&format!("Fingerprint of this private key: {FINGERPRINT}")), "{}", error.message);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_problems_are_found_before_any_request() {
|
||||
// Nothing listens: a request would fail with a network error instead.
|
||||
let (listener, port) = bind().await;
|
||||
drop(listener);
|
||||
let mut encrypted = source(port);
|
||||
encrypted.snowflake.as_mut().unwrap().private_key_file = ENCRYPTED_KEY.into();
|
||||
let error = failure(&encrypted, None).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(error.message, "the private key is encrypted; enter its passphrase");
|
||||
let error = failure(&encrypted, Some("wrong")).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(error.message, "incorrect passphrase for the private key");
|
||||
|
||||
let mut missing = source(port);
|
||||
missing.snowflake.as_mut().unwrap().private_key_file = String::new();
|
||||
assert_eq!(failure(&missing, None).await.message, "select the private key file of the Snowflake user");
|
||||
let mut no_user = source(port);
|
||||
no_user.user = " ".into();
|
||||
assert_eq!(failure(&no_user, None).await.message, "enter the Snowflake user name");
|
||||
let mut reserved = source(port);
|
||||
reserved.properties = vec![DbProperty { name: "rows_per_resultset".into(), value: "5".into() }];
|
||||
assert!(failure(&reserved, None).await.message.contains("manages the rows_per_resultset parameter"));
|
||||
let mut no_account = source(port);
|
||||
no_account.host = String::new();
|
||||
assert!(failure(&no_account, None).await.message.contains("account identifier"));
|
||||
for auth in [DbSnowflakeAuth::Password, DbSnowflakeAuth::Token, DbSnowflakeAuth::OAuth] {
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = auth;
|
||||
let error = failure(&source, None).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied, "{auth:?}");
|
||||
assert!(error.message.starts_with("enter the "), "{}", error.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_encrypted_key_signs_in_with_its_passphrase_and_never_leaks_it() {
|
||||
let (listener, port) = bind().await;
|
||||
// A server that echoes what it was sent must not get the secrets into an error message.
|
||||
let echo =
|
||||
r#"{"success":false,"code":"002003","message":"bad statement test-passphrase session-token-1 master-token-1"}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(echo), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().private_key_file = ENCRYPTED_KEY.into();
|
||||
let pool = Pool::connect(&source, Some("test-passphrase"), None).await.unwrap();
|
||||
let error = pool.execute("select nope", 1, &CancellationToken::new()).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "Snowflake [002003]: bad statement [redacted] [redacted] [redacted]");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(!seen[0].body.contains("test-passphrase"));
|
||||
assert!(!seen[0].head.contains("test-passphrase"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_and_token_methods_send_their_own_proof() {
|
||||
for (auth, secret, expected) in [
|
||||
(DbSnowflakeAuth::Password, "pa55word!", json!({ "PASSWORD": "pa55word!" })),
|
||||
(
|
||||
DbSnowflakeAuth::Token,
|
||||
"pat-value-1",
|
||||
json!({ "AUTHENTICATOR": "PROGRAMMATIC_ACCESS_TOKEN", "TOKEN": "pat-value-1" }),
|
||||
),
|
||||
(DbSnowflakeAuth::OAuth, "oauth-value-1", json!({ "AUTHENTICATOR": "OAUTH", "TOKEN": "oauth-value-1" })),
|
||||
] {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = auth;
|
||||
let pool = Pool::connect(&source, Some(secret), None).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let data = &seen[0].json()["data"];
|
||||
for (name, value) in expected.as_object().unwrap() {
|
||||
assert_eq!(&data[name], value, "{auth:?} {name}");
|
||||
}
|
||||
if auth == DbSnowflakeAuth::Password {
|
||||
assert!(data.get("AUTHENTICATOR").is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_password_asks_again() {
|
||||
let (listener, port) = bind().await;
|
||||
let refused = r#"{"success":false,"code":"390100","message":"Incorrect username or password was specified."}"#;
|
||||
let server = serve(listener, vec![ok(refused)]);
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Password;
|
||||
let error = failure(&source, Some("pa55word!")).await;
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert_eq!(error.message, "Snowflake [390100]: Incorrect username or password was specified.");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unknown_account_is_named() {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(
|
||||
listener,
|
||||
vec![Reply { status: "403 Forbidden", body: b"<html>Forbidden</html>".to_vec(), delay: Duration::ZERO }],
|
||||
);
|
||||
let error = failure(&source(port), None).await;
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert_eq!(error.message, "Snowflake has no account at 127.0.0.1 (HTTP 403): check the account identifier");
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pages_skip_whole_chunks_and_stop_after_the_page() {
|
||||
let (listener, port) = bind().await;
|
||||
let rowtype =
|
||||
json!([{ "name": "ID", "type": "fixed", "precision": 38, "scale": 0 }, { "name": "D", "type": "date" }]);
|
||||
let chunk = |n: u16| format!("http://127.0.0.1:{port}/chunk{n}");
|
||||
let body = json!({ "success": true, "data": {
|
||||
"rowtype": rowtype, "rowset": [["1", "0"], ["2", "1"]], "queryResultFormat": "json", "statementTypeId": 4096,
|
||||
"chunks": [
|
||||
{ "url": chunk(1), "rowCount": 2 }, { "url": chunk(2), "rowCount": 3 }, { "url": chunk(3), "rowCount": 2 },
|
||||
],
|
||||
"chunkHeaders": { "x-amz-server-side-encryption-customer-key": "key-1" },
|
||||
} })
|
||||
.to_string();
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
std::io::Write::write_all(&mut gz, br#"["5","4"],["6","5"],["7",null]"#).unwrap();
|
||||
let second = Reply { status: "200 OK", body: gz.finish().unwrap(), delay: Duration::ZERO };
|
||||
// Rows 5 and 6 (skip 4, keep 2): the first chunk is skipped, the third never fetched.
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(body), second, ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let page = pool.execute_from("select id, d from t", 4, 2, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(
|
||||
page.rows,
|
||||
vec![vec![Some("5".into()), Some("1970-01-05".into())], vec![Some("6".into()), Some("1970-01-06".into())]]
|
||||
);
|
||||
assert!(page.has_more);
|
||||
assert_eq!(page.columns[1].type_name, "DATE");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen[1].json()["parameters"]["ROWS_PER_RESULTSET"], 7);
|
||||
assert_eq!(seen[2].line(), "GET /chunk2 HTTP/1.1");
|
||||
assert_eq!(seen[2].header("x-amz-server-side-encryption-customer-key"), Some("key-1"));
|
||||
assert_eq!(seen[2].header("authorization"), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_long_statement_is_polled_until_it_finishes() {
|
||||
let (listener, port) = bind().await;
|
||||
let running = |code: &str| {
|
||||
json!({ "success": true, "code": code, "message": "Query execution in progress.",
|
||||
"data": { "getResultUrl": "/queries/01b2c3/result", "queryId": "01b2c3" } })
|
||||
.to_string()
|
||||
};
|
||||
let server =
|
||||
serve(listener, vec![ok(LOGIN_OK), ok(running("333333")), ok(running("333334")), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let done = pool.execute("call system$wait(60)", 5, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(done.rows, vec![vec![Some("1".to_owned())]]);
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
for poll in &seen[2..4] {
|
||||
assert_eq!(poll.line(), "GET /queries/01b2c3/result HTTP/1.1");
|
||||
assert_eq!(poll.header("authorization"), Some("Snowflake Token=\"session-token-1\""));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_expired_session_token_is_renewed_and_the_statement_repeated() {
|
||||
let (listener, port) = bind().await;
|
||||
let expired = r#"{"success":false,"code":"390112","message":"Your session has expired. Please login again."}"#;
|
||||
let renewed = r#"{"success":true,"data":{"sessionToken":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(expired), ok(renewed), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let done = pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(done.rows.len(), 1);
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/token-request?requestId="), "{}", seen[2].line());
|
||||
assert_eq!(seen[2].header("authorization"), Some("Snowflake Token=\"master-token-1\""));
|
||||
assert_eq!(seen[2].json(), json!({ "oldSessionToken": "session-token-1", "requestType": "RENEW" }));
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_lost_session_signs_in_again() {
|
||||
let (listener, port) = bind().await;
|
||||
let gone = r#"{"success":false,"code":"390114","message":"Authentication token has expired. The user must authenticate again."}"#;
|
||||
let again = r#"{"success":true,"data":{"token":"session-token-3","masterToken":"master-token-3"}}"#;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(gone), ok(again), ok(one()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
pool.execute("select 1", 5, &CancellationToken::new()).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
|
||||
assert_eq!(seen[3].header("authorization"), Some("Snowflake Token=\"session-token-3\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancelling_aborts_the_statement_on_the_server() {
|
||||
let (listener, port) = bind().await;
|
||||
let slow = Reply { status: "200 OK", body: one().into_bytes(), delay: Duration::from_secs(30) };
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), slow, ok(r#"{"success":true,"data":null}"#), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let statement = pool.execute("call system$wait(600)", 5, &cancel);
|
||||
let stop = async {
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
cancel.cancel();
|
||||
};
|
||||
let (outcome, ()) = tokio::join!(statement, stop);
|
||||
let error = outcome.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(error.message, "cancelled");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let request_id =
|
||||
|line: &str| line.split(['?', '&', ' ']).find_map(|p| p.strip_prefix("requestId=")).unwrap().to_owned();
|
||||
assert!(seen[2].line().starts_with("POST /queries/v1/abort-request?"), "{}", seen[2].line());
|
||||
let abort = seen[2].json();
|
||||
assert_eq!(abort["sqlText"], "call system$wait(600)");
|
||||
assert_eq!(abort["requestId"], request_id(seen[1].line()));
|
||||
assert_ne!(request_id(seen[2].line()), request_id(seen[1].line()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changes_report_counts_and_commands_report_nothing() {
|
||||
let (listener, port) = bind().await;
|
||||
let merged = json!({ "success": true, "data": {
|
||||
"rowtype": [{ "name": "number of rows inserted", "type": "fixed" }, { "name": "number of rows updated", "type": "fixed" }],
|
||||
"rowset": [["3", "4"]], "queryResultFormat": "json", "statementTypeId": 0x3400 } })
|
||||
.to_string();
|
||||
let created = json!({ "success": true, "data": {
|
||||
"rowtype": [{ "name": "status", "type": "text" }], "rowset": [["Table T successfully created."]],
|
||||
"queryResultFormat": "json", "statementTypeId": 0x6100 } })
|
||||
.to_string();
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(merged), ok(created), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let merge = pool.execute("merge into t using s on …", 10, &cancel).await.unwrap();
|
||||
assert_eq!((merge.affected, merge.columns.len(), merge.rows.len()), (Some(7.0), 0, 0));
|
||||
let create = pool.execute("create table t (id int)", 10, &cancel).await.unwrap();
|
||||
assert_eq!((create.affected, create.columns.len(), create.rows.len()), (Some(0.0), 0, 0));
|
||||
pool.close().await;
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_read_only_data_source_refuses_writes_without_sending_them() {
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(one()), ok(one()), ok(CLOSED_OK)]);
|
||||
let mut source = source(port);
|
||||
source.read_only = true;
|
||||
source.options.startup_script = "alter session set week_start = 1".into();
|
||||
let pool = Pool::connect(&source, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
for write in
|
||||
["delete from t", " -- note\n /* x */ INSERT INTO t VALUES (1)", "create table x (i int)", "// c\ncall p()"]
|
||||
{
|
||||
let error = pool.execute(write, 1, &cancel).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(error.message.starts_with("read-only data source: "), "{}", error.message);
|
||||
}
|
||||
assert!(pool.transaction(&[("update t set a = 1".into(), true)]).await.is_err());
|
||||
pool.execute("-- reads pass\nwith x as (select 1) select * from x", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen[1].json()["sqlText"], "alter session set week_start = 1");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn table_edits_commit_together_or_roll_back() {
|
||||
let changed = |n: &str| {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "number of rows updated", "type": "fixed" }],
|
||||
"rowset": [[n]], "queryResultFormat": "json", "statementTypeId": 0x3200 } })
|
||||
.to_string()
|
||||
};
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
let edits =
|
||||
[("update t set a = 1 where id = 1".to_owned(), true), ("update t set a = 2 where id = 2".to_owned(), true)];
|
||||
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(
|
||||
listener,
|
||||
vec![ok(LOGIN_OK), ok(status()), ok(changed("1")), ok(changed("1")), ok(status()), ok(CLOSED_OK)],
|
||||
);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
assert_eq!(pool.transaction(&edits).await.unwrap(), 2);
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..5].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(sent, ["BEGIN", "update t set a = 1 where id = 1", "update t set a = 2 where id = 2", "COMMIT"]);
|
||||
|
||||
let (listener, port) = bind().await;
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(status()), ok(changed("0")), ok(status()), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let error = pool.transaction(&edits).await.unwrap_err();
|
||||
assert!(error.message.contains("expected to change 1 row, changed 0; nothing was saved"), "{}", error.message);
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..4].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(sent, ["BEGIN", "update t set a = 1 where id = 1", "ROLLBACK"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_arrow_result_is_refused_instead_of_shown_empty() {
|
||||
let (listener, port) = bind().await;
|
||||
let arrow = json!({ "success": true, "data": { "rowtype": [{ "name": "N", "type": "fixed" }],
|
||||
"rowsetBase64": "QVJST1c=", "queryResultFormat": "arrow", "statementTypeId": 4096 } })
|
||||
.to_string();
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(arrow), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let error = pool.execute("select 1", 1, &CancellationToken::new()).await.unwrap_err();
|
||||
assert_eq!(error.kind, ErrorKind::Protocol);
|
||||
pool.close().await;
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_only_knows_reads_from_writes() {
|
||||
for read in [
|
||||
"select 1",
|
||||
"WITH a AS (select 1) select * from a",
|
||||
"show tables",
|
||||
"desc table t",
|
||||
"explain select 1",
|
||||
"ls @stage",
|
||||
"use schema s",
|
||||
"",
|
||||
] {
|
||||
assert!(!is_write(read), "{read}");
|
||||
}
|
||||
for write in [
|
||||
"insert into t values (1)",
|
||||
"COPY INTO t FROM @s",
|
||||
"put file://x @s",
|
||||
"truncate t",
|
||||
"grant select on t to role r",
|
||||
"begin",
|
||||
] {
|
||||
assert!(is_write(write), "{write}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn browser_sign_in_presents_the_token_and_proof_key_then_reuses_the_id_token() {
|
||||
let (listener, port) = bind().await;
|
||||
let page =
|
||||
r#"{"success":true,"data":{"ssoUrl":"https://idp.example/sso?SAMLRequest=abc","proofKey":"proof-key-1"}}"#;
|
||||
let signed_in =
|
||||
r#"{"success":true,"data":{"token":"session-token-1","masterToken":"master-token-1","idToken":"id-token-1"}}"#;
|
||||
let again = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let stale = r#"{"success":false,"code":"390195","message":"The provided ID Token is invalid."}"#;
|
||||
let server = serve(
|
||||
listener,
|
||||
vec![
|
||||
// First connection: the browser.
|
||||
ok(page),
|
||||
ok(signed_in),
|
||||
ok(CLOSED_OK),
|
||||
// Second connection: the ID token alone.
|
||||
ok(again),
|
||||
ok(CLOSED_OK),
|
||||
// Third: the ID token is refused, so the browser again.
|
||||
ok(stale),
|
||||
ok(page),
|
||||
ok(signed_in),
|
||||
ok(CLOSED_OK),
|
||||
],
|
||||
);
|
||||
let opened = Arc::new(parking_lot::Mutex::new(Vec::<String>::new()));
|
||||
let pages = opened.clone();
|
||||
// The stand-in browser: the identity provider sends it back to the listener with a token.
|
||||
*browser::TEST_OPENER.lock() = Some(Box::new(move |url, redirect_port| {
|
||||
pages.lock().push(url.to_owned());
|
||||
tokio::spawn(async move {
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", redirect_port)).await.unwrap();
|
||||
// A browser also asks for the tab's icon; that request must not end the sign-in.
|
||||
stream.write_all(b"GET /favicon.ico HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).await.unwrap();
|
||||
assert!(reply.starts_with(b"HTTP/1.1 404"));
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", redirect_port)).await.unwrap();
|
||||
stream.write_all(b"GET /?token=sso%2Btoken-1 HTTP/1.1\r\nHost: localhost\r\n\r\n").await.unwrap();
|
||||
let mut reply = Vec::new();
|
||||
stream.read_to_end(&mut reply).await.unwrap();
|
||||
let reply = String::from_utf8_lossy(&reply).into_owned();
|
||||
assert!(reply.starts_with("HTTP/1.1 200 OK"), "{reply}");
|
||||
assert!(reply.contains("return to OpenIDEA"), "{reply}");
|
||||
});
|
||||
Ok(())
|
||||
}));
|
||||
let mut source = source(port);
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Browser;
|
||||
source.snowflake.as_mut().unwrap().private_key_file = String::new();
|
||||
for _ in 0..3 {
|
||||
let pool = Pool::connect(&source, None, None).await.unwrap();
|
||||
pool.close().await;
|
||||
}
|
||||
*browser::TEST_OPENER.lock() = None;
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(*opened.lock(), ["https://idp.example/sso?SAMLRequest=abc"; 2]);
|
||||
|
||||
assert!(seen[0].line().starts_with("POST /session/authenticator-request?requestId="), "{}", seen[0].line());
|
||||
let asked = &seen[0].json()["data"];
|
||||
assert_eq!(asked["AUTHENTICATOR"], "EXTERNALBROWSER");
|
||||
assert_eq!(asked["LOGIN_NAME"], "jane");
|
||||
assert_eq!(asked["ACCOUNT_NAME"], "127");
|
||||
assert!(asked["BROWSER_MODE_REDIRECT_PORT"].as_str().unwrap().parse::<u16>().unwrap() > 0);
|
||||
|
||||
let first = &seen[1].json()["data"];
|
||||
assert_eq!(first["AUTHENTICATOR"], "EXTERNALBROWSER");
|
||||
assert_eq!(first["TOKEN"], "sso+token-1");
|
||||
assert_eq!(first["PROOF_KEY"], "proof-key-1");
|
||||
assert_eq!(first["SESSION_PARAMETERS"]["CLIENT_STORE_TEMPORARY_CREDENTIAL"], true);
|
||||
assert!(first.get("PASSWORD").is_none());
|
||||
|
||||
let second = &seen[3].json()["data"];
|
||||
assert_eq!(second["AUTHENTICATOR"], "ID_TOKEN");
|
||||
assert_eq!(second["TOKEN"], "id-token-1");
|
||||
assert!(second.get("PROOF_KEY").is_none());
|
||||
|
||||
assert_eq!(seen[5].json()["data"]["AUTHENTICATOR"], "ID_TOKEN");
|
||||
assert!(seen[6].line().starts_with("POST /session/authenticator-request?"), "{}", seen[6].line());
|
||||
assert_eq!(seen[7].json()["data"]["AUTHENTICATOR"], "EXTERNALBROWSER");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_account_without_browser_sign_in_says_so() {
|
||||
let (listener, port) = bind().await;
|
||||
let refused = r#"{"success":false,"code":"390190","message":"There was an error related to the SAML Identity Provider account parameter."}"#;
|
||||
let server = serve(listener, vec![ok(refused)]);
|
||||
let mut source = source(port);
|
||||
source.user = "nobody".into();
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Browser;
|
||||
let error = failure(&source, None).await;
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
error.message.starts_with("Snowflake [390190]: There was an error related to the SAML"),
|
||||
"{}",
|
||||
error.message
|
||||
);
|
||||
server.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_console_schema_is_switched_with_its_statement_and_only_when_needed() {
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
let (listener, port) = bind().await;
|
||||
let mut replies = vec![ok(LOGIN_OK)];
|
||||
// use Sales, 1, 2 | restore PUBLIC, 3 | 4 | use database (the user's), then PUBLIC again, 5
|
||||
for body in [status(), one(), one(), status(), one(), one(), status(), status(), one()] {
|
||||
replies.push(ok(body));
|
||||
}
|
||||
replies.push(ok(CLOSED_OK));
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let mut console = crate::conn::Held::acquire(&pool).await.unwrap();
|
||||
console.apply_context(Some("Sales \"2024\"")).await.unwrap();
|
||||
console.execute(&pool, "select 1", 1, &cancel).await.unwrap();
|
||||
console.execute(&pool, "select 2", 1, &cancel).await.unwrap();
|
||||
// The tree and other consoles run in the data source's schema.
|
||||
pool.execute("select 3", 1, &cancel).await.unwrap();
|
||||
pool.execute("select 4", 1, &cancel).await.unwrap();
|
||||
// After the user's own USE the session may be anywhere: the schema is set again.
|
||||
pool.execute("use database OTHER", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 5", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..10].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(
|
||||
sent,
|
||||
[
|
||||
"USE SCHEMA \"ANALYTICS\".\"Sales \"\"2024\"\"\"",
|
||||
"select 1",
|
||||
"select 2",
|
||||
"USE SCHEMA \"ANALYTICS\".\"PUBLIC\"",
|
||||
"select 3",
|
||||
"select 4",
|
||||
"use database OTHER",
|
||||
"USE SCHEMA \"ANALYTICS\".\"PUBLIC\"",
|
||||
"select 5",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_role_that_may_not_use_the_schema_still_runs_statements() {
|
||||
let status = || {
|
||||
json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string()
|
||||
};
|
||||
let refused = r#"{"success":false,"code":"002043","message":"SQL compilation error:\nObject does not exist, or operation cannot be performed."}"#;
|
||||
let (listener, port) = bind().await;
|
||||
// use role | the schema is refused once, the statement runs | the next one does not ask again |
|
||||
// a role that is not granted | use role back, the schema is entered again
|
||||
let replies = vec![
|
||||
ok(LOGIN_OK),
|
||||
ok(status()),
|
||||
ok(refused),
|
||||
ok(one()),
|
||||
ok(one()),
|
||||
ok(refused),
|
||||
ok(status()),
|
||||
ok(status()),
|
||||
ok(one()),
|
||||
ok(CLOSED_OK),
|
||||
];
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
pool.execute("use role auditor", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 1", 1, &cancel).await.unwrap();
|
||||
pool.execute("select 2", 1, &cancel).await.unwrap();
|
||||
let missing = pool.execute("use role nobody", 0, &cancel).await.unwrap_err();
|
||||
assert!(missing.message.contains("002043") && missing.message.contains("granted"), "{}", missing.message);
|
||||
pool.execute("use role analyst", 0, &cancel).await.unwrap();
|
||||
pool.execute("select 3", 1, &cancel).await.unwrap();
|
||||
pool.close().await;
|
||||
let sent: Vec<String> =
|
||||
server.await.unwrap()[1..9].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
let schema = "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"";
|
||||
assert_eq!(
|
||||
sent,
|
||||
["use role auditor", schema, "select 1", "select 2", "use role nobody", "use role analyst", schema, "select 3"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn databases_of_one_data_source_share_a_login() {
|
||||
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();
|
||||
// One login; `select 1` in ANALYTICS; a switch and `select 2` in "Raw Data"; one session end.
|
||||
let server = serve(listener, vec![ok(LOGIN_OK), ok(one()), ok(status), ok(one()), ok(CLOSED_OK)]);
|
||||
let first = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let mut other = source(port);
|
||||
other.database = "Raw Data".into();
|
||||
other.options.default_schema = String::new();
|
||||
other.read_only = true;
|
||||
let second = Pool::connect(&other, None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
first.execute("select 1", 1, &cancel).await.unwrap();
|
||||
second.execute("select 2", 1, &cancel).await.unwrap();
|
||||
// Read-only is the target's own setting.
|
||||
assert!(second.execute("delete from t", 1, &cancel).await.is_err());
|
||||
first.close().await;
|
||||
assert!(first.is_closed() && !second.is_closed());
|
||||
assert!(first.execute("select 3", 1, &cancel).await.is_err());
|
||||
second.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let sent: Vec<String> = seen[1..4].iter().map(|r| r.json()["sqlText"].as_str().unwrap().to_owned()).collect();
|
||||
assert_eq!(sent, ["select 1", "USE DATABASE \"Raw Data\"", "select 2"]);
|
||||
assert!(seen[4].line().starts_with("POST /session?delete=true"), "{}", seen[4].line());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_runs_on_its_own_session_beside_a_busy_console() {
|
||||
let (listener, port) = bind().await;
|
||||
let slow = Reply { status: "200 OK", body: one().into_bytes(), delay: Duration::from_millis(1500) };
|
||||
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let status = json!({ "success": true, "data": { "rowtype": [{ "name": "status", "type": "text" }],
|
||||
"rowset": [["Statement executed successfully."]], "queryResultFormat": "json", "statementTypeId": 0x5000 } })
|
||||
.to_string();
|
||||
// Console login and its slow statement; the metadata session logs in, enters the schema and answers meanwhile.
|
||||
let server =
|
||||
serve(listener, vec![ok(LOGIN_OK), slow, ok(second), ok(status), ok(one()), ok(CLOSED_OK), ok(CLOSED_OK)]);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let started = Instant::now();
|
||||
let console = pool.execute("call system$wait(60)", 1, &cancel);
|
||||
let tree = async {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
let rows = pool.rows("show terse schemas", 10).await.unwrap();
|
||||
(rows, started.elapsed())
|
||||
};
|
||||
let (console, (rows, tree_took)) = tokio::join!(console, tree);
|
||||
console.unwrap();
|
||||
assert_eq!(rows.len(), 1);
|
||||
assert!(tree_took < Duration::from_millis(1200), "the tree waited for the console: {tree_took:?}");
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
assert!(seen[2].line().starts_with("POST /session/v1/login-request?"), "{}", seen[2].line());
|
||||
assert_eq!(seen[3].json()["sqlText"], "USE SCHEMA \"ANALYTICS\".\"PUBLIC\"");
|
||||
assert_eq!(seen[4].header("authorization"), Some("Snowflake Token=\"session-token-2\""));
|
||||
// Both sessions end with the connection.
|
||||
let ended: Vec<&str> = seen[5..7].iter().filter_map(|r| r.header("authorization")).collect();
|
||||
assert_eq!(ended.len(), 2);
|
||||
assert!(
|
||||
ended.contains(&"Snowflake Token=\"session-token-1\"")
|
||||
&& ended.contains(&"Snowflake Token=\"session-token-2\"")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_database_is_introspected_schema_by_schema_when_its_whole_listing_is_refused() {
|
||||
let (listener, port) = bind().await;
|
||||
let second = r#"{"success":true,"data":{"token":"session-token-2","masterToken":"master-token-2"}}"#;
|
||||
let text = |name: &str| json!({ "name": name, "type": "text" });
|
||||
let terse = json!([text("created_on"), text("name"), text("kind"), text("database_name"), text("schema_name")]);
|
||||
let schemas = result(
|
||||
terse.clone(),
|
||||
json!([
|
||||
["", "INFORMATION_SCHEMA", null, "RAW", null],
|
||||
["", "PUBLIC", null, "RAW", null],
|
||||
["", "STAGE", null, "RAW", null]
|
||||
]),
|
||||
);
|
||||
let refused = r#"{"success":false,"code":"001003","message":"SQL compilation error: unsupported listing"}"#;
|
||||
let public = result(
|
||||
terse.clone(),
|
||||
json!([["", "ORDERS", "TABLE", "RAW", "PUBLIC"], ["", "DAILY", "VIEW", "RAW", "PUBLIC"]]),
|
||||
);
|
||||
let stage = result(terse, json!([["", "ORDERS", "TABLE", "RAW", "STAGE"]]));
|
||||
// Metadata session: login, schema context, the schemas, the refused listing, then each schema.
|
||||
let replies = vec![
|
||||
ok(LOGIN_OK),
|
||||
ok(second),
|
||||
ok(one()),
|
||||
ok(schemas),
|
||||
ok(refused),
|
||||
ok(public),
|
||||
ok(stage),
|
||||
ok(CLOSED_OK),
|
||||
ok(CLOSED_OK),
|
||||
];
|
||||
let server = serve(listener, replies);
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let Pool::Snowflake(snowflake) = &pool else { panic!("not a Snowflake pool") };
|
||||
let catalog = super::meta::catalog(snowflake, "RAW").await.unwrap();
|
||||
let names = |i: usize| catalog[i].tables.iter().map(|t| t.name.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(catalog.iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["PUBLIC", "STAGE"]);
|
||||
assert_eq!(names(0), ["DAILY", "ORDERS"]);
|
||||
assert_eq!(names(1), ["ORDERS"]);
|
||||
pool.close().await;
|
||||
let seen = server.await.unwrap();
|
||||
let sql = |i: usize| seen[i].json()["sqlText"].as_str().unwrap_or_default().to_owned();
|
||||
assert_eq!(sql(3), "SHOW TERSE SCHEMAS IN DATABASE \"RAW\" LIMIT 10000");
|
||||
assert_eq!(sql(4), "SHOW TERSE OBJECTS IN DATABASE \"RAW\" LIMIT 10000");
|
||||
assert_eq!(sql(5), "SHOW TERSE OBJECTS IN SCHEMA \"RAW\".\"PUBLIC\" LIMIT 10000");
|
||||
assert_eq!(sql(6), "SHOW TERSE OBJECTS IN SCHEMA \"RAW\".\"STAGE\" LIMIT 10000");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn targets_opened_at_the_same_time_sign_in_once() {
|
||||
let (listener, port) = bind().await;
|
||||
// Had each connect signed in, the server would be asked for more than this one login.
|
||||
let slow_login = Reply { status: "200 OK", body: LOGIN_OK.as_bytes().to_vec(), delay: Duration::from_millis(300) };
|
||||
let server = serve(listener, vec![slow_login, ok(CLOSED_OK)]);
|
||||
let base = source(port);
|
||||
let mut others = Vec::new();
|
||||
for database in ["RAW", "STAGING", "MARTS"] {
|
||||
let mut other = source(port);
|
||||
other.database = database.into();
|
||||
others.push(other);
|
||||
}
|
||||
let (a, b, c, d) = tokio::join!(
|
||||
Pool::connect(&base, None, None),
|
||||
Pool::connect(&others[0], None, None),
|
||||
Pool::connect(&others[1], None, None),
|
||||
Pool::connect(&others[2], None, None),
|
||||
);
|
||||
let pools = [a.unwrap(), b.unwrap(), c.unwrap(), d.unwrap()];
|
||||
for pool in &pools {
|
||||
pool.close().await;
|
||||
}
|
||||
let seen = server.await.unwrap();
|
||||
assert_eq!(seen.len(), 2);
|
||||
assert!(seen[0].line().starts_with("POST /session/v1/login-request?"), "{}", seen[0].line());
|
||||
assert!(seen[1].line().starts_with("POST /session?delete=true"), "{}", seen[1].line());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_sign_on_tokens_are_keyed_by_account_and_user_and_only_for_browser_sign_in() {
|
||||
let mut source = source(443);
|
||||
source.host = "myorg-myaccount".into();
|
||||
source.port = None;
|
||||
source.ssl = DbSslMode::Prefer;
|
||||
assert_eq!(sso_key(&source), None);
|
||||
source.snowflake.as_mut().unwrap().auth = DbSnowflakeAuth::Browser;
|
||||
let key = sso_key(&source).unwrap();
|
||||
assert_eq!(key, "myorg-myaccount.snowflakecomputing.com:443|JANE");
|
||||
assert_eq!(sso_token(&key), None);
|
||||
sso_seed(&key, "saved-id-token");
|
||||
assert_eq!(sso_token(&key).as_deref(), Some("saved-id-token"));
|
||||
// A token already in use is not replaced by an older saved one.
|
||||
sso_seed(&key, "stale");
|
||||
assert_eq!(sso_token(&key).as_deref(), Some("saved-id-token"));
|
||||
browser::forget(&key);
|
||||
}
|
||||
@@ -22,7 +22,6 @@ pub enum DbDriver {
|
||||
Clickhouse,
|
||||
/// Oracle Database (pure-Rust thin driver, no client library).
|
||||
Oracle,
|
||||
Snowflake,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -34,7 +33,6 @@ impl DbDriver {
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
DbDriver::Snowflake => Some(443),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,37 +131,6 @@ 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;
|
||||
|
||||
@@ -218,8 +185,6 @@ 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,
|
||||
@@ -257,7 +222,6 @@ impl Default for DataSource {
|
||||
options: DbOptions::default(),
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
snowflake: None,
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
@@ -309,7 +273,6 @@ 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,
|
||||
}
|
||||
}
|
||||
@@ -412,9 +375,6 @@ 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() {
|
||||
@@ -445,35 +405,6 @@ 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 {
|
||||
@@ -682,72 +613,4 @@ 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,7 +32,6 @@ 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);
|
||||
@@ -58,10 +57,6 @@ 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;
|
||||
@@ -96,29 +91,11 @@ 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,
|
||||
'\'' => {
|
||||
@@ -155,16 +132,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
i += 1;
|
||||
}
|
||||
'#' if mysql => state = State::LineComment,
|
||||
// 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 => {
|
||||
'$' if driver == DbDriver::Postgres => {
|
||||
// `$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| {
|
||||
@@ -183,31 +151,6 @@ 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;
|
||||
@@ -240,7 +183,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
_ => {}
|
||||
},
|
||||
State::Single => {
|
||||
if (mysql || clickhouse || snowflake) && c == '\\' {
|
||||
if (mysql || clickhouse) && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '\'' {
|
||||
if next == Some('\'') {
|
||||
@@ -338,20 +281,6 @@ 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())
|
||||
@@ -538,68 +467,4 @@ 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 | DbDriver::Snowflake) {
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
|
||||
s = s.replace('\\', "\\\\");
|
||||
}
|
||||
format!("'{s}'")
|
||||
@@ -75,19 +75,12 @@ 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 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"
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' 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 \
|
||||
@@ -95,10 +88,9 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
DbDriver::Mysql => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
@@ -125,11 +117,6 @@ 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",
|
||||
@@ -155,11 +142,6 @@ 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));
|
||||
@@ -200,10 +182,6 @@ 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 \
|
||||
@@ -252,7 +230,6 @@ 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 \
|
||||
@@ -319,15 +296,6 @@ 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'), \
|
||||
@@ -352,7 +320,6 @@ 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 \
|
||||
@@ -403,7 +370,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 | DbDriver::Snowflake => foreign_keys(driver).to_owned(),
|
||||
DbDriver::Clickhouse => 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"
|
||||
),
|
||||
@@ -431,7 +398,6 @@ 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()"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +410,6 @@ 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))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,9 +520,6 @@ 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"),
|
||||
});
|
||||
}
|
||||
@@ -626,7 +588,6 @@ 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)
|
||||
@@ -695,12 +656,6 @@ 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') \
|
||||
@@ -742,10 +697,6 @@ 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",
|
||||
@@ -979,17 +930,6 @@ 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]");
|
||||
@@ -1146,30 +1086,4 @@ 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");
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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
@@ -1,5 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgwYBQrdPGgoV9c58S
|
||||
q6Fc5aso2M4wVyXE9WxrpPzcrHahRANCAAR0SjYZ1Pvc5jG2ll+pTSdBiVRccnSB
|
||||
BirgCQL5f1g4CuEK4YNqTIvXMqJ2bBVx6f2MTbLeCmu8EebiJ99dI/ZM
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -1,28 +0,0 @@
|
||||
-----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-----
|
||||
@@ -1,9 +0,0 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz3421PPLmMLaTYbYUXb+
|
||||
4dXCSdadp52/KSizIv0nT0n0mS5tHgNk246eh7Rqr/dXp6wj8svvoUDpdCrX6Dxm
|
||||
8w6sZ82TyuAbXJJsR4gUInxuwNHd46avGNiYyJJDCLZxe5F0KG/PUzmhY/qVvP1x
|
||||
+mPsOXGzGHZH90Vyo2aWES0QcZ8wJeJTIT/26BO63qFrgSMDxVV51/AFT6dG45KU
|
||||
diNX4FCmcTMP5m0YVlcv/W+1Ye/UcDxvOa7womtnuznQy8TWFHBB6HL32/KYVB7S
|
||||
O2b5wiXG1yV+zjffyXVPwzfUR3Pms9WYweGWtXB6C15W51IrEn9Xt7Z2mLmZxqhi
|
||||
zwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
@@ -1,16 +0,0 @@
|
||||
-----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-----
|
||||
@@ -1,30 +0,0 @@
|
||||
-----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-----
|
||||
@@ -1,30 +0,0 @@
|
||||
-----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-----
|
||||
@@ -1,27 +0,0 @@
|
||||
-----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-----
|
||||
@@ -1,30 +0,0 @@
|
||||
-----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-----
|
||||
@@ -1,365 +0,0 @@
|
||||
//! 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");
|
||||
}
|
||||
@@ -46,7 +46,6 @@ 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
|
||||
|
||||
@@ -84,9 +84,6 @@ pub struct Host {
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
/// What child processes get beyond this process's environment (Reload Environment, `remoteEnv`).
|
||||
pub environment: environment::Environment,
|
||||
/// The built-in web server (Open In › Browser, the editor's browser bar). On a remote host the
|
||||
/// client forwards its ports.
|
||||
pub web: ide_web::WebServer,
|
||||
/// The HTTP proxy's saved password (see `proxy.rs`).
|
||||
proxy_password: ide_ssh::secrets::SecretFile,
|
||||
/// The proxy password when it could not be saved (no credential store).
|
||||
@@ -143,7 +140,6 @@ impl Host {
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
environment: environment::Environment::new(project_environment, local_data_dir.clone()),
|
||||
web: ide_web::WebServer::new(),
|
||||
global_settings,
|
||||
dependency_versions: Arc::new(ide_workspace::dependencies::VersionCache::new(
|
||||
data_dir.join("cache/dependency-versions"),
|
||||
|
||||
@@ -5,9 +5,9 @@ use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::registry::{AgentRegistry, RegistryPaths};
|
||||
use ide_agent::{
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentChanges, AgentChangesView, AgentCommand, AgentEntry,
|
||||
AgentEvent, AgentProbe, AgentPrompt, AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession,
|
||||
McpScope, McpServer, McpServerInput, NewAgentTask,
|
||||
AcpImportOutcome, AgentAcceptOutcome, AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentProbe, AgentPrompt,
|
||||
AgentProvider, AgentQueued, AgentTask, CustomAgentInput, ExternalSession, McpScope, McpServer, McpServerInput,
|
||||
NewAgentTask,
|
||||
};
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
@@ -304,28 +304,16 @@ pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<A
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// The task's uncommitted changes and the commits made since it started.
|
||||
pub async fn grouped_changes(host: &Host, window: &str, task: String) -> IdeResult<AgentChanges> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.grouped_changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as `view` compares them.
|
||||
pub async fn rollback_changes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
task: String,
|
||||
paths: Option<Vec<String>>,
|
||||
view: Option<AgentChangesView>,
|
||||
) -> IdeResult<()> {
|
||||
/// Rollback: the task's changed files among `paths` (all without) back as they were when it started.
|
||||
pub async fn rollback_changes(host: &Host, window: &str, task: String, paths: Option<Vec<String>>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback_changes(&task, paths, view.unwrap_or_default()).await
|
||||
host.project(window)?.agents.rollback_changes(&task, paths).await
|
||||
}
|
||||
|
||||
/// Accept: copies a worktree task's changed files among `paths` (all without) into the
|
||||
@@ -488,10 +476,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_grouped_changes", |c| async move { grouped_changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
d.add("agent_rollback_changes", |c| async move {
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?, c.arg("view")?).await
|
||||
rollback_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
});
|
||||
d.add("agent_accept_changes", |c| async move {
|
||||
accept_changes(&c.host, &c.window, c.arg("task")?, c.arg("paths")?).await
|
||||
|
||||
@@ -71,22 +71,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
let id = c.arg::<String>("id")?;
|
||||
databases(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_database_catalog", |c| async move {
|
||||
let (id, database) = (c.arg::<String>("id")?, c.arg::<String>("database")?);
|
||||
database_catalog(&c.host, &c.window, &id, &database).await
|
||||
});
|
||||
d.add("db_saved_catalog", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
saved_catalog(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_retain_catalog", |c| async move {
|
||||
let (id, databases) = (c.arg::<String>("id")?, c.arg::<Vec<String>>("databases")?);
|
||||
retain_catalog(&c.host, &c.window, &id, databases).await
|
||||
});
|
||||
d.add("db_session_context", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
session_context(&c.host, &c.window, &id).await
|
||||
});
|
||||
d.add("db_schemas", |c| async move {
|
||||
let id = c.arg::<String>("id")?;
|
||||
schemas(&c.host, &c.window, &id).await
|
||||
@@ -262,35 +246,6 @@ pub async fn databases(host: &Host, window: &str, id: &str) -> IdeResult<Databas
|
||||
db.databases(id).await
|
||||
}
|
||||
|
||||
/// The schemas of `database` of Snowflake data source `id` with their tables and views.
|
||||
pub async fn database_catalog(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: &str,
|
||||
database: &str,
|
||||
) -> IdeResult<Vec<ide_db::CatalogSchema>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.database_catalog(id, database).await
|
||||
}
|
||||
|
||||
/// What earlier introspection of data source `id` saved; the data source is not connected to.
|
||||
pub async fn saved_catalog(host: &Host, window: &str, id: &str) -> IdeResult<Option<ide_db::SourceCatalog>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.saved_catalog(id).await
|
||||
}
|
||||
|
||||
/// Drops the saved databases of data source `id` that are not in `databases` any more.
|
||||
pub async fn retain_catalog(host: &Host, window: &str, id: &str, databases: Vec<String>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.retain_catalog(id, databases).await
|
||||
}
|
||||
|
||||
/// The warehouse and role of the Snowflake session of data source `id`, and what it may switch to.
|
||||
pub async fn session_context(host: &Host, window: &str, id: &str) -> IdeResult<ide_db::SessionContext> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.session_context(id).await
|
||||
}
|
||||
|
||||
/// The schemas of data source `id`.
|
||||
pub async fn schemas(host: &Host, window: &str, id: &str) -> IdeResult<Vec<String>> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
|
||||
@@ -35,7 +35,6 @@ pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
pub mod vcs;
|
||||
pub mod web;
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
|
||||
@@ -164,21 +164,10 @@ pub fn set_settings(
|
||||
// A LightEdit window has no project layer: its values are global.
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.as_ref().is_some_and(|p| !p.light);
|
||||
let is_proxy = key == "proxy" || key.starts_with("proxy.");
|
||||
let Some(mut value) = host.take_proxy_password(key, value)? else {
|
||||
let Some(value) = host.take_proxy_password(key, value)? else {
|
||||
host.apply_proxy();
|
||||
return Ok(SettingsApplied { settings: settings(host, window), project_scope, roots_changed: None });
|
||||
};
|
||||
// One canonical spelling per linked POM, including a project outside the opened root.
|
||||
if project_scope
|
||||
&& key == "languages.java.maven.linkedProjects"
|
||||
&& let Some(project) = current.as_ref()
|
||||
&& let Some(list) = value.as_array()
|
||||
{
|
||||
let poms: Vec<String> = list.iter().filter_map(|item| item.as_str().map(str::to_owned)).collect();
|
||||
if poms.len() == list.len() {
|
||||
value = serde_json::json!(ide_build::maven::normalize_linked_poms(&project.root, &poms));
|
||||
}
|
||||
}
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::Host;
|
||||
use ide_core::remote_settings::RunTargetKind;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
@@ -139,24 +138,11 @@ pub fn save_configs(
|
||||
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
/// The project `config` runs in (a workspace member, by its working directory) and its
|
||||
/// settings, with the primary project as the inherited fallback: a member with no Python
|
||||
/// of its own runs on the primary project's interpreter (see `apply_to_run_configs_in`).
|
||||
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
|
||||
fn settings_for(
|
||||
host: &Host,
|
||||
project: &crate::project::Project,
|
||||
config: &mut RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings, Option<std::path::PathBuf>)> {
|
||||
let (root, settings) = settings_only(host, project, config)?;
|
||||
let fallback = (root != project.root).then_some(project.root.clone());
|
||||
Ok((root, settings, fallback))
|
||||
}
|
||||
|
||||
/// [`settings_for`] without the fallback, for callers that only rebase or read settings.
|
||||
fn settings_only(
|
||||
host: &Host,
|
||||
project: &crate::project::Project,
|
||||
config: &mut RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&project.root)?;
|
||||
let owner = ide_run::scope::owner(&project.root, config);
|
||||
@@ -194,8 +180,8 @@ pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> Id
|
||||
if c.kind == "gradle" {
|
||||
return Ok(c);
|
||||
}
|
||||
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
|
||||
Ok(ide_sdk::apply::without_derived_in(&c, &root, fallback.as_deref(), &settings))
|
||||
let (root, settings) = settings_for(host, &project, &mut c)?;
|
||||
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -208,13 +194,8 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if c.kind != "gradle" {
|
||||
let (root, settings, fallback) = settings_for(host, &project, &mut c)?;
|
||||
ide_sdk::apply::apply_to_run_configs_in(
|
||||
std::slice::from_mut(&mut c),
|
||||
&root,
|
||||
fallback.as_deref(),
|
||||
&settings,
|
||||
);
|
||||
let (root, settings) = settings_for(host, &project, &mut c)?;
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
|
||||
}
|
||||
Ok(c)
|
||||
})
|
||||
@@ -348,7 +329,7 @@ async fn start_inner(
|
||||
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
|
||||
}
|
||||
let mut config = config;
|
||||
let (root, settings, fallback) = settings_for(host, &project, &mut config)?;
|
||||
let (root, settings) = settings_for(host, &project, &mut config)?;
|
||||
let root = &root;
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
// Maven re-applies too: a config listed before a settings change (daemon toggle,
|
||||
@@ -357,38 +338,17 @@ async fn start_inner(
|
||||
if config.kind == "maven" {
|
||||
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
|
||||
} else if matches!(config.kind.as_str(), "gradle" | "tomcat" | "junit") {
|
||||
ide_sdk::apply::apply_to_run_configs_in(
|
||||
std::slice::from_mut(&mut config),
|
||||
root,
|
||||
fallback.as_deref(),
|
||||
&settings,
|
||||
);
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
}
|
||||
if config.kind == "go" {
|
||||
ide_sdk::apply::apply_to_run_configs_in(
|
||||
std::slice::from_mut(&mut config),
|
||||
root,
|
||||
fallback.as_deref(),
|
||||
&settings,
|
||||
);
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
|
||||
}
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
// A Python configuration pointing at a missing interpreter fails in the depth of
|
||||
// process spawn otherwise; name the cause and the fix like PyCharm's "Invalid SDK".
|
||||
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
|
||||
&& !Path::new(&config.command).is_file()
|
||||
&& Path::new(&config.command).components().count() > 1
|
||||
{
|
||||
return Err(IdeError::invalid(format!(
|
||||
"The configured Python interpreter does not exist: {}\nSelect a working interpreter in Settings › Languages & Frameworks › Python Interpreter.",
|
||||
config.command
|
||||
)));
|
||||
}
|
||||
if config.kind == "tomcat" {
|
||||
if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("This Tomcat configuration is already running"));
|
||||
|
||||
@@ -69,14 +69,7 @@ pub fn sdk_python_effective(host: &Host, window: &str, near: Option<PathBuf>) ->
|
||||
}
|
||||
None => (project.root.clone(), project.settings.effective()),
|
||||
};
|
||||
// A member with no Python of its own falls back to the primary project's
|
||||
// (the same chain run configurations use), so every answer names one interpreter.
|
||||
let interpreter = ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).or_else(|| {
|
||||
(root != project.root)
|
||||
.then(|| ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()))
|
||||
.flatten()
|
||||
});
|
||||
Ok(interpreter.map(Into::into))
|
||||
Ok(ide_sdk::apply::python_interpreter_near(&root, near.as_deref(), &settings).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
|
||||
|
||||
@@ -48,91 +48,6 @@ pub fn spawn(
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Tools › Start SSH Session: a login shell on SSH configuration `id`, opened by the
|
||||
/// built-in client with the configuration's saved password, key or agent.
|
||||
pub async fn spawn_ssh(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
on_output: Emit<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
let config = host.ssh.config(id)?;
|
||||
if config.host.trim().is_empty() {
|
||||
return Err(ide_core::IdeError::invalid(format!("SSH configuration {} has no host", config.name)));
|
||||
}
|
||||
let size = (if cols == 0 { 80 } else { cols }, if rows == 0 { 24 } else { rows });
|
||||
let session = host.ssh.session(id).await?;
|
||||
let (feed, output) = ide_terminal::feed();
|
||||
let out = feed.clone();
|
||||
let process = session
|
||||
.spawn(
|
||||
"",
|
||||
Some(size),
|
||||
Box::new(move |chunk| match chunk {
|
||||
ide_ssh::Output::Stdout(data) | ide_ssh::Output::Stderr(data) => out.output(data),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let exit = process.exit_status();
|
||||
let (input, mut inputs) = tokio::sync::mpsc::unbounded_channel();
|
||||
host.runtime.spawn(async move {
|
||||
let p = process;
|
||||
while let Some(op) = inputs.recv().await {
|
||||
let result = match op {
|
||||
SshInput::Data(data) => p.write(&data).await,
|
||||
SshInput::Resize(cols, rows) => p.resize(cols, rows).await,
|
||||
SshInput::Close => {
|
||||
p.close().await;
|
||||
break;
|
||||
}
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("ssh terminal: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
host.runtime.spawn(async move {
|
||||
let code = exit.await;
|
||||
feed.exited(code.and_then(|c| i32::try_from(c).ok()));
|
||||
});
|
||||
let title = if config.name.trim().is_empty() { config.label() } else { config.name.clone() };
|
||||
host.terminals.attach(
|
||||
title,
|
||||
size,
|
||||
Box::new(SshTerminal(input)),
|
||||
output,
|
||||
host.owner_window(window).to_owned(),
|
||||
host.window_sink(window),
|
||||
Box::new(move |chunk| on_output(chunk)),
|
||||
)
|
||||
}
|
||||
|
||||
enum SshInput {
|
||||
Data(Vec<u8>),
|
||||
Resize(u16, u16),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Input of an SSH shell, sent in order by one task.
|
||||
struct SshTerminal(tokio::sync::mpsc::UnboundedSender<SshInput>);
|
||||
|
||||
impl ide_terminal::TerminalChannel for SshTerminal {
|
||||
fn write(&self, data: &[u8]) -> IdeResult<()> {
|
||||
self.0.send(SshInput::Data(data.to_vec())).map_err(|_| ide_core::IdeError::invalid("the SSH session has ended"))
|
||||
}
|
||||
|
||||
fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
let _ = self.0.send(SshInput::Resize(cols, rows));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
let _ = self.0.send(SshInput::Close);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write(host: &Host, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
host.terminals.write(id, data)
|
||||
}
|
||||
|
||||
@@ -161,23 +161,6 @@ pub async fn jdk_download(
|
||||
Ok(sdk)
|
||||
}
|
||||
|
||||
/// The Java version the jdtls this window launches needs; `None` while it isn't
|
||||
/// installed or doesn't tell.
|
||||
pub async fn jdtls_required_java(host: &Host, window: &str) -> IdeResult<Option<u32>> {
|
||||
let settings = match host.project(window) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let Some(mut cfg) =
|
||||
languages(host, window).into_iter().flat_map(|l| l.language_servers).find(|s| s.name == "jdtls")
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
ide_sdk::apply::server_launch_overrides(&mut cfg, &settings);
|
||||
ide_sdk::apply::server_protocol_overrides(&mut cfg, &settings);
|
||||
tokio::task::spawn_blocking(move || ide_sdk::java::jdtls_required_java(&cfg)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
|
||||
@@ -190,7 +173,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
|
||||
d.add("jdtls_required_java", |c| async move { jdtls_required_java(&c.host, &c.window).await });
|
||||
d.add("jdk_download", |c| async move {
|
||||
jdk_download(
|
||||
&c.host,
|
||||
|
||||
@@ -373,25 +373,6 @@ pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<S
|
||||
.await
|
||||
}
|
||||
|
||||
/// The file's git mode at `rev` (`100644`, `100755`, `120000`…); `"WT"` reads the
|
||||
/// worktree's. `None` when the path is absent on that side. The diff viewer shows
|
||||
/// "File mode changed" when the texts compare equal but the modes differ.
|
||||
pub async fn vcs_file_mode(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window).ok();
|
||||
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
|
||||
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
|
||||
});
|
||||
blocking(move || {
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| r.file_mode_at(&path, rev.as_deref()));
|
||||
}
|
||||
external
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.file_mode_at(&path, rev.as_deref())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -1411,7 +1392,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_file_mode", |c| async move { vcs_file_mode(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move {
|
||||
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
|
||||
});
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
//! The built-in web server's URLs.
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use std::path::Path;
|
||||
|
||||
/// The URL serving `file`, under the window's project when it holds the file.
|
||||
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
host.web.url_for(root.as_deref(), file).await
|
||||
}
|
||||
@@ -142,10 +142,6 @@ impl Host {
|
||||
crate::plugins::injections::configure_lsp(&lsp);
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
|
||||
&root,
|
||||
&settings.effective().languages.java.maven.linked_projects,
|
||||
));
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
@@ -1124,10 +1120,6 @@ impl Project {
|
||||
}
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.lsp.set_content_roots(roots.clone());
|
||||
self.lsp.set_jdtls_import_roots(ide_build::maven_external_roots(
|
||||
&self.root,
|
||||
&self.settings.effective().languages.java.maven.linked_projects,
|
||||
));
|
||||
self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
@@ -1247,9 +1239,8 @@ pub(crate) fn workspace_member_roots(root: &Path) -> Vec<PathBuf> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Workspace members in manifest order, or extra Project Structure directories, Gradle
|
||||
/// project folders outside the project, and directories of Maven POMs linked from
|
||||
/// outside it (`build_cache` is the Gradle sync cache).
|
||||
/// Workspace members in manifest order, or extra Project Structure directories and the
|
||||
/// Gradle project folders outside the project as of the last sync (`build_cache`).
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec<PathBuf> {
|
||||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||||
if workspace.is_container(root) {
|
||||
@@ -1341,10 +1332,7 @@ fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, bui
|
||||
.iter()
|
||||
.chain(settings.project_structure.dependencies.iter())
|
||||
.map(|r| PathBuf::from(r.trim()));
|
||||
for p in user
|
||||
.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle))
|
||||
.chain(ide_build::maven_external_roots(root, &settings.languages.java.maven.linked_projects))
|
||||
{
|
||||
for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) {
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
@@ -1780,26 +1768,6 @@ mod maven_linkage_tests {
|
||||
assert_eq!(super::jdtls_maven_import(root, &settings).1, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_maven_link_is_a_content_root_and_not_its_sibling_module() {
|
||||
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");
|
||||
for dir in [&a, &b, &c] {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
std::fs::write(dir.join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
let mut settings = ide_core::settings::Settings::default();
|
||||
settings.languages.java.maven.linked_projects = vec!["../B/pom.xml".into()];
|
||||
assert_eq!(super::content_roots(&settings, &a, &base.join("cache")), vec![b]);
|
||||
// External links must not replace jdtls's scan of the opened root with a build-file list.
|
||||
assert_eq!(super::jdtls_maven_import(&a, &settings), (Vec::new(), None));
|
||||
settings.languages.java.maven.linked_projects.clear();
|
||||
assert!(super::content_roots(&settings, &a, &base.join("cache")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unlinked_roots_remain_plain_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -265,7 +265,6 @@ impl Dispatcher {
|
||||
ops::run::start_request(&c.host, &c.window, c.arg("config")?, on_output, c.arg("request")?).await
|
||||
})
|
||||
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
|
||||
.add("web_url", |c| async move { ops::web::url(&c.host, &c.window, &c.arg::<PathBuf>("path")?).await })
|
||||
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
|
||||
|
||||
@@ -254,7 +254,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "jdk_download_releases"
|
||||
| "jdk_download_location"
|
||||
| "jdk_download"
|
||||
| "jdtls_required_java"
|
||||
| "run_system_env"
|
||||
| "run_gradle_jvm_args_script"
|
||||
| "proxy_check_connection"
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn external_maven_link_persists_reloads_and_unlink_keeps_files() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("A");
|
||||
let sibling = base.join("B");
|
||||
std::fs::create_dir_all(root.join("core")).unwrap();
|
||||
std::fs::create_dir_all(&sibling).unwrap();
|
||||
let a_pom = "<project><artifactId>a</artifactId><packaging>pom</packaging><modules><module>core</module></modules></project>";
|
||||
let b_pom = "<project><artifactId>b</artifactId></project>";
|
||||
std::fs::write(root.join("pom.xml"), a_pom).unwrap();
|
||||
std::fs::write(root.join("core/pom.xml"), "<project><artifactId>core</artifactId></project>").unwrap();
|
||||
std::fs::write(sibling.join("pom.xml"), b_pom).unwrap();
|
||||
let config = base.join("config");
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: config.clone(),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
|
||||
let written =
|
||||
serde_json::to_string(&vec!["../B/pom.xml".to_owned(), sibling.join("pom.xml").to_string_lossy().into_owned()])
|
||||
.unwrap();
|
||||
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", &written)
|
||||
.unwrap();
|
||||
let saved =
|
||||
|| ide_workspace::SettingsStore::load(&config, Some(&root)).effective().languages.java.maven.linked_projects;
|
||||
let canonical = dunce::canonicalize(sibling.join("pom.xml")).unwrap().to_string_lossy().replace('\\', "/");
|
||||
assert_eq!(saved(), vec![canonical.clone()]);
|
||||
|
||||
let model = ops::build::build_model(&host, "main").await.unwrap();
|
||||
let projects = &model.maven.unwrap().projects;
|
||||
assert_eq!(projects.iter().filter(|p| p.artifact_id == "a").count(), 1);
|
||||
let b = projects.iter().find(|p| p.artifact_id == "b").unwrap();
|
||||
assert!(!b.aggregated);
|
||||
assert_eq!(b.pom, canonical);
|
||||
assert_eq!(dunce::canonicalize(root.join(&b.dir)).unwrap(), sibling);
|
||||
assert_eq!(host.project("main").unwrap().content_roots(), vec![sibling.clone()]);
|
||||
assert_eq!(host.project("main").unwrap().lsp.jdtls_import_roots(), vec![sibling.clone()]);
|
||||
|
||||
let reloaded = ops::build::build_model(&host, "main").await.unwrap();
|
||||
assert_eq!(
|
||||
reloaded.maven.unwrap().projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>(),
|
||||
projects.iter().map(|p| p.pom.clone()).collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(saved(), vec![canonical]);
|
||||
|
||||
ops::project::set_settings(&host, "main", SettingsScope::Project, "languages.java.maven.linkedProjects", "[]")
|
||||
.unwrap();
|
||||
let gone = ops::build::build_model(&host, "main").await.unwrap();
|
||||
assert!(gone.maven.unwrap().projects.iter().all(|p| p.artifact_id != "b"));
|
||||
assert!(host.project("main").unwrap().content_roots().is_empty());
|
||||
assert!(host.project("main").unwrap().lsp.jdtls_import_roots().is_empty());
|
||||
assert_eq!(saved(), Vec::<String>::new());
|
||||
assert_eq!(std::fs::read_to_string(root.join("pom.xml")).unwrap(), a_pom);
|
||||
assert_eq!(std::fs::read_to_string(sibling.join("pom.xml")).unwrap(), b_pom);
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -126,12 +126,6 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
// The built-in web server runs where the project is; the client forwards its port.
|
||||
let root = std::path::PathBuf::from(opened["root"].as_str().unwrap());
|
||||
let url = invoke(&peer, "web_url", json!({ "path": root.join("src/main.py") })).await.unwrap();
|
||||
let url = url.as_str().unwrap();
|
||||
assert!(url.starts_with("http://127.0.0.1:") && url.ends_with("/demo/src/main.py"), "{url}");
|
||||
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
|
||||
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
|
||||
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_core::{IdeEvent, SharedSink};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_ssh::{SshFiles, SshManager};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::Duration;
|
||||
|
||||
fn read_until(rx: &mpsc::Receiver<String>, needle: &str) -> String {
|
||||
let mut all = String::new();
|
||||
while !all.contains(needle) {
|
||||
match rx.recv_timeout(Duration::from_secs(10)) {
|
||||
Ok(s) => all.push_str(&s),
|
||||
Err(_) => panic!("timed out waiting for {needle:?}; got {all:?}"),
|
||||
}
|
||||
}
|
||||
all
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn ssh_session_opens_a_shell_with_the_saved_password() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let port =
|
||||
ide_ssh::testing::sshd(tmp.path().join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
tmp.path().join("secrets.json"),
|
||||
SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None },
|
||||
));
|
||||
ssh.set_configs(vec![SshConfiguration {
|
||||
id: "box".into(),
|
||||
name: "Build box".into(),
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "dev".into(),
|
||||
auth: SshAuth::Password,
|
||||
save_secret: true,
|
||||
..Default::default()
|
||||
}]);
|
||||
ssh.set_secret("box", Some("secret".into()), true);
|
||||
assert!(ssh.session("box").await.is_err(), "unknown host key first");
|
||||
ssh.trust_host("box").await.unwrap();
|
||||
|
||||
let (events_tx, events) = mpsc::channel();
|
||||
let events_tx = Mutex::new(events_tx);
|
||||
let sink: SharedSink = Arc::new(move |e: IdeEvent| {
|
||||
let _ = events_tx.lock().unwrap().send(e);
|
||||
});
|
||||
let base = tmp.path().join("app");
|
||||
let mut host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| sink.clone()),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
Arc::get_mut(&mut host).unwrap().ssh = ssh;
|
||||
|
||||
let (tx, output) = mpsc::channel();
|
||||
let tx = Mutex::new(tx);
|
||||
let info = ide_host::ops::term::spawn_ssh(
|
||||
&host,
|
||||
"main",
|
||||
"box",
|
||||
100,
|
||||
30,
|
||||
Arc::new(move |s| {
|
||||
let _ = tx.lock().unwrap().send(s);
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((info.title.as_str(), info.pid), ("Build box", None));
|
||||
assert!(read_until(&output, "$ ").contains("pty 100x30"));
|
||||
host.terminals.write(info.id, "hello ").unwrap();
|
||||
read_until(&output, "hello ");
|
||||
host.terminals.resize(info.id, 120, 40).unwrap();
|
||||
read_until(&output, "resized 120x40");
|
||||
host.terminals.write(info.id, "exit\r").unwrap();
|
||||
let code = loop {
|
||||
match events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
|
||||
IdeEvent::TerminalExited { terminal, code } if terminal == info.id => break code,
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(code, Some(0));
|
||||
assert!(!host.terminals.contains(info.id));
|
||||
|
||||
// Closing the tab hangs up a running shell.
|
||||
let info = ide_host::ops::term::spawn_ssh(&host, "main", "box", 80, 24, Arc::new(|_| {})).await.unwrap();
|
||||
host.terminals.kill(info.id).unwrap();
|
||||
let code = loop {
|
||||
match events.recv_timeout(Duration::from_secs(10)).expect("exit event") {
|
||||
IdeEvent::TerminalExited { terminal, code } if terminal == info.id => break code,
|
||||
_ => {}
|
||||
}
|
||||
};
|
||||
assert_eq!(code, None);
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -53,7 +53,6 @@ async fn application_commands_work_in_restricted_projects_and_without_a_project(
|
||||
"jdk_download_releases",
|
||||
"jdk_download_location",
|
||||
"jdk_download",
|
||||
"jdtls_required_java",
|
||||
"scaffold_catalog",
|
||||
"scaffold_options",
|
||||
"scaffold_requirements",
|
||||
|
||||
@@ -105,56 +105,6 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn python_effective_interpreter_falls_back_to_the_primary_project() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let primary = base.join("primary");
|
||||
let venv_python =
|
||||
if cfg!(windows) { primary.join(".venv/Scripts/python.exe") } else { primary.join(".venv/bin/python") };
|
||||
std::fs::create_dir_all(venv_python.parent().unwrap()).unwrap();
|
||||
std::fs::write(primary.join(".venv/pyvenv.cfg"), "home = /usr/bin\n").unwrap();
|
||||
std::fs::write(&venv_python, "").unwrap();
|
||||
let member = base.join("attached");
|
||||
std::fs::create_dir_all(member.join("src")).unwrap();
|
||||
// The primary project with an attached folder (the "attach" flow, not a container workspace).
|
||||
Workspace::attach(&primary, "Primary", vec![primary.clone(), member.clone()]).unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&primary, true, false).unwrap();
|
||||
host.trust.decide(&member, true, false).unwrap();
|
||||
host.open_project("main", &primary).unwrap();
|
||||
let python = |near: Option<&std::path::Path>| {
|
||||
ops::sdk::sdk_python_effective(&host, "main", near.map(|p| p.to_path_buf())).unwrap().map(|p| p.into_path_buf())
|
||||
};
|
||||
// The primary project's own virtualenv, and the bare member inheriting it.
|
||||
assert_eq!(python(Some(&primary)), Some(venv_python.clone()));
|
||||
assert_eq!(python(Some(&member.join("src"))), Some(venv_python.clone()));
|
||||
// The member's own settings win: an explicit interpreter configured for it
|
||||
// (its file is created here — a missing one would be skipped as invalid).
|
||||
let configured = member.join("selected").join(if cfg!(windows) { "python.exe" } else { "python" });
|
||||
std::fs::create_dir_all(configured.parent().unwrap()).unwrap();
|
||||
std::fs::write(&configured, "").unwrap();
|
||||
ide_workspace::SettingsStore::load(&base.join("config"), Some(&member))
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"languages.python.interpreter",
|
||||
serde_json::to_value(configured.to_string_lossy().into_owned()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(python(Some(&member.join("src"))), Some(configured));
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn php_language_level_follows_the_member_of_the_file() {
|
||||
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
|
||||
|
||||
@@ -55,7 +55,6 @@ tree-sitter-xml = "0.7.0"
|
||||
tree-sitter-haskell = "0.24.1"
|
||||
tree-sitter-dart = "0.2.0"
|
||||
tree-sitter-graphql = "0.3.0"
|
||||
tree-sitter-prisma-io = "1.6.0"
|
||||
tree-sitter-gdscript = "6.1.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
; Prisma schema highlighting (our captures, not the grammar crate's). Later patterns win.
|
||||
|
||||
(identifier) @variable
|
||||
(developer_comment) @comment
|
||||
(comment) @comment.doc
|
||||
(number) @number
|
||||
(string) @string
|
||||
[(true) (false) (null)] @keyword
|
||||
|
||||
(variable) @property
|
||||
(column_declaration . (identifier) @property)
|
||||
(type_expression . (identifier) @variable.parameter)
|
||||
(enumeral (identifier) @constant)
|
||||
|
||||
(column_type (identifier) @type)
|
||||
(column_type (call_expression (identifier) @type))
|
||||
(call_expression (identifier) @function)
|
||||
|
||||
(model_declaration (identifier) @type)
|
||||
(view_declaration (identifier) @type)
|
||||
(type_declaration (identifier) @type)
|
||||
(type_declaration (type_declaration_type) @type)
|
||||
(enum_declaration (identifier) @type)
|
||||
(datasource_declaration (identifier) @type)
|
||||
(generator_declaration (identifier) @type)
|
||||
|
||||
(attribute (identifier) @attribute)
|
||||
(attribute (call_expression (identifier) @attribute))
|
||||
(attribute (member_expression (identifier) @attribute (property_identifier) @attribute))
|
||||
(attribute (call_expression (member_expression (identifier) @attribute (property_identifier) @attribute)))
|
||||
(block_attribute_declaration (identifier) @attribute)
|
||||
(block_attribute_declaration (call_expression (identifier) @attribute))
|
||||
["@" "@@"] @attribute
|
||||
|
||||
["datasource" "enum" "generator" "model" "type" "view"] @keyword
|
||||
|
||||
["(" ")" "[" "]" "{" "}"] @punctuation.bracket
|
||||
["," ":" "."] @punctuation.delimiter
|
||||
(maybe) @operator
|
||||
"=" @operator
|
||||
@@ -1,8 +0,0 @@
|
||||
(model_declaration (identifier) @name) @def.class
|
||||
(view_declaration (identifier) @name) @def.class
|
||||
(type_declaration . (identifier) @name (statement_block)) @def.struct
|
||||
(enum_declaration (identifier) @name) @def.enum
|
||||
(enumeral (identifier) @name) @def.enum_member
|
||||
(datasource_declaration (identifier) @name) @outline.module
|
||||
(generator_declaration (identifier) @name) @outline.module
|
||||
(column_declaration . (identifier) @name) @def.field
|
||||
@@ -24,9 +24,8 @@ pub use registry::{FileTypes, LanguageRegistry};
|
||||
pub use syntax::{
|
||||
CSS_CLASS, CSS_ID, CSS_VARIABLE, ConfigurationKey, ExternalFold, ExternalSymbol, ExternalSyntax, ExternalToken,
|
||||
FileReference, Literal, LiteralKind, MAX_CACHED_TREES, MAX_PARSE_BYTES, ParsedTree, SpellingKind, SpellingRegion,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, VueComponentImport,
|
||||
VueRefAccess, VueRefElement, WebReference, WebReferenceKind, merge_visibility, set_external_syntax, shift_pos,
|
||||
shift_range,
|
||||
Syntax, SyntaxCache, SyntaxCacheStats, SyntaxHighlightKind, SyntaxHighlights, TreeError, WebReference,
|
||||
WebReferenceKind, merge_visibility, set_external_syntax, shift_pos, shift_range,
|
||||
};
|
||||
|
||||
/// Parsed form of `languages/<id>.toml`. Backend-only; the UI gets [`LanguageInfo`].
|
||||
|
||||
@@ -67,7 +67,6 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("php", include_str!("../../../languages/php.toml")),
|
||||
("plaintext", include_str!("../../../languages/plaintext.toml")),
|
||||
("powershell", include_str!("../../../languages/powershell.toml")),
|
||||
("prisma", include_str!("../../../languages/prisma.toml")),
|
||||
("properties", include_str!("../../../languages/properties.toml")),
|
||||
("proto", include_str!("../../../languages/proto.toml")),
|
||||
("python", include_str!("../../../languages/python.toml")),
|
||||
@@ -1027,7 +1026,6 @@ mod tests {
|
||||
("CMakeLists.txt", "cmake"),
|
||||
("api.proto", "proto"),
|
||||
("schema.graphql", "graphql"),
|
||||
("schema.prisma", "prisma"),
|
||||
("main.tf", "terraform"),
|
||||
("prod.tfvars", "terraform-vars"),
|
||||
("job.nomad", "hcl"),
|
||||
|
||||
@@ -430,16 +430,6 @@ pub(crate) static SPECS: &[Spec] = &[
|
||||
imports: NONE,
|
||||
constructors: NONE,
|
||||
},
|
||||
Spec {
|
||||
name: "prisma",
|
||||
language: tree_sitter_prisma_io::LANGUAGE,
|
||||
outline: outline!("prisma"),
|
||||
highlights: Some(Highlights { query: highlights!("prisma"), first_wins: false }),
|
||||
fold_nodes: NONE,
|
||||
fold_bodies: NONE,
|
||||
imports: NONE,
|
||||
constructors: NONE,
|
||||
},
|
||||
Spec {
|
||||
name: "make",
|
||||
language: tree_sitter_make::LANGUAGE,
|
||||
|
||||
@@ -501,28 +501,6 @@ mod tests {
|
||||
("NEWHOPE", K::Constant),
|
||||
],
|
||||
),
|
||||
(
|
||||
"prisma",
|
||||
"// hi\n/// Doc\nmodel User {\n id Int @id @default(autoincrement())\n email String? @db.VarChar(255)\n role Role\n @@index([email])\n}\nenum Role { ADMIN }\ndatasource db {\n provider = \"postgresql\"\n}\n",
|
||||
&[
|
||||
("// hi", K::Comment),
|
||||
("/// Doc", K::DocComment),
|
||||
("model", K::Keyword),
|
||||
("User", K::Type),
|
||||
("id", K::Property),
|
||||
("Int", K::Type),
|
||||
("@id", K::Attribute),
|
||||
("@default", K::Attribute),
|
||||
("autoincrement", K::FunctionCall),
|
||||
("VarChar", K::Attribute),
|
||||
("255", K::Number),
|
||||
("Role", K::Type),
|
||||
("@@index", K::Attribute),
|
||||
("ADMIN", K::Constant),
|
||||
("provider", K::Property),
|
||||
("\"postgresql\"", K::String),
|
||||
],
|
||||
),
|
||||
(
|
||||
"hcl",
|
||||
"# hi\nresource \"aws_instance\" \"web\" {\n ami = var.ami\n count = 2\n tags = { Name = \"web-${var.env}\" }\n name = upper(\"x\")\n}\n",
|
||||
|
||||
@@ -12,7 +12,6 @@ mod outline;
|
||||
mod rst;
|
||||
mod selection;
|
||||
mod sfc;
|
||||
mod vue;
|
||||
mod web;
|
||||
|
||||
use crate::{FoldRange, LanguageConfig, LanguageRegistry, ScriptRegion, SymbolTag};
|
||||
@@ -29,7 +28,6 @@ use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use tree_sitter::{Language, Parser, Tree};
|
||||
pub use vue::{VueComponentImport, VueRefAccess, VueRefElement};
|
||||
pub use web::{CSS_CLASS, CSS_ID, CSS_VARIABLE, FileReference, WebReference, WebReferenceKind};
|
||||
|
||||
/// Texts larger than this are not parsed (minified bundles, generated dumps).
|
||||
|
||||
@@ -886,17 +886,6 @@ fn outline_more_languages() {
|
||||
Function GetUser
|
||||
",
|
||||
);
|
||||
check_outline(
|
||||
"prisma",
|
||||
"datasource db {\n provider = \"sqlite\"\n}\nmodel User {\n id Int @id\n @@map(\"users\")\n}\nenum Role {\n ADMIN\n}\n",
|
||||
"
|
||||
Module db
|
||||
Class User
|
||||
Field id
|
||||
Enum Role
|
||||
EnumMember ADMIN
|
||||
",
|
||||
);
|
||||
check_outline(
|
||||
"nix",
|
||||
"{ pkgs, ... }:\nlet\n foo = 1;\n bar = x: x;\nin { inherit foo; }\n",
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
//! Vue Options API `this.$refs`: Vue 2.7 types `$refs` as an index signature, so the
|
||||
//! TypeScript service resolves `this.$refs.child` to that signature and `.child.focusIt`
|
||||
//! to nothing. The template names each ref (`ref="child"`) and the element it is on,
|
||||
//! which is all navigation needs.
|
||||
|
||||
use super::lines::{LineIndex, byte_at};
|
||||
use super::web::walk;
|
||||
use super::{Syntax, parse};
|
||||
use crate::ScriptRegion;
|
||||
use ide_core::{LanguageId, Pos, Range};
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// The `$refs.<name>` (or `$refs.<name>.<member>`) expression a position is on.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VueRefAccess {
|
||||
pub name: String,
|
||||
/// The member read from the referenced element, when the position is on it.
|
||||
pub member: Option<String>,
|
||||
}
|
||||
|
||||
/// The template element that carries `ref="<name>"`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VueRefElement {
|
||||
/// The attribute value: the ref's declaration.
|
||||
pub value: Range,
|
||||
pub tag: String,
|
||||
}
|
||||
|
||||
/// A default import in a component's `<script>`.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct VueComponentImport {
|
||||
pub specifier: String,
|
||||
/// The imported name, where the language service resolves the module.
|
||||
pub binding: Range,
|
||||
}
|
||||
|
||||
/// Options whose members are on the component instance.
|
||||
const INSTANCE_OPTIONS: [&str; 5] = ["methods", "computed", "props", "data", "setup"];
|
||||
|
||||
impl Syntax {
|
||||
/// The `$refs` access at `pos` in a Vue SFC's `<script>`.
|
||||
pub fn vue_ref_access(&self, text: &str, pos: Pos) -> Option<VueRefAccess> {
|
||||
let at = byte_at(text, pos)?;
|
||||
let region = self.script_regions(&LanguageId::new("vue"), text).into_iter().find(|r| contains(r, at))?;
|
||||
let g = self.grammar(®ion.language)?;
|
||||
let tree = parse(&g.language, ®ion.text, None)?;
|
||||
let rel = at - region.base;
|
||||
let root = tree.root_node();
|
||||
let node = [rel, rel.saturating_sub(1)]
|
||||
.into_iter()
|
||||
.filter_map(|b| root.descendant_for_byte_range(b, b))
|
||||
.find(|n| n.kind() == "property_identifier")?;
|
||||
let src = region.text.as_str();
|
||||
let read = node.parent().filter(|p| p.kind() == "member_expression")?;
|
||||
let object = read.child_by_field_name("object")?;
|
||||
if is_refs(object, src) {
|
||||
return Some(VueRefAccess { name: text_of(node, src).to_owned(), member: None });
|
||||
}
|
||||
if object.kind() == "member_expression"
|
||||
&& let Some(refs) = object.child_by_field_name("object")
|
||||
&& is_refs(refs, src)
|
||||
&& let Some(name) = object.child_by_field_name("property")
|
||||
{
|
||||
return Some(VueRefAccess {
|
||||
name: text_of(name, src).to_owned(),
|
||||
member: Some(text_of(node, src).to_owned()),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The element with `ref="<name>"` in a Vue SFC's template; the last one when several
|
||||
/// share the name, as Vue keeps the last.
|
||||
pub fn vue_ref_element(&self, text: &str, name: &str) -> Option<VueRefElement> {
|
||||
let g = self.web_grammar(&LanguageId::new("vue"))?;
|
||||
let tree = parse(&g.language, text, None)?;
|
||||
let idx = LineIndex::new(text);
|
||||
let mut found = None;
|
||||
walk(tree.root_node(), &mut |n| {
|
||||
if n.kind() != "attribute" {
|
||||
return !matches!(n.kind(), "script_element" | "style_element");
|
||||
}
|
||||
if n.child(0).is_some_and(|a| text_of(a, text) == "ref")
|
||||
&& let Some(value) = attribute_value(n)
|
||||
&& text_of(value, text) == name
|
||||
&& let Some(tag) = n.parent().and_then(|t| t.child(1)).filter(|t| t.kind() == "tag_name")
|
||||
{
|
||||
found = Some(VueRefElement {
|
||||
value: idx.range(value.start_byte(), value.end_byte()),
|
||||
tag: text_of(tag, text).to_owned(),
|
||||
});
|
||||
}
|
||||
false
|
||||
});
|
||||
found
|
||||
}
|
||||
|
||||
/// The default import in a Vue SFC's `<script>` that a template tag names
|
||||
/// (`<child-button>` / `<ChildButton>` → `import ChildButton from …`).
|
||||
pub fn vue_component_import(&self, text: &str, tag: &str) -> Option<VueComponentImport> {
|
||||
let wanted = component_key(tag);
|
||||
let idx = LineIndex::new(text);
|
||||
self.script_regions(&LanguageId::new("vue"), text).into_iter().find_map(|region| {
|
||||
let g = self.grammar(®ion.language)?;
|
||||
let tree = parse(&g.language, ®ion.text, None)?;
|
||||
let src = region.text.as_str();
|
||||
let mut c = tree.root_node().walk();
|
||||
tree.root_node().children(&mut c).filter(|n| n.kind() == "import_statement").find_map(|n| {
|
||||
let clause = n.children(&mut n.walk()).find(|ch| ch.kind() == "import_clause")?;
|
||||
let binding = clause.children(&mut clause.walk()).find(|ch| ch.kind() == "identifier")?;
|
||||
if component_key(text_of(binding, src)) != wanted {
|
||||
return None;
|
||||
}
|
||||
let source = n.child_by_field_name("source")?;
|
||||
let specifier = source.named_child(0).map(|f| text_of(f, src)).unwrap_or_default();
|
||||
Some(VueComponentImport {
|
||||
specifier: specifier.to_owned(),
|
||||
binding: idx.range(region.base + binding.start_byte(), region.base + binding.end_byte()),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Where a component declares the instance member `name` in its options object
|
||||
/// (`methods`, `computed`, `props`, `data()` / `setup()` return). `lang` is `vue` for an
|
||||
/// SFC, or the script language of a component written as a plain module.
|
||||
pub fn vue_component_member(&self, lang: &LanguageId, text: &str, name: &str) -> Option<Range> {
|
||||
let regions = if lang.as_str() == "vue" {
|
||||
self.script_regions(lang, text)
|
||||
} else {
|
||||
vec![ScriptRegion { base: 0, language: lang.clone(), text: text.to_owned() }]
|
||||
};
|
||||
let idx = LineIndex::new(text);
|
||||
regions.into_iter().find_map(|region| {
|
||||
let g = self.grammar(®ion.language)?;
|
||||
let tree = parse(&g.language, ®ion.text, None)?;
|
||||
let src = region.text.as_str();
|
||||
let options = default_options(tree.root_node(), src)?;
|
||||
let key = instance_member(options, src, name)?;
|
||||
Some(idx.range(region.base + key.start_byte(), region.base + key.end_byte()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn contains(region: &ScriptRegion, at: usize) -> bool {
|
||||
region.base <= at && at <= region.base + region.text.len()
|
||||
}
|
||||
|
||||
fn text_of<'t>(n: Node, text: &'t str) -> &'t str {
|
||||
n.utf8_text(text.as_bytes()).unwrap_or("")
|
||||
}
|
||||
|
||||
/// `<anything>.$refs`.
|
||||
fn is_refs(n: Node, src: &str) -> bool {
|
||||
n.kind() == "member_expression" && n.child_by_field_name("property").is_some_and(|p| text_of(p, src) == "$refs")
|
||||
}
|
||||
|
||||
fn attribute_value(attr: Node) -> Option<Node> {
|
||||
let value =
|
||||
attr.children(&mut attr.walk()).find(|ch| matches!(ch.kind(), "quoted_attribute_value" | "attribute_value"))?;
|
||||
if value.kind() == "attribute_value" { Some(value) } else { value.named_child(0) }
|
||||
}
|
||||
|
||||
/// Vue matches a tag to a registered component in kebab-case or PascalCase.
|
||||
fn component_key(name: &str) -> String {
|
||||
name.chars().filter(|c| *c != '-').flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
/// The options object of `export default { … }`, `export default defineComponent({ … })`
|
||||
/// or `export default Vue.extend({ … })`.
|
||||
fn default_options<'t>(root: Node<'t>, src: &str) -> Option<Node<'t>> {
|
||||
let mut c = root.walk();
|
||||
let export = root.children(&mut c).find(|n| {
|
||||
n.kind() == "export_statement" && n.children(&mut n.walk()).any(|ch| text_of(ch, src) == "default")
|
||||
})?;
|
||||
let value = export.child_by_field_name("value").or_else(|| export.child_by_field_name("declaration"))?;
|
||||
object_of(value)
|
||||
}
|
||||
|
||||
fn object_of(n: Node) -> Option<Node> {
|
||||
match n.kind() {
|
||||
"object" => Some(n),
|
||||
"parenthesized_expression" | "as_expression" | "satisfies_expression" => object_of(n.named_child(0)?),
|
||||
"call_expression" => object_of(n.child_by_field_name("arguments")?.named_child(0)?),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn instance_member<'t>(options: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
|
||||
for option in INSTANCE_OPTIONS {
|
||||
let Some(entry) = entry(options, src, option) else { continue };
|
||||
let found = match option {
|
||||
"data" | "setup" => returned_objects(entry).into_iter().find_map(|o| key(o, src, name)),
|
||||
"props" => match entry.child_by_field_name("value") {
|
||||
Some(array) if array.kind() == "array" => array
|
||||
.named_children(&mut array.walk())
|
||||
.find(|s| s.kind() == "string" && string_value(*s, src) == name),
|
||||
value => value.and_then(object_of).and_then(|o| key(o, src, name)),
|
||||
},
|
||||
_ => entry.child_by_field_name("value").and_then(object_of).and_then(|o| key(o, src, name)),
|
||||
};
|
||||
if found.is_some() {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The `pair` or `method_definition` of `object` named `name`.
|
||||
fn entry<'t>(object: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
|
||||
object.named_children(&mut object.walk()).find(|n| {
|
||||
matches!(n.kind(), "pair" | "method_definition")
|
||||
&& n.child_by_field_name(if n.kind() == "pair" { "key" } else { "name" })
|
||||
.is_some_and(|k| property_name(k, src) == name)
|
||||
})
|
||||
}
|
||||
|
||||
/// The key node of `object`'s member `name`, shorthand properties included.
|
||||
fn key<'t>(object: Node<'t>, src: &str, name: &str) -> Option<Node<'t>> {
|
||||
object.named_children(&mut object.walk()).find_map(|n| match n.kind() {
|
||||
"pair" => n.child_by_field_name("key").filter(|k| property_name(*k, src) == name),
|
||||
"method_definition" => n.child_by_field_name("name").filter(|k| property_name(*k, src) == name),
|
||||
"shorthand_property_identifier" => (text_of(n, src) == name).then_some(n),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn property_name<'t>(key: Node, src: &'t str) -> &'t str {
|
||||
if key.kind() == "string" { string_value(key, src) } else { text_of(key, src) }
|
||||
}
|
||||
|
||||
fn string_value<'t>(s: Node, src: &'t str) -> &'t str {
|
||||
s.named_child(0).map(|f| text_of(f, src)).unwrap_or("")
|
||||
}
|
||||
|
||||
/// The objects a `data()` / `setup()` option returns (`data: () => ({ … })` included).
|
||||
fn returned_objects(entry: Node) -> Vec<Node> {
|
||||
let function = if entry.kind() == "method_definition" { Some(entry) } else { entry.child_by_field_name("value") };
|
||||
let Some(function) = function else { return Vec::new() };
|
||||
let Some(body) = function.child_by_field_name("body") else { return Vec::new() };
|
||||
if let Some(object) = object_of(body) {
|
||||
return vec![object];
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
walk(body, &mut |n| match n.kind() {
|
||||
"return_statement" => {
|
||||
out.extend(n.named_child(0).and_then(object_of));
|
||||
false
|
||||
}
|
||||
// A nested function's `return` is not the option's.
|
||||
"function_expression" | "arrow_function" | "function_declaration" | "method_definition" | "class_body" => false,
|
||||
_ => true,
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const APP: &str = r#"<template>
|
||||
<div>
|
||||
<child-button ref="child" @my-event="handler" />
|
||||
<input ref='field'>
|
||||
<span>{{ $refs.child }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ChildButton from './components/ChildButton.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: { ChildButton },
|
||||
methods: {
|
||||
handler() {
|
||||
this.$refs.child
|
||||
this.$refs.child.focusIt()
|
||||
this.$refs.field?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
"#;
|
||||
|
||||
fn pos_of(text: &str, needle: &str, nth: usize, offset: usize) -> Pos {
|
||||
let byte = text.match_indices(needle).nth(nth).unwrap().0 + offset;
|
||||
LineIndex::new(text).pos(byte)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_ref_and_the_member_read_from_it() {
|
||||
let s = Syntax::new();
|
||||
let child = s.vue_ref_access(APP, pos_of(APP, "this.$refs.child", 0, 12)).unwrap();
|
||||
assert_eq!(child, VueRefAccess { name: "child".into(), member: None });
|
||||
// At the end of the name too.
|
||||
let end = s.vue_ref_access(APP, pos_of(APP, "this.$refs.child", 0, 16)).unwrap();
|
||||
assert_eq!(end.name, "child");
|
||||
let member = s.vue_ref_access(APP, pos_of(APP, "focusIt", 0, 2)).unwrap();
|
||||
assert_eq!(member, VueRefAccess { name: "child".into(), member: Some("focusIt".into()) });
|
||||
let optional = s.vue_ref_access(APP, pos_of(APP, "field?.focus", 0, 1)).unwrap();
|
||||
assert_eq!(optional.name, "field");
|
||||
assert!(s.vue_ref_access(APP, pos_of(APP, "$refs.child", 1, 2)).is_none(), "$refs itself");
|
||||
assert!(s.vue_ref_access(APP, pos_of(APP, "handler", 1, 1)).is_none());
|
||||
assert!(s.vue_ref_access(APP, pos_of(APP, "$refs.child }}", 0, 7)).is_none(), "template is the server's");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_element_with_the_ref() {
|
||||
let s = Syntax::new();
|
||||
let child = s.vue_ref_element(APP, "child").unwrap();
|
||||
assert_eq!(child.tag, "child-button");
|
||||
assert_eq!(child.value.start, pos_of(APP, "\"child\"", 0, 1));
|
||||
let field = s.vue_ref_element(APP, "field").unwrap();
|
||||
assert_eq!(field.tag, "input");
|
||||
assert!(s.vue_ref_element(APP, "missing").is_none());
|
||||
let last = "<template><a ref=\"x\"/><b ref=\"x\"></b></template>";
|
||||
assert_eq!(s.vue_ref_element(last, "x").unwrap().tag, "b");
|
||||
assert!(s.vue_ref_element("<template><a :ref=\"x\"/></template>", "x").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_import_a_tag_names() {
|
||||
let s = Syntax::new();
|
||||
let import = s.vue_component_import(APP, "child-button").unwrap();
|
||||
assert_eq!(import.specifier, "./components/ChildButton.vue");
|
||||
assert_eq!(import.binding.start, pos_of(APP, "ChildButton from", 0, 0));
|
||||
assert_eq!(s.vue_component_import(APP, "ChildButton").unwrap().specifier, import.specifier);
|
||||
assert!(s.vue_component_import(APP, "input").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_instance_members_in_the_options() {
|
||||
let s = Syntax::new();
|
||||
let child = r#"<template><button /></template>
|
||||
<script>
|
||||
export default {
|
||||
name: 'ChildButton',
|
||||
props: ['label', 'size'],
|
||||
data() {
|
||||
const helper = () => { return { nope: 1 } }
|
||||
return { count: 0, helper }
|
||||
},
|
||||
computed: { double() { return this.count * 2 }, 'quoted': function () {} },
|
||||
methods: {
|
||||
focusIt() { this.$el.focus() },
|
||||
reset: function () {},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
"#;
|
||||
let vue = LanguageId::new("vue");
|
||||
let at = |name: &str| s.vue_component_member(&vue, child, name);
|
||||
assert_eq!(at("focusIt").unwrap().start, pos_of(child, "focusIt", 0, 0));
|
||||
assert_eq!(at("reset").unwrap().start, pos_of(child, "reset", 0, 0));
|
||||
assert_eq!(at("double").unwrap().start, pos_of(child, "double", 0, 0));
|
||||
assert_eq!(at("quoted").unwrap().start, pos_of(child, "'quoted'", 0, 0));
|
||||
assert_eq!(at("size").unwrap().start, pos_of(child, "'size'", 0, 0));
|
||||
assert_eq!(at("count").unwrap().start, pos_of(child, "count: 0", 0, 0));
|
||||
assert_eq!(at("helper").unwrap().start, pos_of(child, "helper }", 0, 0));
|
||||
assert!(at("nope").is_none());
|
||||
assert!(at("name").is_none());
|
||||
|
||||
let ts = "import { defineComponent } from 'vue'\nexport default defineComponent({ methods: { go() {} } })\n";
|
||||
let at = s.vue_component_member(&LanguageId::new("typescript"), ts, "go").unwrap();
|
||||
assert_eq!(at.start, pos_of(ts, "go()", 0, 0));
|
||||
let props = "export default { props: { value: String } }";
|
||||
assert!(s.vue_component_member(&LanguageId::new("javascript"), props, "value").is_some());
|
||||
}
|
||||
}
|
||||
@@ -430,9 +430,6 @@ impl Handler for ClientHandler {
|
||||
crate::kotlin_lsp::record_import_state(&self.server, self.generation, ¶ms)
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
if self.server.running_gen(self.generation).is_some() {
|
||||
self.server.set_java_ready();
|
||||
}
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ProjectStatus" => {
|
||||
|
||||
@@ -51,7 +51,7 @@ struct Response {
|
||||
#[serde(default)]
|
||||
incomplete: bool,
|
||||
}
|
||||
pub(crate) fn syntax() -> &'static Syntax {
|
||||
fn syntax() -> &'static Syntax {
|
||||
static SYNTAX: OnceLock<Syntax> = OnceLock::new();
|
||||
SYNTAX.get_or_init(Syntax::new)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ pub mod types;
|
||||
mod uri;
|
||||
mod virtual_doc;
|
||||
mod vue_navigation;
|
||||
mod vue_refs;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
@@ -202,10 +201,6 @@ pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
content_roots: RwLock<Vec<PathBuf>>,
|
||||
/// Maven projects linked from outside `project_root`. jdtls scans them as extra
|
||||
/// workspace folders; `projectConfigurations` stays unset so the opened root is
|
||||
/// still discovered automatically.
|
||||
jdtls_import_roots: RwLock<Vec<PathBuf>>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
@@ -449,7 +444,6 @@ impl LspManager {
|
||||
me: me.clone(),
|
||||
project_root,
|
||||
content_roots: RwLock::new(Vec::new()),
|
||||
jdtls_import_roots: RwLock::new(Vec::new()),
|
||||
languages,
|
||||
sink: {
|
||||
let me = me.clone();
|
||||
@@ -509,28 +503,6 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories of Maven projects linked from outside the opened root. The jdtls
|
||||
/// rooted at the opened project scans them together with that root. An empty list
|
||||
/// leaves discovery as the single `rootUri` scan.
|
||||
pub fn set_jdtls_import_roots(&self, mut roots: Vec<PathBuf>) {
|
||||
roots.retain(|root| root.is_absolute() && root.is_dir() && !root.starts_with(&self.project_root));
|
||||
roots.sort();
|
||||
roots.dedup();
|
||||
let changed = {
|
||||
let mut current = self.jdtls_import_roots.write();
|
||||
let changed = *current != roots;
|
||||
*current = roots;
|
||||
changed
|
||||
};
|
||||
if changed && let Some(me) = self.me.upgrade() {
|
||||
me.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn jdtls_import_roots(&self) -> Vec<PathBuf> {
|
||||
self.jdtls_import_roots.read().clone()
|
||||
}
|
||||
|
||||
/// Shares JDT LS within this manager; callers reconfigure after the hook's value changes.
|
||||
pub fn set_jdtls_shared_hook(&self, hook: Box<JdtlsSharedFn>) {
|
||||
*self.jdtls_shared_hook.write() = Some(Arc::from(hook));
|
||||
@@ -565,29 +537,10 @@ impl LspManager {
|
||||
if state.shared {
|
||||
return state.roots.clone();
|
||||
}
|
||||
// The opened project's server also scans linked external Maven projects.
|
||||
// Other in-root builds keep their own server and their own root.
|
||||
if server.root == self.project_root {
|
||||
return self.project_and_import_roots();
|
||||
}
|
||||
}
|
||||
vec![server.root.clone()]
|
||||
}
|
||||
|
||||
fn project_and_import_roots(&self) -> Vec<PathBuf> {
|
||||
let mut roots = vec![self.project_root.clone()];
|
||||
for root in self.jdtls_import_roots.read().iter() {
|
||||
if root.is_absolute() && root.is_dir() && !roots.contains(root) {
|
||||
roots.push(root.clone());
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn imports_external_java(&self, config: &LanguageServerConfig, path: &Path) -> bool {
|
||||
is_local_jdtls(config) && self.jdtls_import_roots.read().iter().any(|root| path.starts_with(root))
|
||||
}
|
||||
|
||||
fn project_root_for(&self, path: &Path) -> PathBuf {
|
||||
self.content_roots
|
||||
.read()
|
||||
@@ -604,9 +557,7 @@ impl LspManager {
|
||||
let mut servers = self.servers.lock();
|
||||
let project = self.project_root_for(path);
|
||||
let sketch = (cfg.name == arduino::SERVER).then(|| arduino::sketch_root(&project, path)).flatten();
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) || self.imports_external_java(cfg, path) {
|
||||
// One server for the opened root and a linked external Maven project, so
|
||||
// Java completion and navigation see the import jdtls made of both.
|
||||
let root = if shared.as_ref().is_some_and(|state| state.shared) {
|
||||
self.project_root.clone()
|
||||
} else {
|
||||
sketch.or_else(|| launch::find_root(&project, path, &cfg.root_markers)).unwrap_or_else(|| {
|
||||
@@ -1002,15 +953,13 @@ impl LspManager {
|
||||
}
|
||||
init_options["settings"] = handler::all_settings(&cfg);
|
||||
let shared = is_local_jdtls(&server.config) && self.jdtls_workspace_state().shared;
|
||||
// jdtls imports `initializationOptions.workspaceFolders` by scanning each
|
||||
// directory. It ignores the LSP workspace folder list for that first import,
|
||||
// and a `projectConfigurations` list replaces the scan. Extra Maven projects
|
||||
// are therefore folders here, not an explicit build-file list, so projects
|
||||
// under the opened root are still discovered.
|
||||
let folders: Vec<String> =
|
||||
self.server_workspace_roots(server).iter().map(|root| uri::path_to_uri_string(root)).collect();
|
||||
if shared || folders.len() > 1 {
|
||||
init_options["workspaceFolders"] = json!(folders);
|
||||
if shared {
|
||||
init_options["workspaceFolders"] = json!(
|
||||
self.server_workspace_roots(server)
|
||||
.iter()
|
||||
.map(|root| uri::path_to_uri_string(root))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
let triggers: Vec<_> = self
|
||||
.docs
|
||||
@@ -1603,9 +1552,6 @@ impl LspManager {
|
||||
{
|
||||
return Ok(result.locations);
|
||||
}
|
||||
if let Some(found) = self.vue_ref_navigation(doc, &snap, pos).await {
|
||||
return Ok(found);
|
||||
}
|
||||
}
|
||||
let (method, cap, feature) = goto_method(kind);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
|
||||
|
||||
@@ -80,7 +80,7 @@ pub(crate) struct Widened<R> {
|
||||
impl<R> Widened<R> {
|
||||
/// An answer from the server's analysis scope alone; `empty`: it found nothing.
|
||||
pub fn only(first: R, empty: bool, server: &Server) -> Self {
|
||||
let incomplete = (empty && server.indexing()).then_some(ReferencesIncomplete::Indexing);
|
||||
let incomplete = (empty && server.is_busy()).then_some(ReferencesIncomplete::Indexing);
|
||||
Self { first, second: None, incomplete }
|
||||
}
|
||||
}
|
||||
@@ -130,7 +130,7 @@ impl LspManager {
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = IdeResult<R>>,
|
||||
{
|
||||
let indexing = t.server.indexing();
|
||||
let busy = t.server.is_busy();
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
@@ -143,7 +143,7 @@ impl LspManager {
|
||||
};
|
||||
let (first, candidates) = futures::join!(query(), candidates);
|
||||
let mut first = first?;
|
||||
if indexing && covered(&first).is_empty() && t.server.wait_indexed(INDEX_WAIT, cancel).await {
|
||||
if busy && covered(&first).is_empty() && t.server.wait_idle(INDEX_WAIT, cancel).await {
|
||||
first = query().await?;
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
@@ -151,7 +151,7 @@ impl LspManager {
|
||||
}
|
||||
// Servers report analysis progress often; only "nothing found" while it lasts is suspect.
|
||||
let mut incomplete =
|
||||
(t.server.indexing() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
|
||||
(t.server.is_busy() && covered(&first).is_empty()).then_some(ReferencesIncomplete::Indexing);
|
||||
let Some(candidates) = candidates else { return Ok(Widened { first, second: None, incomplete }) };
|
||||
if candidates.limit_hit {
|
||||
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
|
||||
|
||||
@@ -127,7 +127,7 @@ pub struct Server {
|
||||
published: Mutex<HashMap<PathBuf, Instant>>,
|
||||
/// Woken on every `publishDiagnostics` (Inspect Code waits on it).
|
||||
pub published_notify: Notify,
|
||||
/// Woken when an empty answer stops being suspect (see [`Server::indexing`]).
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
idle_notify: Notify,
|
||||
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
|
||||
/// one, then whether it is still loading the project.
|
||||
@@ -148,8 +148,6 @@ pub struct Server {
|
||||
/// The instance whose just registered external project is loading, until an open file's
|
||||
/// diagnostics arrive (0: none).
|
||||
pub external_loading: std::sync::atomic::AtomicU64,
|
||||
/// jdtls sent `ServiceReady` on this instance: the project is imported.
|
||||
java_ready: std::sync::atomic::AtomicBool,
|
||||
/// kotlin-lsp's project import as the current instance last reported it.
|
||||
pub(crate) import_state: watch::Sender<Option<crate::kotlin_lsp::ImportState>>,
|
||||
}
|
||||
@@ -197,7 +195,6 @@ impl Server {
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
external_loading: std::sync::atomic::AtomicU64::new(0),
|
||||
java_ready: std::sync::atomic::AtomicBool::new(false),
|
||||
import_state: watch::channel(None).0,
|
||||
}
|
||||
}
|
||||
@@ -599,22 +596,6 @@ impl Server {
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
/// Whether an empty search answer may only mean the server has not indexed the project
|
||||
/// yet. jdtls' searches wait for its index themselves, and the progress it reports once
|
||||
/// the project is imported is background work (builds, validation): only the import counts.
|
||||
pub fn indexing(&self) -> bool {
|
||||
if self.name == "jdtls" {
|
||||
return self.is_busy() && !self.java_ready.load(std::sync::atomic::Ordering::Acquire);
|
||||
}
|
||||
self.is_busy()
|
||||
}
|
||||
|
||||
/// Records jdtls' `ServiceReady`.
|
||||
pub fn set_java_ready(&self) {
|
||||
self.java_ready.store(true, std::sync::atomic::Ordering::Release);
|
||||
self.phase_ended();
|
||||
}
|
||||
|
||||
/// Whether progress token `token` is still running on this instance.
|
||||
pub fn has_progress(&self, token: &str) -> bool {
|
||||
self.progress.lock().contains_key(token)
|
||||
@@ -632,7 +613,7 @@ impl Server {
|
||||
|
||||
fn phase_ended(&self) {
|
||||
self.phase_notify.notify_waiters();
|
||||
if !self.indexing() {
|
||||
if !self.is_busy() {
|
||||
self.idle_notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
@@ -642,17 +623,17 @@ impl Server {
|
||||
self.phase_notify.notified()
|
||||
}
|
||||
|
||||
/// Waits until [`Server::indexing`] ends; false on timeout or cancellation.
|
||||
pub async fn wait_indexed(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
|
||||
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
let notified = self.idle_notify.notified();
|
||||
if !self.indexing() || self.running().is_none() {
|
||||
return !self.indexing();
|
||||
if !self.is_busy() || self.running().is_none() {
|
||||
return !self.is_busy();
|
||||
}
|
||||
tokio::select! {
|
||||
() = notified => {}
|
||||
() = tokio::time::sleep_until(deadline) => return !self.indexing(),
|
||||
() = tokio::time::sleep_until(deadline) => return !self.is_busy(),
|
||||
() = cancel.cancelled() => return false,
|
||||
}
|
||||
}
|
||||
@@ -742,7 +723,6 @@ impl Server {
|
||||
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
|
||||
*self.loading.lock() = None;
|
||||
self.external_loading.store(0, std::sync::atomic::Ordering::Release);
|
||||
self.java_ready.store(false, std::sync::atomic::Ordering::Release);
|
||||
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
|
||||
}
|
||||
|
||||
@@ -977,7 +957,7 @@ mod tests {
|
||||
stalled: true,
|
||||
},
|
||||
);
|
||||
assert!(!s.is_busy(), "a stuck task must not hold wait_indexed or ReferencesIncomplete::Indexing");
|
||||
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
|
||||
s.progress.lock().get_mut("p").unwrap().stalled = false;
|
||||
assert!(s.is_busy(), "a reporting task keeps the server busy");
|
||||
}
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
//! Vue Options API `this.$refs`: the Vue 2.7 types declare `$refs` as an index signature,
|
||||
//! so the TypeScript service sends `this.$refs.child` to that signature and finds nothing
|
||||
//! for `this.$refs.child.focusIt`. The template says which element each ref is on:
|
||||
//! `this.$refs.child` goes to its `ref="child"`, and a member read from a component's ref
|
||||
//! goes to that member in the component's options.
|
||||
|
||||
use crate::jdtls::configuration::syntax;
|
||||
use crate::types::GotoKind;
|
||||
use crate::{DocSnap, LspManager, goto_method};
|
||||
use ide_core::{DocId, LanguageId, Location, Pos};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
impl LspManager {
|
||||
pub(crate) async fn vue_ref_navigation(&self, doc: DocId, snap: &DocSnap, pos: Pos) -> Option<Vec<Location>> {
|
||||
if snap.language.as_str() != "vue" || !snap.text.as_str().contains("$refs") {
|
||||
return None;
|
||||
}
|
||||
let text = snap.text.clone();
|
||||
let (member, element, import) = tokio::task::spawn_blocking(move || {
|
||||
let s = syntax();
|
||||
let access = s.vue_ref_access(text.as_str(), pos)?;
|
||||
let element = s.vue_ref_element(text.as_str(), &access.name)?;
|
||||
let import = access.member.as_ref().and_then(|_| s.vue_component_import(text.as_str(), &element.tag));
|
||||
Some((access.member, element, import))
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
let Some(member) = member else {
|
||||
return Some(vec![Location { path: snap.path.clone().into(), range: element.value, uri: None }]);
|
||||
};
|
||||
let import = import?;
|
||||
let file = match relative_module(&snap.path, &import.specifier) {
|
||||
Some(file) => file,
|
||||
// An alias (`@/components/…`): the language service knows the project's paths;
|
||||
// without them configured, `@` is the Vue CLI / Vite templates' `src`.
|
||||
None => match self.definition_file(doc, import.binding.start).await {
|
||||
Some(file) => file,
|
||||
None => src_alias(&self.project_root_for(&snap.path), &import.specifier)?,
|
||||
},
|
||||
};
|
||||
let lang = script_language(&file)?;
|
||||
let open = self.docs.lock().values().find(|d| d.path == file).map(|d| d.text.clone());
|
||||
let range = tokio::task::spawn_blocking({
|
||||
let file = file.clone();
|
||||
move || {
|
||||
let text = match open {
|
||||
Some(text) => text.as_str().to_owned(),
|
||||
None => std::fs::read_to_string(&file).ok()?,
|
||||
};
|
||||
syntax().vue_component_member(&lang, &text, &member)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
Some(vec![Location { path: file.into(), range, uri: None }])
|
||||
}
|
||||
|
||||
async fn definition_file(&self, doc: DocId, pos: Pos) -> Option<PathBuf> {
|
||||
let (method, cap, feature) = goto_method(GotoKind::Definition);
|
||||
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await.ok()?;
|
||||
let found = self.goto_in(&snap, &targets, method, pos, None, None).await.ok()?;
|
||||
// The import binding itself is no answer.
|
||||
found.into_iter().find(|l| l.uri.is_none() && *l.path != snap.path).map(|l| l.path.into_path_buf())
|
||||
}
|
||||
}
|
||||
|
||||
/// The file a relative import names, with the extensions a bundler tries.
|
||||
fn relative_module(from: &Path, specifier: &str) -> Option<PathBuf> {
|
||||
if !specifier.starts_with("./") && !specifier.starts_with("../") {
|
||||
return None;
|
||||
}
|
||||
let mut base = from.parent()?.to_path_buf();
|
||||
for part in Path::new(specifier).components() {
|
||||
match part {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
base.pop();
|
||||
}
|
||||
part => base.push(part),
|
||||
}
|
||||
}
|
||||
let candidates = [String::new(), ".vue".into(), ".ts".into(), ".js".into()]
|
||||
.into_iter()
|
||||
.map(|ext| PathBuf::from(format!("{}{ext}", base.display())))
|
||||
.chain(["index.vue", "index.ts", "index.js"].into_iter().map(|index| base.join(index)));
|
||||
candidates.into_iter().find(|p| p.is_file())
|
||||
}
|
||||
|
||||
fn src_alias(root: &Path, specifier: &str) -> Option<PathBuf> {
|
||||
let rest = specifier.strip_prefix("@/")?;
|
||||
relative_module(&root.join("src").join("_"), &format!("./{rest}"))
|
||||
}
|
||||
|
||||
fn script_language(file: &Path) -> Option<LanguageId> {
|
||||
let id = match file.extension()?.to_str()? {
|
||||
"vue" => "vue",
|
||||
"ts" | "mts" | "cts" => "typescript",
|
||||
"tsx" => "tsx",
|
||||
"js" | "mjs" | "cjs" | "jsx" => "javascript",
|
||||
_ => return None,
|
||||
};
|
||||
Some(LanguageId::new(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_component_imports() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("src");
|
||||
std::fs::create_dir_all(src.join("components/Panel")).unwrap();
|
||||
std::fs::write(src.join("components/ChildButton.vue"), "").unwrap();
|
||||
std::fs::write(src.join("components/Panel/index.vue"), "").unwrap();
|
||||
let app = src.join("App.vue");
|
||||
assert_eq!(relative_module(&app, "./components/ChildButton.vue"), Some(src.join("components/ChildButton.vue")));
|
||||
assert_eq!(relative_module(&app, "./components/ChildButton"), Some(src.join("components/ChildButton.vue")));
|
||||
assert_eq!(relative_module(&app, "./components/Panel"), Some(src.join("components/Panel/index.vue")));
|
||||
assert_eq!(
|
||||
relative_module(&src.join("components/Panel/index.vue"), "../ChildButton.vue"),
|
||||
Some(src.join("components/ChildButton.vue"))
|
||||
);
|
||||
assert_eq!(relative_module(&app, "@/components/ChildButton.vue"), None);
|
||||
assert_eq!(relative_module(&app, "./components/Missing.vue"), None);
|
||||
assert_eq!(src_alias(dir.path(), "@/components/ChildButton"), Some(src.join("components/ChildButton.vue")));
|
||||
assert_eq!(src_alias(dir.path(), "~/components/ChildButton"), None);
|
||||
}
|
||||
}
|
||||
@@ -759,40 +759,6 @@ async fn java_classpath_does_not_retry_permanently_unavailable_servers() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn external_maven_import_joins_the_opened_root_without_replacing_discovery() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
let external = dunce::canonicalize(external.path()).unwrap();
|
||||
std::fs::write(external.join("pom.xml"), "<project><artifactId>b</artifactId></project>").unwrap();
|
||||
let outside = external.join("src/Main.java");
|
||||
std::fs::create_dir_all(outside.parent().unwrap()).unwrap();
|
||||
std::fs::write(&outside, "class External {}").unwrap();
|
||||
let inside = h.root.join("src/App.java");
|
||||
std::fs::create_dir_all(inside.parent().unwrap()).unwrap();
|
||||
std::fs::write(&inside, "class App {}").unwrap();
|
||||
// An in-root build with its own marker keeps a separate server.
|
||||
let nested = h.root.join("nested");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
std::fs::write(nested.join("mock.toml"), "").unwrap();
|
||||
let nested_file = nested.join("Nested.java");
|
||||
std::fs::write(&nested_file, "class Nested {}").unwrap();
|
||||
|
||||
h.mgr.set_jdtls_import_roots(vec![external.clone()]);
|
||||
h.mgr.did_open(DocId::next(), &inside, &h.lang, 1, "class App {}");
|
||||
h.mgr.did_open(DocId::next(), &outside, &h.lang, 1, "class External {}");
|
||||
h.mgr.did_open(DocId::next(), &nested_file, &h.lang, 1, "class Nested {}");
|
||||
let state = h.launcher.state("jdtls");
|
||||
eventually(|| state.requested("initialize").len() == 2).await;
|
||||
let inits = state.requested("initialize");
|
||||
let project = inits.iter().find(|init| init["rootUri"] == uri_of(&h.root)).unwrap();
|
||||
assert_eq!(project["initializationOptions"]["workspaceFolders"], json!([uri_of(&h.root), uri_of(&external)]));
|
||||
assert!(project["initializationOptions"].get("projectConfigurations").is_none());
|
||||
assert!(state.requested("initialize").iter().any(|init| init["rootUri"] == uri_of(&nested)));
|
||||
assert_eq!(h.mgr.servers().len(), 2);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shared_java_external_roots_reach_initialization_queries_and_watches() {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
@@ -4959,39 +4925,6 @@ async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for()
|
||||
assert!(seen.lock().is_empty());
|
||||
}
|
||||
|
||||
/// jdtls' searches wait for its index themselves, and it reports background work (builds,
|
||||
/// validation) for long stretches: a method nothing calls is answered at once meanwhile.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_usages_of_an_unused_method_do_not_wait_for_background_work() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
s.respond("workspace/executeCommand", json!([]));
|
||||
let (doc, _) = h.open("a.java", "void unused() {}");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
s.peer()
|
||||
.notify("$/progress", json!({ "token": "build", "value": { "kind": "begin", "title": "Building" } }))
|
||||
.unwrap();
|
||||
h.wait_status("jdtls", ServerStatus::Busy).await;
|
||||
// Until the project is imported, finding nothing may only mean it is not indexed yet.
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::Indexing));
|
||||
s.peer().notify("language/status", json!({"type": "ServiceReady", "message": "ServiceReady"})).unwrap();
|
||||
for _ in 0..200 {
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), true, false, Default::default()).await.unwrap();
|
||||
if refs.incomplete.is_none() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let started = std::time::Instant::now();
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 6), true, true, Default::default()).await.unwrap();
|
||||
assert!(started.elapsed() < Duration::from_secs(2), "waited {:?}", started.elapsed());
|
||||
assert!(refs.locations.is_empty());
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
/// A library method's usages in the sources are shown before the dependencies and the JDK
|
||||
/// are searched, which takes seconds; scope widening searches the sources alone again.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
|
||||
@@ -14,7 +14,6 @@ serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
futures.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
globset.workspace = true
|
||||
|
||||
@@ -10,9 +10,6 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Remote folders listed (or files checked) at the same time.
|
||||
pub(crate) const LIST_CONCURRENCY: usize = 16;
|
||||
|
||||
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
|
||||
/// paths are both relative to that root, so checking never opens a connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -202,49 +199,20 @@ impl Deployment<'_> {
|
||||
}
|
||||
|
||||
/// Remote files under `dir` (recursively), excluded ones left out.
|
||||
/// Files under `dir`, by path. Lists [`LIST_CONCURRENCY`] folders at a time: one round trip
|
||||
/// per folder after another made large trees take minutes.
|
||||
async fn remote_files(&self, dir: &str, out: &mut Vec<ide_ssh::RemoteEntry>) -> IdeResult<()> {
|
||||
use futures::stream::{FuturesUnordered, StreamExt};
|
||||
let start = out.len();
|
||||
// One `find` over SSH instead of a listing per folder (thousands of round trips on a
|
||||
// big project); the same entries the walk below would keep.
|
||||
if let Some(entries) = self.fs.tree(dir, self.exclude.prunable_names()).await {
|
||||
for e in self.fs.list(dir).await? {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let base = dir.trim_end_matches('/');
|
||||
out.extend(entries.into_iter().filter(|e| {
|
||||
let rel = e.path.strip_prefix(base).unwrap_or(&e.path);
|
||||
!self.exclude.remote(&e.path) && !rel.split('/').any(|name| !name.is_empty() && self.exclude.name(name))
|
||||
}));
|
||||
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
|
||||
return Ok(());
|
||||
}
|
||||
let mut pending = vec![dir.to_owned()];
|
||||
let mut listing = FuturesUnordered::new();
|
||||
loop {
|
||||
while listing.len() < LIST_CONCURRENCY
|
||||
&& let Some(dir) = pending.pop()
|
||||
{
|
||||
listing.push(async move { self.fs.list(&dir).await });
|
||||
if self.exclude.remote(&e.path) {
|
||||
continue;
|
||||
}
|
||||
let Some(entries) = listing.next().await else { break };
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
for e in entries? {
|
||||
if self.exclude.remote(&e.path) {
|
||||
continue;
|
||||
}
|
||||
if e.is_dir && !e.is_symlink {
|
||||
pending.push(e.path);
|
||||
} else if !e.is_dir {
|
||||
out.push(e);
|
||||
}
|
||||
if e.is_dir && !e.is_symlink {
|
||||
Box::pin(self.remote_files(&e.path, out)).await?;
|
||||
} else if !e.is_dir {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
out[start..].sort_by(|a, b| a.path.cmp(&b.path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -175,8 +175,6 @@ impl Mappings {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Excludes {
|
||||
names: GlobSet,
|
||||
/// The name patterns `find -name` matches the same way (no `/`, braces, classes or `**`).
|
||||
plain: Vec<String>,
|
||||
/// Remote paths (absolute) never touched.
|
||||
remote: Vec<String>,
|
||||
}
|
||||
@@ -184,14 +182,10 @@ pub struct Excludes {
|
||||
impl Excludes {
|
||||
pub fn new(settings: &DeploymentSettings, server: &DeploymentServer, remote_root: &str) -> Self {
|
||||
let mut b = GlobSetBuilder::new();
|
||||
let mut plain = Vec::new();
|
||||
for pattern in settings.exclude.iter().flat_map(|p| p.split(';')).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match Glob::new(pattern) {
|
||||
Ok(g) => {
|
||||
b.add(g);
|
||||
if !pattern.contains(['/', '\\', '{', '}', '[', ']']) && !pattern.contains("**") {
|
||||
plain.push(pattern.to_owned());
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("deployment exclude {pattern:?}: {e}"),
|
||||
}
|
||||
@@ -203,12 +197,7 @@ impl Excludes {
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| join(remote_root, p.trim_start_matches('/')).trim_end_matches('/').to_owned())
|
||||
.collect();
|
||||
Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), plain, remote }
|
||||
}
|
||||
|
||||
/// Name patterns a remote `find` can skip whole folders by; the rest is filtered afterwards.
|
||||
pub fn prunable_names(&self) -> &[String] {
|
||||
&self.plain
|
||||
Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote }
|
||||
}
|
||||
|
||||
pub fn name(&self, name: &str) -> bool {
|
||||
|
||||
@@ -87,7 +87,7 @@ impl RemoteService {
|
||||
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
|
||||
}
|
||||
let session = self.ssh.session(ssh).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?).with_shell(session))
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
FsConnection::Ftp { server, secure, ftp } => {
|
||||
let password = self.ssh.password(&ide_ssh::ftp::secret_key(server));
|
||||
@@ -418,7 +418,7 @@ impl RemoteService {
|
||||
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
|
||||
}
|
||||
let session = self.ssh.session(&target.ssh_config).await?;
|
||||
let fs = RemoteFs::new(session.sftp().await?).with_shell(session.clone());
|
||||
let fs = RemoteFs::new(session.sftp().await?);
|
||||
let folder = if target.project_root.trim().is_empty() {
|
||||
format!(
|
||||
"~/{}",
|
||||
@@ -470,27 +470,13 @@ impl RemoteService {
|
||||
preserve_time: true,
|
||||
cancel: CancellationToken::new(),
|
||||
};
|
||||
let started = std::time::Instant::now();
|
||||
out(OutputChunk::System { text: format!("Comparing project files with {remote_root}...\n") });
|
||||
let mut progress = |_: &str, _: usize, _: usize| {};
|
||||
let report = d.push_changes(&root, &mut progress).await;
|
||||
if report.is_ok() && !libraries.is_empty() {
|
||||
out(OutputChunk::System {
|
||||
text: format!("Checking {} external classpath entries...\n", libraries.len()),
|
||||
});
|
||||
}
|
||||
let synced = match &report {
|
||||
Ok(_) => sync_external_libraries(&fs, &libraries, preserve_time).await,
|
||||
Err(_) => Ok(0),
|
||||
};
|
||||
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
|
||||
fs.close().await;
|
||||
let report = report?;
|
||||
out(OutputChunk::System {
|
||||
text: format!(
|
||||
"Uploaded {} changed file(s) to {remote_root} in {:.1}s\n",
|
||||
report.files,
|
||||
started.elapsed().as_secs_f64()
|
||||
),
|
||||
text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files),
|
||||
});
|
||||
match report.errors.first() {
|
||||
Some(e) => Err(IdeError::new(
|
||||
@@ -531,8 +517,9 @@ async fn sync_external_libraries(
|
||||
fn fail(path: &str, e: IdeError) -> IdeError {
|
||||
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut synced = 0;
|
||||
for (local, remote_base) in libraries {
|
||||
let mut files = Vec::new();
|
||||
if local.is_dir() {
|
||||
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
|
||||
for entry in walk.flatten() {
|
||||
@@ -547,11 +534,7 @@ async fn sync_external_libraries(
|
||||
} else {
|
||||
files.push((local.clone(), remote_base.clone()));
|
||||
}
|
||||
}
|
||||
// The remote copies are checked several at a time (one round trip each); uploads follow.
|
||||
use futures::stream::{self, StreamExt, TryStreamExt};
|
||||
let stale: Vec<(PathBuf, String)> = stream::iter(files)
|
||||
.map(|(file, remote)| async move {
|
||||
for (file, remote) in files {
|
||||
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
|
||||
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
|
||||
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
|
||||
@@ -561,17 +544,14 @@ async fn sync_external_libraries(
|
||||
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
|
||||
None => None,
|
||||
};
|
||||
let same = matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same);
|
||||
Ok((!same).then_some((file, remote)))
|
||||
})
|
||||
.buffered(crate::deploy::LIST_CONCURRENCY)
|
||||
.try_filter_map(|x| async move { Ok(x) })
|
||||
.try_collect()
|
||||
.await?;
|
||||
for (file, remote) in &stale {
|
||||
fs.upload(file, remote, preserve_time).await.map_err(|e| fail(remote, e))?;
|
||||
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
|
||||
continue;
|
||||
}
|
||||
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
|
||||
synced += 1;
|
||||
}
|
||||
}
|
||||
Ok(stale.len())
|
||||
Ok(synced)
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
|
||||
@@ -263,15 +263,10 @@ fn target_command(
|
||||
RemoteCommand { program, args, cwd, env, shell_line: None }
|
||||
}
|
||||
|
||||
/// How long Stop waits for the channel to close after `KILL`.
|
||||
const KILL_WAIT: std::time::Duration = std::time::Duration::from_secs(3);
|
||||
|
||||
struct RemoteRun {
|
||||
info: Mutex<RunInfo>,
|
||||
process: Mutex<Option<Arc<RemoteProcess>>>,
|
||||
cancel: CancellationToken,
|
||||
/// Stop gave up waiting for the channel to report the exit: the run finishes locally.
|
||||
abandon: CancellationToken,
|
||||
stopping: AtomicBool,
|
||||
/// The runtime it started on: stdin / stop requests may come from threads without one.
|
||||
runtime: tokio::runtime::Handle,
|
||||
@@ -323,7 +318,6 @@ impl RemoteRuns {
|
||||
info: Mutex::new(info.clone()),
|
||||
process: Mutex::new(None),
|
||||
cancel: CancellationToken::new(),
|
||||
abandon: CancellationToken::new(),
|
||||
stopping: AtomicBool::new(false),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
});
|
||||
@@ -367,8 +361,7 @@ impl RemoteRuns {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `INT`, then `KILL` (closing the channel) after `grace`; a channel that still reports no
|
||||
/// exit after another [`KILL_WAIT`] is left behind and the run finishes as stopped.
|
||||
/// `INT`, then `KILL` (closing the channel) after `grace`.
|
||||
pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> {
|
||||
let run =
|
||||
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
|
||||
@@ -381,8 +374,6 @@ impl RemoteRuns {
|
||||
tokio::time::sleep(grace).await;
|
||||
if run.info.lock().running {
|
||||
let _ = process.signal(Sig::KILL).await;
|
||||
tokio::time::sleep(KILL_WAIT).await;
|
||||
run.abandon.cancel();
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
@@ -429,10 +420,7 @@ async fn run_remote(
|
||||
.await?;
|
||||
let process = Arc::new(process);
|
||||
*run.process.lock() = Some(process.clone());
|
||||
tokio::select! {
|
||||
code = process.wait() => Ok(code),
|
||||
_ = run.abandon.cancelled() => Ok(None),
|
||||
}
|
||||
Ok(process.wait().await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -16,11 +16,6 @@ struct Env {
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
env_with(|_| Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await
|
||||
}
|
||||
|
||||
/// `exec`: the server's command handler, given the folder that backs `/`.
|
||||
async fn env_with(exec: impl FnOnce(std::path::PathBuf) -> ide_ssh::testing::ExecFn) -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
let server_root = tmp.path().join("server");
|
||||
@@ -30,7 +25,7 @@ async fn env_with(exec: impl FnOnce(std::path::PathBuf) -> ide_ssh::testing::Exe
|
||||
std::fs::write(project.join("src/main.py"), "print('hi')\n").unwrap();
|
||||
std::fs::write(project.join("README.md"), "# demo\n").unwrap();
|
||||
std::fs::write(project.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
let port = ide_ssh::testing::sshd(server_root.clone(), exec(server_root.clone())).await;
|
||||
let port = ide_ssh::testing::sshd(server_root.clone(), Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await;
|
||||
let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None };
|
||||
let target = SshTarget {
|
||||
host: "127.0.0.1".into(),
|
||||
@@ -189,72 +184,6 @@ async fn uploads_compares_syncs_and_downloads() {
|
||||
assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 0);
|
||||
}
|
||||
|
||||
/// GNU `find -printf '%y\t%s\t%T@\t%p\0'` over the test server's files (no pruning: the
|
||||
/// deployment filters what it excludes itself).
|
||||
fn find_printf(root: std::path::PathBuf, calls: Arc<std::sync::atomic::AtomicUsize>) -> ide_ssh::testing::ExecFn {
|
||||
Arc::new(move |cmd| {
|
||||
let Some(dir) = cmd.strip_prefix("LC_ALL=C find ").and_then(|c| c.split(" -mindepth").next()) else {
|
||||
return (String::new(), 127);
|
||||
};
|
||||
calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
fn walk(local: &std::path::Path, remote: &str, out: &mut String) {
|
||||
for e in std::fs::read_dir(local).unwrap().flatten() {
|
||||
let (path, meta) = (format!("{remote}/{}", e.file_name().to_string_lossy()), e.metadata().unwrap());
|
||||
if meta.is_dir() {
|
||||
walk(&e.path(), &path, out);
|
||||
} else {
|
||||
let mtime = meta.modified().unwrap().duration_since(std::time::UNIX_EPOCH).unwrap();
|
||||
out.push_str(&format!(
|
||||
"f\t{}\t{}.{:09}0\t{path}\0",
|
||||
meta.len(),
|
||||
mtime.as_secs(),
|
||||
mtime.subsec_nanos()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = String::new();
|
||||
let local = root.join(dir.trim_start_matches('/'));
|
||||
if local.is_dir() {
|
||||
walk(&local, dir, &mut out);
|
||||
}
|
||||
(out, 0)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compares_with_one_find_over_ssh_and_falls_back_to_listing_folders() {
|
||||
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let counted = calls.clone();
|
||||
let env = env_with(move |root| find_printf(root, counted)).await;
|
||||
let server = server();
|
||||
let settings = DeploymentSettings::default();
|
||||
let map = Mappings::new(&server, &env.project, "/srv");
|
||||
let exclude = Excludes::new(&settings, &server, "/srv");
|
||||
// An excluded folder on the server is not listed, whatever `find` returns.
|
||||
std::fs::create_dir_all(env.server_root.join("srv/app/.git")).unwrap();
|
||||
std::fs::write(env.server_root.join("srv/app/.git/HEAD"), "x\n").unwrap();
|
||||
std::fs::write(env.server_root.join("srv/app/remote.txt"), "server side\n").unwrap();
|
||||
for shell in [true, false] {
|
||||
let fs = RemoteFs::new(env.session.sftp().await.unwrap());
|
||||
let fs = if shell { fs.with_shell(env.session.clone()) } else { fs };
|
||||
let d =
|
||||
Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() };
|
||||
let before = calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||
if shell {
|
||||
assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 2);
|
||||
}
|
||||
let entries = d.compare(&env.project).await.unwrap();
|
||||
let status = |p: &str| entries.iter().find(|e| e.path == p).map(|e| e.status);
|
||||
assert_eq!(status("src/main.py"), Some(SyncStatus::Same), "{entries:?}");
|
||||
assert_eq!(status("README.md"), Some(SyncStatus::Same));
|
||||
assert_eq!(status("remote.txt"), Some(SyncStatus::RemoteOnly));
|
||||
assert!(entries.iter().all(|e| !e.path.starts_with(".git")), "{entries:?}");
|
||||
let used = calls.load(std::sync::atomic::Ordering::SeqCst) - before;
|
||||
assert_eq!(used > 0, shell, "find is used only with a shell");
|
||||
}
|
||||
}
|
||||
|
||||
struct Sink(Mutex<Vec<IdeEvent>>);
|
||||
|
||||
impl ide_core::EventSink for Sink {
|
||||
|
||||
@@ -66,19 +66,16 @@ pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<Pat
|
||||
|
||||
/// The project interpreter: the configured one, else a project virtualenv
|
||||
/// (`.venv`, `venv`, …) or pixi environment (`.pixi/envs/<env>`) in the project
|
||||
/// root, else the Python the project pins for mise / asdf. A configured
|
||||
/// interpreter whose executable is gone (uninstalled, moved) is skipped like
|
||||
/// PyCharm's "Invalid" SDKs, so runs fall back instead of failing on a dead path.
|
||||
/// root, else the Python the project pins for mise / asdf.
|
||||
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
setting_path(root, &s.languages.python.interpreter)
|
||||
.filter(|p| p.is_file())
|
||||
.or_else(|| project_venv_python(root))
|
||||
.or_else(|| pinned::pinned_python(root))
|
||||
}
|
||||
|
||||
/// An explicit project interpreter wins; otherwise use the closest virtualenv to the file.
|
||||
pub fn python_interpreter_near(root: &Path, near: Option<&Path>, s: &Settings) -> Option<PathBuf> {
|
||||
if let Some(python) = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file()) {
|
||||
if let Some(python) = setting_path(root, &s.languages.python.interpreter) {
|
||||
return Some(python);
|
||||
}
|
||||
let near = near.filter(|dir| dir.starts_with(root)).unwrap_or(root);
|
||||
@@ -1449,22 +1446,7 @@ pub fn apply_maven_env(c: &mut RunConfig, root: &Path, s: &Settings) {
|
||||
/// cargo / go configs the selected toolchain, Maven / Gradle their runner JDK
|
||||
/// (and Maven the configured home and options).
|
||||
pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings) {
|
||||
apply_to_run_configs_in(configs, root, None, s)
|
||||
}
|
||||
|
||||
/// [`apply_to_run_configs`] with an inherited fallback project: a workspace member
|
||||
/// (`root`) with no Python of its own (no interpreter setting, virtualenv or pin)
|
||||
/// runs on the primary project's (`fallback`) — the module SDK a member without its
|
||||
/// own inherits in IDEA. `None` keeps the configuration's own project.
|
||||
pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback: Option<&Path>, s: &Settings) {
|
||||
let inherited = fallback.filter(|f| *f != root);
|
||||
// An interpreter whose executable is gone is skipped (like PyCharm's "Invalid"
|
||||
// SDKs): the run configuration uses the venv / fallback instead of a dead path.
|
||||
let explicit_python = setting_path(root, &s.languages.python.interpreter).filter(|p| p.is_file());
|
||||
let inherited_python = inherited
|
||||
.and_then(|f| setting_path(f, &s.languages.python.interpreter))
|
||||
.filter(|p| p.is_file())
|
||||
.filter(|_| explicit_python.is_none());
|
||||
let explicit_python = setting_path(root, &s.languages.python.interpreter);
|
||||
let pm = non_empty(&s.languages.node.package_manager);
|
||||
let php = setting_path(root, &s.languages.php.interpreter);
|
||||
let rust = rust_bin(root, s);
|
||||
@@ -1482,9 +1464,7 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
|
||||
// A module outside the project (an external linked build) keeps the project's choices.
|
||||
let near = if owner.starts_with(root) { owner } else { root };
|
||||
match tool {
|
||||
// A member with no Python of its own runs on the primary project's.
|
||||
"python" => python_interpreter_near(root, Some(near), s)
|
||||
.or_else(|| inherited.and_then(|f| python_interpreter_near(f, Some(f), s))),
|
||||
"python" => python_interpreter_near(root, Some(near), s),
|
||||
"node" if non_empty(&s.languages.node.interpreter).is_none() => {
|
||||
node_dir(near, s).or_else(|| node_dir(root, s))
|
||||
}
|
||||
@@ -1516,10 +1496,7 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
|
||||
let mut path_dirs: Vec<PathBuf> = Vec::new();
|
||||
match c.kind.as_str() {
|
||||
"python" | "pytest" | "django-test" => {
|
||||
let python = toolchain("python", &owner);
|
||||
// The member's explicit interpreter, else the inherited one, else the
|
||||
// member's own venv / pin.
|
||||
if let Some(py) = explicit_python.as_ref().or(inherited_python.as_ref()).or(python.as_ref()) {
|
||||
if let Some(py) = &explicit_python {
|
||||
let runner = PY_RUNNERS.contains(&c.command.as_str());
|
||||
if (c.kind == "python" || c.kind == "django-test") && (runner || is_python_command(&c.command)) {
|
||||
if runner && c.args.first().map(String::as_str) == Some("run") {
|
||||
@@ -1538,6 +1515,7 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
|
||||
c.args = ["-m".to_owned(), "pytest".to_owned()].into_iter().chain(rest).collect();
|
||||
}
|
||||
}
|
||||
let python = toolchain("python", &owner);
|
||||
if let Some(venv) = python.as_deref().and_then(venv_of)
|
||||
&& !c.env.contains_key("VIRTUAL_ENV")
|
||||
{
|
||||
@@ -1675,16 +1653,9 @@ pub fn apply_to_run_configs_in(configs: &mut [RunConfig], root: &Path, fallback:
|
||||
/// `VIRTUAL_ENV`, `JAVA_HOME`, `GOROOT`, …) or an absolute command its bare name
|
||||
/// resolves to (the project interpreter, a toolchain's `cargo` / `go`, Maven's `mvn`).
|
||||
pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConfig {
|
||||
without_derived_in(config, root, None, s)
|
||||
}
|
||||
|
||||
/// [`without_derived`] with the inherited fallback project the configuration runs with
|
||||
/// (see [`apply_to_run_configs_in`]): a derived command or environment entry is derived
|
||||
/// only when re-applying settings with the same fallback reproduces it.
|
||||
pub fn without_derived_in(config: &RunConfig, root: &Path, fallback: Option<&Path>, s: &Settings) -> RunConfig {
|
||||
let applied = |c: &RunConfig| {
|
||||
let mut c = c.clone();
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut c), root, fallback, s);
|
||||
apply_to_run_configs(std::slice::from_mut(&mut c), root, s);
|
||||
c
|
||||
};
|
||||
let mut out = config.clone();
|
||||
|
||||
@@ -282,16 +282,10 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
|
||||
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
|
||||
/// an older JDK the server dies resolving its bundles.
|
||||
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
|
||||
jdtls_required_java(cfg).unwrap_or(JDTLS_MIN_JAVA)
|
||||
}
|
||||
|
||||
/// [`jdtls_min_java`] when the installed jdtls tells; `None` when none is
|
||||
/// installed or its bundles can't be read.
|
||||
pub fn jdtls_required_java(cfg: &LanguageServerConfig) -> Option<u32> {
|
||||
crate::jdtls::script_for(cfg)
|
||||
let required = crate::jdtls::script_for(cfg)
|
||||
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
|
||||
.and_then(|home| bundles_min_java(&home))
|
||||
.map(|v| v.max(JDTLS_MIN_JAVA))
|
||||
.and_then(|home| bundles_min_java(&home));
|
||||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||||
}
|
||||
|
||||
/// Per install: (plugins directory mtime, its requirement).
|
||||
|
||||
@@ -108,7 +108,6 @@ pub fn project_libraries_stream(
|
||||
})
|
||||
.collect();
|
||||
let rust = s.spawn(|| {
|
||||
let project = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut groups = Vec::new();
|
||||
for dir in &roots {
|
||||
@@ -137,11 +136,7 @@ pub fn project_libraries_stream(
|
||||
if !seen.insert(workspace.clone()) {
|
||||
continue;
|
||||
}
|
||||
// Resolving writes a Cargo.lock. A crate outside the project's workspace (one in its
|
||||
// `exclude`) without its own keeps none, as RustRover leaves crates it doesn't attach.
|
||||
let locked = workspace.canonicalize().unwrap_or_else(|_| workspace.clone()) != project
|
||||
&& !workspace.join("Cargo.lock").is_file();
|
||||
let batch = rust_groups(&workspace, &settings, locked, cancel);
|
||||
let batch = rust_groups(&workspace, &settings, cancel);
|
||||
if !cancel.is_cancelled() && !batch.is_empty() {
|
||||
on_groups(batch.clone());
|
||||
groups.extend(batch);
|
||||
@@ -773,8 +768,7 @@ pub(crate) fn package_dependencies(package_json: &str) -> Vec<String> {
|
||||
|
||||
// ------------------------------------------------------------------ rust
|
||||
|
||||
/// `locked`: `cargo metadata --locked`, which fails instead of writing a Cargo.lock.
|
||||
fn rust_groups(root: &Path, s: &Settings, locked: bool, cancel: &CancellationToken) -> Vec<LibraryGroup> {
|
||||
fn rust_groups(root: &Path, s: &Settings, cancel: &CancellationToken) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, CARGO_MANIFESTS) else { return Vec::new() };
|
||||
if !root.join("Cargo.toml").is_file() {
|
||||
return Vec::new();
|
||||
@@ -799,11 +793,14 @@ fn rust_groups(root: &Path, s: &Settings, locked: bool, cancel: &CancellationTok
|
||||
});
|
||||
if let Some(cargo) = tool(bin, "cargo") {
|
||||
let graph = cached_optional(root, "cargo", r.toolchain.clone(), &files, || {
|
||||
let mut args = vec!["metadata", "--format-version", "1", "--offline"];
|
||||
if locked {
|
||||
args.push("--locked");
|
||||
}
|
||||
let json = query_in_cancellable(&cargo, &args, root, &[], GRAPH_TIMEOUT, cancel)?;
|
||||
let json = query_in_cancellable(
|
||||
&cargo,
|
||||
&["metadata", "--format-version", "1", "--offline"],
|
||||
root,
|
||||
&[],
|
||||
GRAPH_TIMEOUT,
|
||||
cancel,
|
||||
)?;
|
||||
let roots = parse_cargo_metadata(&json)?;
|
||||
Some(group("Cargo dependencies", "cargo", named_roots(roots)).into_iter().collect())
|
||||
});
|
||||
@@ -1109,50 +1106,6 @@ mod go_cache_tests {
|
||||
assert_eq!(std::fs::read_to_string(calls).unwrap().lines().count(), 1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cargo_metadata_writes_no_lock_file_for_a_crate_outside_the_workspace() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
std::fs::write(root.join("Cargo.toml"), "[workspace]\nmembers=['app']\nexclude=['vendored']\n").unwrap();
|
||||
for name in ["app", "vendored"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(root.join(name).join("Cargo.toml"), format!("[package]\nname='{name}'\nversion='0.1.0'\n"))
|
||||
.unwrap();
|
||||
}
|
||||
let bin = root.join("tools");
|
||||
std::fs::create_dir(&bin).unwrap();
|
||||
let calls = root.join("metadata-calls");
|
||||
// Like cargo: an excluded crate is its own workspace.
|
||||
let cargo = format!(
|
||||
"#!/bin/sh\nif [ \"$1\" = locate-project ]; then case \"$(pwd -P)\" in */vendored) echo \"$(pwd -P)/Cargo.toml\";; *) echo '{}';; esac; exit; fi\necho \"$(pwd -P) $*\" >> '{}'\nexit 1\n",
|
||||
root.join("Cargo.toml").display(),
|
||||
calls.display()
|
||||
);
|
||||
for (name, script) in [("cargo", cargo.as_str()), ("rustc", "#!/bin/sh\nexit 1\n")] {
|
||||
std::fs::write(bin.join(name), script).unwrap();
|
||||
std::fs::set_permissions(bin.join(name), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.rust.toolchain = "tools".into();
|
||||
let metadata = |dir: &Path| {
|
||||
let prefix = format!("{} metadata ", dir.display());
|
||||
let calls = std::fs::read_to_string(&calls).unwrap();
|
||||
calls.lines().rev().find_map(|l| l.strip_prefix(&prefix).map(str::to_owned))
|
||||
};
|
||||
|
||||
project_libraries(&root, &settings, &[]);
|
||||
// The project's own workspace resolves as before, writing its Cargo.lock if it has none.
|
||||
assert_eq!(metadata(&root).as_deref(), Some("--format-version 1 --offline"));
|
||||
assert_eq!(metadata(&root.join("vendored")).as_deref(), Some("--format-version 1 --offline --locked"));
|
||||
|
||||
// A crate with its own Cargo.lock keeps resolving with it.
|
||||
std::fs::write(root.join("vendored/Cargo.lock"), "version = 4\n").unwrap();
|
||||
project_libraries(&root, &settings, &[]);
|
||||
assert_eq!(metadata(&root.join("vendored")).as_deref(), Some("--format-version 1 --offline"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn libraries_publish_node_and_maven_while_cargo_is_running_and_cancel_it() {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::apply::{
|
||||
apply_to_run_configs, apply_to_run_configs_in, configure_server, jdtls_jdk, maven_command, maven_options,
|
||||
project_jdk, python_interpreter, ruff_configuration, terminal_activation, tool_install_environment, tsdk,
|
||||
without_derived, without_derived_in,
|
||||
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
|
||||
ruff_configuration, terminal_activation, tool_install_environment, tsdk, without_derived,
|
||||
};
|
||||
use crate::python::parse_pip_list;
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
@@ -63,14 +62,6 @@ fn run(kind: &str, command: &str, args: &[&str]) -> RunConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// An interpreter executable at `rel` under `root` (resolution skips paths that don't exist).
|
||||
fn fake_interpreter(root: &Path, rel: &str) -> std::path::PathBuf {
|
||||
let path = root.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, "").unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_venv_is_the_default_interpreter() {
|
||||
if cfg!(windows) {
|
||||
@@ -83,10 +74,8 @@ fn project_venv_is_the_default_interpreter() {
|
||||
assert_eq!(python_interpreter(tmp.path(), &s), Some(py.clone()));
|
||||
assert_eq!(venv_of(&py), Some(tmp.path().join(".venv")));
|
||||
let mut s = Settings::default();
|
||||
// A configured interpreter wins over the virtualenv while its executable exists.
|
||||
let configured = fake_interpreter(tmp.path(), "opt/py/bin/python3");
|
||||
s.languages.python.interpreter = configured.to_string_lossy().into_owned();
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), configured);
|
||||
s.languages.python.interpreter = "/opt/py/bin/python3".into();
|
||||
assert_eq!(python_interpreter(tmp.path(), &s).unwrap(), Path::new("/opt/py/bin/python3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -315,9 +304,7 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
let python = fake_interpreter(tmp.path(), "opt/py/bin/python3");
|
||||
let python = python.to_string_lossy().into_owned();
|
||||
s.languages.python.interpreter = python.clone();
|
||||
s.languages.python.interpreter = "/opt/py/bin/python3".into();
|
||||
s.languages.node.package_manager = "pnpm".into();
|
||||
let mut configs = vec![
|
||||
run("python", "uv", &["run", "python", "-m", "app"]),
|
||||
@@ -329,17 +316,17 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
run("django-test", ".venv/bin/python", &["manage.py", "test", "polls"]),
|
||||
];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
assert_eq!(configs[0].command, python);
|
||||
assert_eq!(configs[0].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[0].args, ["-m", "app"]);
|
||||
assert_eq!(configs[1].command, python);
|
||||
assert_eq!(configs[1].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[1].args, ["-m", "pytest"]);
|
||||
assert_eq!(configs[2].args, ["-m", "pytest", "-x"]);
|
||||
assert_eq!(configs[3].command, "pnpm");
|
||||
assert_eq!(configs[4].command, "python3", "shell configs are left alone");
|
||||
// Django tests run on the selected interpreter, like `manage.py runserver`.
|
||||
assert_eq!(configs[5].command, python);
|
||||
assert_eq!(configs[5].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[5].args, ["manage.py", "test"]);
|
||||
assert_eq!(configs[6].command, python);
|
||||
assert_eq!(configs[6].command, "/opt/py/bin/python3");
|
||||
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
|
||||
}
|
||||
|
||||
@@ -2244,13 +2231,11 @@ fn nested_python_configs_get_their_own_virtualenv_but_explicit_settings_stay_pro
|
||||
apply_to_run_configs(std::slice::from_mut(&mut member), root, &Settings::default());
|
||||
assert_eq!(member.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.python.interpreter = root.join("custom/bin").join(exe("python")).to_string_lossy().into_owned();
|
||||
std::fs::create_dir_all(root.join("custom/bin")).unwrap();
|
||||
std::fs::write(root.join("custom/bin").join(exe("python")), "").unwrap();
|
||||
settings.languages.python.interpreter = "custom/bin/python".into();
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("apps/python".into());
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
assert_eq!(config.command, root.join("custom/bin").join(exe("python")).to_string_lossy());
|
||||
assert_eq!(config.command, root.join("custom/bin/python").to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2260,97 +2245,11 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
|
||||
let outer = fake_venv(root, ".venv");
|
||||
let inner = fake_venv(&root.join("apps/api"), ".venv");
|
||||
let near = root.join("apps/api/src/package");
|
||||
let settings = Settings::default();
|
||||
let mut settings = Settings::default();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(inner));
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&root.join("apps/other")), &settings), Some(outer));
|
||||
let mut settings = Settings::default();
|
||||
let selected = root.join("selected").join(exe("python"));
|
||||
std::fs::create_dir_all(root.join("selected")).unwrap();
|
||||
std::fs::write(&selected, "").unwrap();
|
||||
settings.languages.python.interpreter = selected.to_string_lossy().into_owned();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(selected));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interpreter_that_no_longer_exists_falls_back_to_the_project_virtualenv() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let venv = fake_venv(root, ".venv");
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.python.interpreter = root.join("gone/python").to_string_lossy().into_owned();
|
||||
// Resolution skips the missing executable like PyCharm's "Invalid" SDKs.
|
||||
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(venv.clone()));
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, None, &settings), Some(venv.clone()));
|
||||
// Run configurations use the fallback instead of the dead path.
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
assert_eq!(config.command, venv.to_string_lossy());
|
||||
assert_eq!(config.env["VIRTUAL_ENV"], venv.parent().unwrap().parent().unwrap().to_string_lossy());
|
||||
// A configured interpreter that still exists keeps winning over the venv.
|
||||
let real = root.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
let real_python = real.join(exe("python"));
|
||||
std::fs::write(&real_python, "").unwrap();
|
||||
settings.languages.python.interpreter = real_python.to_string_lossy().into_owned();
|
||||
assert_eq!(crate::apply::python_interpreter(root, &settings), Some(real_python));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_without_python_falls_back_to_the_primary_project_interpreter() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
let primary = root.join("primary");
|
||||
let bare = root.join("bare");
|
||||
let outer = fake_venv(&primary, ".venv");
|
||||
// An attached member with a venv of its own uses it.
|
||||
let own = fake_venv(&root.join("own"), ".venv");
|
||||
let settings = Settings::default();
|
||||
assert_eq!(crate::apply::python_interpreter_near(&root.join("own"), Some(&own), &settings), Some(own));
|
||||
|
||||
// A run configuration of the bare member runs on the primary project's venv.
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("src".into());
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &settings);
|
||||
assert_eq!(config.command, outer.to_string_lossy());
|
||||
assert_eq!(config.env["VIRTUAL_ENV"], outer.parent().unwrap().parent().unwrap().to_string_lossy());
|
||||
// Saving strips what settings derived, so the fallback keeps following settings.
|
||||
let stored = without_derived_in(&config, &bare, Some(&primary), &settings);
|
||||
assert_eq!(stored.command, exe("python"));
|
||||
assert!(!stored.env.contains_key("VIRTUAL_ENV"));
|
||||
|
||||
// The member's explicit interpreter wins over the primary project's venv —
|
||||
// when it exists (bare names stay bare; a missing path falls back).
|
||||
let mut chosen = Settings::default();
|
||||
chosen.languages.python.interpreter = root.join("bare/python").to_string_lossy().into_owned();
|
||||
std::fs::create_dir_all(&bare).unwrap();
|
||||
let bare_python = bare.join(exe("python"));
|
||||
std::fs::write(&bare_python, "").unwrap();
|
||||
chosen.languages.python.interpreter = bare_python.to_string_lossy().into_owned();
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("src".into());
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &chosen);
|
||||
assert_eq!(config.command, bare_python.to_string_lossy());
|
||||
assert!(!config.env.contains_key("VIRTUAL_ENV"));
|
||||
|
||||
// An interpreter inherited from the primary follows a later change of its setting
|
||||
// (the paths are created here: resolution skips missing executables).
|
||||
let mut inherited = Settings::default();
|
||||
for dir in ["primary", "moved"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
std::fs::write(root.join(dir).join(exe("python")), "").unwrap();
|
||||
}
|
||||
let primary_python = root.join("primary").join(exe("python"));
|
||||
let moved_python = root.join("moved").join(exe("python"));
|
||||
inherited.languages.python.interpreter = primary_python.to_string_lossy().into_owned();
|
||||
let mut config = run("python", "python3", &["main.py"]);
|
||||
config.extras.module = Some("src".into());
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut config), &bare, Some(&primary), &inherited);
|
||||
assert_eq!(config.command, primary_python.to_string_lossy());
|
||||
let stored = without_derived_in(&config, &bare, Some(&primary), &inherited);
|
||||
inherited.languages.python.interpreter = moved_python.to_string_lossy().into_owned();
|
||||
let mut reloaded = stored;
|
||||
apply_to_run_configs_in(std::slice::from_mut(&mut reloaded), &bare, Some(&primary), &inherited);
|
||||
assert_eq!(reloaded.command, moved_python.to_string_lossy());
|
||||
settings.languages.python.interpreter = "selected/python".into();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2451,17 +2350,12 @@ fn tool_installs_resolve_project_relative_sdks_and_leave_path_fallback_unconfigu
|
||||
assert!(!defaults.env.contains_key("GOROOT"));
|
||||
s.languages.go.goroot = "go-sdk".into();
|
||||
s.languages.node.interpreter = "node-sdk/bin/node".into();
|
||||
s.languages.python.interpreter = "python-sdk/bin/python3.14".into();
|
||||
s.languages.ruby.interpreter = "ruby-sdk/bin/ruby".into();
|
||||
let runtime = tool_install_environment(root, &s);
|
||||
assert_eq!(runtime.commands["go"], root.join("go-sdk/bin").join(if cfg!(windows) { "go.exe" } else { "go" }));
|
||||
assert_eq!(runtime.env["GOROOT"], root.join("go-sdk").to_string_lossy());
|
||||
assert_eq!(runtime.commands["node"], root.join("node-sdk/bin/node"));
|
||||
assert_eq!(runtime.commands["python"], root.join("python-sdk/bin/python3.14"));
|
||||
assert_eq!(runtime.commands["ruby"], root.join("ruby-sdk/bin/ruby"));
|
||||
// A configured interpreter whose executable is missing is skipped here too:
|
||||
// the tool install environment only names Pythons that can actually run.
|
||||
std::fs::create_dir_all(root.join("python-sdk/bin")).unwrap();
|
||||
let real = root.join("python-sdk/bin").join(exe("python3.14"));
|
||||
std::fs::write(&real, "").unwrap();
|
||||
s.languages.python.interpreter = real.to_string_lossy().into_owned();
|
||||
assert_eq!(tool_install_environment(root, &s).commands["python"], real);
|
||||
}
|
||||
@@ -35,22 +35,6 @@ impl RemoteFs {
|
||||
Self::Sftp(SftpFs::new(sftp))
|
||||
}
|
||||
|
||||
/// Lets [`RemoteFs::tree`] list a whole folder tree with one command over `shell`.
|
||||
pub fn with_shell(self, shell: std::sync::Arc<crate::Session>) -> Self {
|
||||
match self {
|
||||
Self::Sftp(fs) => Self::Sftp(fs.with_shell(shell)),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// See [`SftpFs::tree`]; `None` means list folder by folder.
|
||||
pub async fn tree(&self, dir: &str, prune: &[String]) -> Option<Vec<RemoteEntry>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.tree(dir, prune).await,
|
||||
Self::Ftp(_) | Self::Local => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn home(&self) -> IdeResult<String> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.home().await,
|
||||
|
||||
@@ -227,35 +227,20 @@ impl Session {
|
||||
}
|
||||
let (mut read, write) = channel.split();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let hangup = Arc::new(tokio::sync::Notify::new());
|
||||
let hung_up = hangup.clone();
|
||||
let mut on_output = on_output;
|
||||
tokio::spawn(async move {
|
||||
let mut code = None;
|
||||
loop {
|
||||
// After the exit status, output still in flight is read until the channel goes
|
||||
// quiet: a child that inherited stdout keeps the channel open past the exit.
|
||||
let msg = match code {
|
||||
// russh forwards no close to a channel this side closed.
|
||||
None => tokio::select! {
|
||||
msg = read.wait() => msg,
|
||||
() = hung_up.notified() => None,
|
||||
},
|
||||
Some(_) => tokio::time::timeout(EXIT_DRAIN, read.wait()).await.ok().flatten(),
|
||||
};
|
||||
let Some(msg) = msg else { break };
|
||||
while let Some(msg) = read.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
|
||||
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
|
||||
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
|
||||
// Closed without a status (hung up): nothing more arrives.
|
||||
ChannelMsg::Close => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(code);
|
||||
});
|
||||
Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)), hangup })
|
||||
Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)) })
|
||||
}
|
||||
|
||||
/// Writes `data` to `remote` (absolute; its folder is created) through an
|
||||
@@ -332,9 +317,6 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a process's channel may stay quiet after its exit status before it counts as finished.
|
||||
const EXIT_DRAIN: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
|
||||
/// A chunk of a remote process's output.
|
||||
pub enum Output<'a> {
|
||||
Stdout(&'a [u8]),
|
||||
@@ -345,7 +327,6 @@ pub enum Output<'a> {
|
||||
pub struct RemoteProcess {
|
||||
write: Arc<ChannelWriteHalf<Msg>>,
|
||||
exit: Mutex<Option<oneshot::Receiver<Option<u32>>>>,
|
||||
hangup: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
impl RemoteProcess {
|
||||
@@ -372,22 +353,10 @@ impl RemoteProcess {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Closes the channel (an interactive shell hangs up); the process ends without a status.
|
||||
pub async fn close(&self) {
|
||||
let _ = self.write.close().await;
|
||||
self.hangup.notify_one();
|
||||
}
|
||||
|
||||
/// Waits for the exit status (`None`: killed, or no status reported). Once.
|
||||
pub async fn wait(&self) -> Option<u32> {
|
||||
self.exit_status().await
|
||||
}
|
||||
|
||||
/// [`Self::wait`] without borrowing the process, so dropping it meanwhile still
|
||||
/// releases the channel. Once.
|
||||
pub fn exit_status(&self) -> impl Future<Output = Option<u32>> + Send + 'static {
|
||||
let rx = self.exit.lock().take();
|
||||
async move { rx?.await.ok().flatten() }
|
||||
let rx = self.exit.lock().take()?;
|
||||
rx.await.ok().flatten()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,29 +80,11 @@ fn err(path: &str, e: SftpError) -> IdeError {
|
||||
/// Higher-level operations on an [`SftpSession`].
|
||||
pub struct SftpFs {
|
||||
sftp: SftpSession,
|
||||
/// The SSH session behind it, for commands that list a whole tree at once ([`SftpFs::tree`]).
|
||||
shell: Option<std::sync::Arc<crate::Session>>,
|
||||
}
|
||||
|
||||
impl SftpFs {
|
||||
pub fn new(sftp: SftpSession) -> Self {
|
||||
Self { sftp, shell: None }
|
||||
}
|
||||
|
||||
pub fn with_shell(self, shell: std::sync::Arc<crate::Session>) -> Self {
|
||||
Self { shell: Some(shell), ..self }
|
||||
}
|
||||
|
||||
/// Every file and symlink below `dir` (not into folders named by `prune`) in one `find`
|
||||
/// command instead of a listing per folder. `None` when there is no shell or the host's
|
||||
/// `find` can't do it (no GNU `-printf`, an unreadable folder): list folder by folder then.
|
||||
pub async fn tree(&self, dir: &str, prune: &[String]) -> Option<Vec<RemoteEntry>> {
|
||||
let shell = self.shell.as_ref()?;
|
||||
let out = shell.exec(&tree_command(dir, prune), None).await.ok()?;
|
||||
if !out.success() {
|
||||
return None;
|
||||
}
|
||||
parse_tree(&out.stdout)
|
||||
Self { sftp }
|
||||
}
|
||||
|
||||
/// The login directory.
|
||||
@@ -287,61 +269,10 @@ pub fn mtime_of(path: &Path) -> Option<u32> {
|
||||
u32::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()
|
||||
}
|
||||
|
||||
/// `find` listing the files and symlinks below `dir` as `type\tsize\tmtime\tpath\0` records.
|
||||
fn tree_command(dir: &str, prune: &[String]) -> String {
|
||||
let mut cmd = format!("LC_ALL=C find {} -mindepth 1 ", crate::shell::quote(dir));
|
||||
if !prune.is_empty() {
|
||||
let names: Vec<String> = prune.iter().map(|p| format!("-name {}", crate::shell::quote(p))).collect();
|
||||
cmd.push_str(&format!("\\( {} \\) -prune -o ", names.join(" -o ")));
|
||||
}
|
||||
cmd.push_str("\\( -type f -o -type l \\) -printf '%y\\t%s\\t%T@\\t%p\\0'");
|
||||
cmd
|
||||
}
|
||||
|
||||
fn parse_tree(out: &[u8]) -> Option<Vec<RemoteEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
for record in out.split(|&b| b == 0).filter(|r| !r.is_empty()) {
|
||||
let record = std::str::from_utf8(record).ok()?;
|
||||
let mut fields = record.splitn(4, '\t');
|
||||
let (kind, size, mtime, path) = (fields.next()?, fields.next()?, fields.next()?, fields.next()?);
|
||||
let mtime = mtime.split('.').next()?.parse::<u64>().ok()?;
|
||||
entries.push(RemoteEntry {
|
||||
name: path.rsplit('/').next().unwrap_or(path).to_owned(),
|
||||
path: path.to_owned(),
|
||||
is_dir: false,
|
||||
is_symlink: kind == "l",
|
||||
size: size.parse().ok()?,
|
||||
mtime: u32::try_from(mtime).ok(),
|
||||
permissions: String::new(),
|
||||
});
|
||||
}
|
||||
Some(entries)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lists_a_tree_with_one_find() {
|
||||
assert_eq!(
|
||||
tree_command("/srv/my app", &[".git".into(), "*.pyc".into()]),
|
||||
"LC_ALL=C find '/srv/my app' -mindepth 1 \\( -name .git -o -name '*.pyc' \\) -prune -o \
|
||||
\\( -type f -o -type l \\) -printf '%y\\t%s\\t%T@\\t%p\\0'"
|
||||
);
|
||||
let out = b"f\t12\t1700000000.5000000000\t/srv/a b\ttab.txt\0l\t7\t1700000001.0\t/srv/link\0";
|
||||
let entries = parse_tree(out).unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(
|
||||
(entries[0].path.as_str(), entries[0].name.as_str(), entries[0].size, entries[0].mtime),
|
||||
("/srv/a b\ttab.txt", "a b\ttab.txt", 12, Some(1_700_000_000))
|
||||
);
|
||||
assert!(entries[1].is_symlink && !entries[1].is_dir);
|
||||
assert!(parse_tree(b"").unwrap().is_empty());
|
||||
// Not `find -printf` output (BusyBox, an error message): list folder by folder instead.
|
||||
assert!(parse_tree(b"find: unrecognized: -printf\n").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_and_splits_paths() {
|
||||
assert_eq!(join("/srv/app/", "a.txt"), "/srv/app/a.txt");
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! An in-process SSH server for tests (feature `testing`): password `dev` /
|
||||
//! `secret`, commands answered by a closure, SFTP served from a local
|
||||
//! folder (remote `/x` is `<root>/x`), and a shell that echoes its input
|
||||
//! (`exit` ends it with status 0).
|
||||
//! `secret`, commands answered by a closure, and SFTP served from a local
|
||||
//! folder (remote `/x` is `<root>/x`).
|
||||
|
||||
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
|
||||
use russh::{Channel, ChannelId};
|
||||
@@ -42,7 +41,7 @@ struct Sshd {
|
||||
impl server::Server for Sshd {
|
||||
type Handler = Conn;
|
||||
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Conn {
|
||||
Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default(), shells: Default::default() }
|
||||
Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,7 +49,6 @@ struct Conn {
|
||||
root: PathBuf,
|
||||
exec: ExecFn,
|
||||
channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
|
||||
shells: std::collections::HashSet<ChannelId>,
|
||||
}
|
||||
|
||||
impl server::Handler for Conn {
|
||||
@@ -86,61 +84,6 @@ impl server::Handler for Conn {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn pty_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
_term: &str,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
_modes: &[(russh::Pty, u32)],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
session.data(channel, format!("pty {cols}x{rows}\r\n").into_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn shell_request(&mut self, channel: ChannelId, session: &mut Session) -> Result<(), Self::Error> {
|
||||
self.shells.insert(channel);
|
||||
session.channel_success(channel)?;
|
||||
session.data(channel, b"$ ".to_vec())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn window_change_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
cols: u32,
|
||||
rows: u32,
|
||||
_pix_width: u32,
|
||||
_pix_height: u32,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.data(channel, format!("resized {cols}x{rows}\r\n").into_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn data(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> {
|
||||
if !self.shells.contains(&channel) {
|
||||
return Ok(());
|
||||
}
|
||||
session.data(channel, data.to_vec())?;
|
||||
if String::from_utf8_lossy(data).contains("exit") {
|
||||
self.shells.remove(&channel);
|
||||
session.exit_status_request(channel, 0)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_close(&mut self, channel: ChannelId, _session: &mut Session) -> Result<(), Self::Error> {
|
||||
self.shells.remove(&channel);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subsystem_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
|
||||
@@ -59,8 +59,7 @@ impl server::Handler for Sshd {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3. `linger` exits but keeps
|
||||
/// the channel open, as when a child it started still holds stdout.
|
||||
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3.
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
@@ -71,10 +70,8 @@ impl server::Handler for Sshd {
|
||||
session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?;
|
||||
session.extended_data(channel, 1, b"oops".to_vec())?;
|
||||
session.exit_status_request(channel, 3)?;
|
||||
if data != b"linger" {
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
}
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -297,30 +294,6 @@ async fn runs_commands_and_streams_processes() {
|
||||
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_process_that_exits_finishes_even_while_its_channel_stays_open() {
|
||||
let env = env().await;
|
||||
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
|
||||
let seen: Arc<Mutex<String>> = Arc::default();
|
||||
let s = seen.clone();
|
||||
let process = session
|
||||
.spawn(
|
||||
"linger",
|
||||
None,
|
||||
Box::new(move |o| {
|
||||
if let Output::Stdout(b) = o {
|
||||
s.lock().push_str(&String::from_utf8_lossy(b));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let code = tokio::time::timeout(std::time::Duration::from_secs(10), process.wait())
|
||||
.await
|
||||
.expect("the exit status must end the process");
|
||||
assert_eq!((code, seen.lock().as_str()), (Some(3), "ran: linger"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_reports_a_command_that_stops_reading() {
|
||||
let env = env().await;
|
||||
|
||||
@@ -85,8 +85,6 @@ const FISH_INIT: &str = concat!(
|
||||
r#"function __openidea_preexec --on-event fish_preexec; printf '\e]133;C\a'; end"#,
|
||||
);
|
||||
|
||||
const POWERSHELL_INIT: &str = r#"$openideaClearHost = (Get-Command Clear-Host).ScriptBlock; $openideaClearHandler = { & $openideaClearHost @args; [Console]::Write([char]27 + ']133;OpenIDEA;ClearScrollback' + [char]7) }.GetNewClosure(); Set-Item Function:global:Clear-Host -Value $openideaClearHandler"#;
|
||||
|
||||
/// Directory holding the generated startup files (written on demand).
|
||||
fn scripts_dir() -> PathBuf {
|
||||
std::env::temp_dir().join(format!("openidea-shell-integration-{}", env!("CARGO_PKG_VERSION")))
|
||||
@@ -106,13 +104,6 @@ fn write_if_changed(path: &Path, content: &str) -> std::io::Result<()> {
|
||||
/// resolved arguments; returns `false` (leaving everything untouched) for
|
||||
/// shells without integration or when the scripts cannot be written.
|
||||
pub(crate) fn apply(shell_name: &str, args: &mut Vec<String>, env: &mut BTreeMap<String, String>) -> bool {
|
||||
if cfg!(windows) && (shell_name.eq_ignore_ascii_case("pwsh") || shell_name.eq_ignore_ascii_case("powershell")) {
|
||||
if !args.is_empty() {
|
||||
return false;
|
||||
}
|
||||
args.extend(["-NoExit".into(), "-Command".into(), POWERSHELL_INIT.into()]);
|
||||
return true;
|
||||
}
|
||||
if !cfg!(unix) {
|
||||
return false;
|
||||
}
|
||||
@@ -166,23 +157,6 @@ pub(crate) fn apply(shell_name: &str, args: &mut Vec<String>, env: &mut BTreeMap
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn powershell_clear_integration_only_changes_plain_windows_starts() {
|
||||
for shell in ["pwsh", "powershell", "PwSh"] {
|
||||
let mut args = Vec::new();
|
||||
let mut env = BTreeMap::new();
|
||||
assert_eq!(apply(shell, &mut args, &mut env), cfg!(windows));
|
||||
if cfg!(windows) {
|
||||
assert_eq!(args, ["-NoExit", "-Command", POWERSHELL_INIT]);
|
||||
} else {
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
let mut explicit = vec!["-NoProfile".into()];
|
||||
assert!(!apply(shell, &mut explicit, &mut env));
|
||||
assert_eq!(explicit, ["-NoProfile"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bash_gets_init_file_and_login_flag() {
|
||||
if cfg!(windows) {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Integrated terminal sessions over a native PTY, or over a [`TerminalChannel`]
|
||||
//! such as an SSH shell. Rendering is xterm.js.
|
||||
//! Integrated terminal sessions over a native PTY. Rendering is xterm.js.
|
||||
|
||||
mod integration;
|
||||
mod utf8;
|
||||
@@ -53,56 +52,14 @@ pub struct TerminalInfo {
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
/// A terminal whose shell runs elsewhere (an SSH channel). Its output and exit
|
||||
/// arrive through the [`TerminalFeed`] paired with it by [`feed`].
|
||||
pub trait TerminalChannel: Send + Sync {
|
||||
fn write(&self, data: &[u8]) -> IdeResult<()>;
|
||||
fn resize(&self, cols: u16, rows: u16) -> IdeResult<()>;
|
||||
/// Hangs up; the exit is still reported through the feed.
|
||||
fn close(&self);
|
||||
}
|
||||
|
||||
/// Where a [`TerminalChannel`] reports its output and exit.
|
||||
#[derive(Clone)]
|
||||
pub struct TerminalFeed(mpsc::Sender<Msg>);
|
||||
|
||||
impl TerminalFeed {
|
||||
pub fn output(&self, data: &[u8]) {
|
||||
let _ = self.0.send(Msg::Data(data.to_vec()));
|
||||
}
|
||||
|
||||
pub fn exited(&self, code: Option<i32>) {
|
||||
let _ = self.0.send(Msg::Exited(code));
|
||||
}
|
||||
}
|
||||
|
||||
/// The receiving end of a [`TerminalFeed`], handed to [`TerminalManager::attach`].
|
||||
pub struct FeedReceiver(mpsc::Receiver<Msg>);
|
||||
|
||||
/// A feed for a channel that is about to be opened.
|
||||
pub fn feed() -> (TerminalFeed, FeedReceiver) {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
(TerminalFeed(tx), FeedReceiver(rx))
|
||||
}
|
||||
|
||||
enum Io {
|
||||
Pty {
|
||||
// Cancel input before dropping the master (ClosePseudoConsole on Windows).
|
||||
writer: ide_core::input::InputWriter,
|
||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
},
|
||||
Channel {
|
||||
channel: Box<dyn TerminalChannel>,
|
||||
size: Mutex<(u16, u16)>,
|
||||
},
|
||||
}
|
||||
|
||||
struct Session {
|
||||
/// The window that opened it: closing that window's project closes its terminals.
|
||||
key: String,
|
||||
info: TerminalInfo,
|
||||
io: Io,
|
||||
// Cancel input before dropping the master (ClosePseudoConsole on Windows).
|
||||
writer: ide_core::input::InputWriter,
|
||||
master: Mutex<Box<dyn MasterPty + Send>>,
|
||||
killer: Mutex<Box<dyn ChildKiller + Send + Sync>>,
|
||||
exited: Arc<AtomicBool>,
|
||||
started: Instant,
|
||||
/// The first input was logged (startup diagnostics, see `spawn`).
|
||||
@@ -243,7 +200,9 @@ impl TerminalManager {
|
||||
let session = Arc::new(Session {
|
||||
key,
|
||||
info: info.clone(),
|
||||
io: Io::Pty { writer, master: Mutex::new(pair.master), killer: Mutex::new(child.clone_killer()) },
|
||||
master: Mutex::new(pair.master),
|
||||
writer,
|
||||
killer: Mutex::new(child.clone_killer()),
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
@@ -262,7 +221,7 @@ impl TerminalManager {
|
||||
.and_then(|_| spawn_pump(id, rx, on_output, exited, self.sessions.clone(), sink, Instant::now()));
|
||||
if let Err(e) = spawned {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
terminate(&s);
|
||||
let _ = s.killer.lock().kill();
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
@@ -272,41 +231,6 @@ impl TerminalManager {
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Registers an opened `channel` as terminal `title`; its output (from `feed`)
|
||||
/// is batched and decoded as a PTY's is, and its exit emits `IdeEvent::TerminalExited`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn attach(
|
||||
&self,
|
||||
title: String,
|
||||
size: (u16, u16),
|
||||
channel: Box<dyn TerminalChannel>,
|
||||
feed: FeedReceiver,
|
||||
key: String,
|
||||
sink: SharedSink,
|
||||
on_output: Box<dyn Fn(String) + Send>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
let id = TerminalId::next();
|
||||
let info = TerminalInfo { id, title, pid: None };
|
||||
let exited = Arc::new(AtomicBool::new(false));
|
||||
let session = Arc::new(Session {
|
||||
key,
|
||||
info: info.clone(),
|
||||
io: Io::Channel { channel, size: Mutex::new(size) },
|
||||
exited: exited.clone(),
|
||||
started: Instant::now(),
|
||||
input_logged: AtomicBool::new(false),
|
||||
});
|
||||
self.sessions.lock().insert(id, session);
|
||||
if let Err(e) = spawn_pump(id, feed.0, on_output, exited, self.sessions.clone(), sink, Instant::now()) {
|
||||
if let Some(s) = self.sessions.lock().remove(&id) {
|
||||
terminate(&s);
|
||||
}
|
||||
return Err(IdeError::internal(format!("failed to start terminal threads: {e}")));
|
||||
}
|
||||
tracing::info!(%id, title = info.title, "terminal attached");
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(&self, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
if !session.input_logged.swap(true, Ordering::Relaxed) {
|
||||
@@ -314,26 +238,17 @@ impl TerminalManager {
|
||||
let ms = session.started.elapsed().as_millis();
|
||||
tracing::info!(%id, ms, bytes = data.len(), cursor_report, "terminal first input");
|
||||
}
|
||||
match &session.io {
|
||||
Io::Pty { writer, .. } => writer.write(data.as_bytes())?,
|
||||
Io::Channel { channel, .. } => channel.write(data.as_bytes())?,
|
||||
}
|
||||
session.writer.write(data.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize(&self, id: TerminalId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
let session = self.session(id)?;
|
||||
match &session.io {
|
||||
Io::Pty { master, .. } => master
|
||||
.lock()
|
||||
.resize(pty_size(cols, rows))
|
||||
.map_err(|e| IdeError::internal(format!("resize failed: {e}"))),
|
||||
Io::Channel { channel, size } => {
|
||||
let new = pty_size(cols, rows);
|
||||
*size.lock() = (new.cols, new.rows);
|
||||
channel.resize(new.cols, new.rows)
|
||||
}
|
||||
}
|
||||
session
|
||||
.master
|
||||
.lock()
|
||||
.resize(pty_size(cols, rows))
|
||||
.map_err(|e| IdeError::internal(format!("resize failed: {e}")))
|
||||
}
|
||||
|
||||
/// Closes the terminals `key` opened: the project of that window is closing.
|
||||
@@ -377,13 +292,8 @@ impl TerminalManager {
|
||||
/// Current PTY size of a session (for diagnostics and tests).
|
||||
pub fn size(&self, id: TerminalId) -> IdeResult<(u16, u16)> {
|
||||
let session = self.session(id)?;
|
||||
match &session.io {
|
||||
Io::Pty { master, .. } => {
|
||||
let size = master.lock().get_size().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok((size.cols, size.rows))
|
||||
}
|
||||
Io::Channel { size, .. } => Ok(*size.lock()),
|
||||
}
|
||||
let size = session.master.lock().get_size().map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
Ok((size.cols, size.rows))
|
||||
}
|
||||
|
||||
fn session(&self, id: TerminalId) -> IdeResult<Arc<Session>> {
|
||||
@@ -411,8 +321,7 @@ fn not_found(id: TerminalId) -> IdeError {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn foreground_busy(session: &Session, pid: u32) -> bool {
|
||||
let Io::Pty { master, .. } = &session.io else { return false };
|
||||
let leader = master.lock().process_group_leader();
|
||||
let leader = session.master.lock().process_group_leader();
|
||||
leader.is_some_and(|fg| u32::try_from(fg).ok() != Some(pid))
|
||||
}
|
||||
|
||||
@@ -482,20 +391,11 @@ mod win {
|
||||
|
||||
/// Hangs up the shell (and its process group), escalating to SIGKILL if it lingers.
|
||||
fn terminate(session: &Session) {
|
||||
let (writer, killer) = match &session.io {
|
||||
Io::Pty { writer, killer, .. } => (writer, killer),
|
||||
Io::Channel { channel, .. } => {
|
||||
if !session.exited.load(Ordering::Acquire) {
|
||||
channel.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
writer.close();
|
||||
session.writer.close();
|
||||
if session.exited.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
if let Err(e) = killer.lock().kill() {
|
||||
if let Err(e) = session.killer.lock().kill() {
|
||||
tracing::debug!(id = %session.info.id, "terminal kill: {e}");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
@@ -1182,77 +1082,6 @@ mod tests {
|
||||
assert_eq!(env["TERM"], "dumb");
|
||||
assert_eq!(env["COLORTERM"], "truecolor");
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeChannel {
|
||||
log: Arc<Mutex<Vec<String>>>,
|
||||
}
|
||||
|
||||
impl TerminalChannel for FakeChannel {
|
||||
fn write(&self, data: &[u8]) -> IdeResult<()> {
|
||||
self.log.lock().push(format!("write {}", String::from_utf8_lossy(data)));
|
||||
Ok(())
|
||||
}
|
||||
fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
self.log.lock().push(format!("resize {cols}x{rows}"));
|
||||
Ok(())
|
||||
}
|
||||
fn close(&self) {
|
||||
self.log.lock().push("close".into());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attached_channel_gets_input_and_resizes_and_reports_output_and_exit() {
|
||||
let h = harness();
|
||||
let channel = FakeChannel::default();
|
||||
let log = channel.log.clone();
|
||||
let (feed, rx) = feed();
|
||||
let (tx, output) = mpsc::channel();
|
||||
let info = h
|
||||
.manager
|
||||
.attach(
|
||||
"host".into(),
|
||||
(80, 24),
|
||||
Box::new(channel),
|
||||
rx,
|
||||
"test".into(),
|
||||
h.sink.clone(),
|
||||
Box::new(move |s| {
|
||||
let _ = tx.send(s);
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!((info.title.as_str(), info.pid), ("host", None));
|
||||
assert!(!h.manager.is_busy(info.id).unwrap());
|
||||
h.manager.write(info.id, "ls\r").unwrap();
|
||||
h.manager.resize(info.id, 120, 40).unwrap();
|
||||
assert_eq!(h.manager.size(info.id).unwrap(), (120, 40));
|
||||
// A character split across two chunks arrives whole.
|
||||
feed.output("pr\u{e9}".as_bytes().split_at(3).0);
|
||||
feed.output(&"pr\u{e9}mpt".as_bytes()[3..]);
|
||||
assert_eq!(read_until(&output, "mpt"), "pr\u{e9}mpt");
|
||||
feed.exited(Some(3));
|
||||
assert_eq!(wait_exit(&h, info.id), Some(3));
|
||||
assert!(!h.manager.contains(info.id));
|
||||
assert_eq!(*log.lock(), ["write ls\r", "resize 120x40"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn killing_an_attached_channel_hangs_it_up() {
|
||||
let h = harness();
|
||||
let channel = FakeChannel::default();
|
||||
let log = channel.log.clone();
|
||||
let (feed, rx) = feed();
|
||||
let info = h
|
||||
.manager
|
||||
.attach("host".into(), (80, 24), Box::new(channel), rx, "test".into(), h.sink.clone(), Box::new(|_| {}))
|
||||
.unwrap();
|
||||
h.manager.kill(info.id).unwrap();
|
||||
assert_eq!(*log.lock(), ["close"]);
|
||||
drop(feed);
|
||||
assert_eq!(wait_exit(&h, info.id), None);
|
||||
}
|
||||
}
|
||||
|
||||
/// `resolve_shell` / `default_msystem` / `default_chere_invoking` behave the
|
||||
|
||||
@@ -407,17 +407,6 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
homepage: "https://github.com/graphql/graphiql",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "prisma-language-server",
|
||||
display: "Prisma Language Server",
|
||||
kind: LanguageServer,
|
||||
commands: &["prisma-language-server"],
|
||||
method: Method::Npm { packages: &["@prisma/language-server"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/prisma/language-tools",
|
||||
needs: &[],
|
||||
},
|
||||
ToolSpec {
|
||||
name: "intelephense",
|
||||
display: "Intelephense (PHP)",
|
||||
|
||||
@@ -783,17 +783,6 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
Ok(self.blob_of(path, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
|
||||
}
|
||||
/// The file's git mode at `rev` as printed by git (`100644`, `100755`, `120000`…),
|
||||
/// `None` when absent there. `rev` follows `file_at`'s rules, `WT` reads the worktree.
|
||||
pub fn file_mode_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
if rev == Some("WT") {
|
||||
return repo::worktree_mode(self.root(), &self.rel_path(path)?);
|
||||
}
|
||||
let rel = self.rel_path(path)?;
|
||||
let repo = self.gix()?;
|
||||
repo::mode_at(&repo, &rel, rev.unwrap_or("HEAD"))
|
||||
}
|
||||
/// Raw revision bytes, for encoding detection when the working file is absent.
|
||||
pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult<Option<Vec<u8>>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
|
||||
@@ -255,56 +255,6 @@ fn index_spelling(index: &gix::index::State, rel: &str) -> Option<String> {
|
||||
found
|
||||
}
|
||||
|
||||
/// The mode a worktree file would be committed with (`100755` when executable,
|
||||
/// `100644` otherwise, `120000` for a symlink), `None` when `abs` is not a file.
|
||||
/// On filesystems without an executable bit git can't record one, so it is `100644`.
|
||||
pub(crate) fn worktree_mode(root: &Path, rel: &str) -> IdeResult<Option<String>> {
|
||||
let meta = match std::fs::symlink_metadata(root.join(rel)) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
return Ok(Some("120000".to_owned()));
|
||||
}
|
||||
if !meta.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
Ok(Some(if meta.permissions().mode() & 0o111 != 0 { "100755" } else { "100644" }.to_owned()))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Ok(Some("100644".to_owned()))
|
||||
}
|
||||
|
||||
/// The entry's mode of `rel` at `rev` as git prints it (`100644`, `100755`, `120000`,
|
||||
/// `160000`…), `None` when the path is absent there. Same `rev` rules as [`blob_at`].
|
||||
pub(crate) fn mode_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<String>> {
|
||||
if let Some(stage) = rev.strip_prefix(':') {
|
||||
let stage = match stage {
|
||||
"" | "0" => gix::index::entry::Stage::Unconflicted,
|
||||
"1" => gix::index::entry::Stage::Base,
|
||||
"2" => gix::index::entry::Stage::Ours,
|
||||
"3" => gix::index::entry::Stage::Theirs,
|
||||
other => return Err(IdeError::invalid(format!("invalid index stage :{other}"))),
|
||||
};
|
||||
let index = repo.index_or_empty().map_err(gx)?;
|
||||
let Some(entry) = index.entry_by_path_and_stage(BStr::new(rel.as_bytes()), stage) else {
|
||||
return Ok(None);
|
||||
};
|
||||
return Ok(entry.mode.to_tree_entry_mode().map(|m| format!("{:o}", m.value())));
|
||||
}
|
||||
let Some(tree) = tree_of(repo, rev)? else { return Ok(None) };
|
||||
let Some(entry) = tree.lookup_entry_by_path(Path::new(rel)).map_err(gx)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !entry.mode().is_blob_or_symlink() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(format!("{:o}", entry.mode().value())))
|
||||
}
|
||||
|
||||
/// Blob content of `rel` at `rev`. `rev` is a revision (`HEAD`, a branch, an id) or
|
||||
/// `:N` for index stage N (`:` / `:0` = the merged index entry).
|
||||
pub(crate) fn blob_at(repo: &gix::Repository, rel: &str, rev: &str) -> IdeResult<Option<Vec<u8>>> {
|
||||
|
||||
@@ -125,8 +125,6 @@ fn gix_status(r: &Repo, ignored: &IgnoredNames) -> IdeResult<Entries> {
|
||||
ChangeKind::Conflicted
|
||||
}
|
||||
EntryStatus::Change(WtChange::Removed) => ChangeKind::Deleted,
|
||||
// `content_change` is `None` when only the executable bit changed:
|
||||
// still a modification `git status` lists and `git commit` records.
|
||||
EntryStatus::Change(_) => ChangeKind::Modified,
|
||||
EntryStatus::IntentToAdd => ChangeKind::Added,
|
||||
EntryStatus::NeedsUpdate(_) => continue,
|
||||
@@ -404,40 +402,6 @@ mod tests {
|
||||
assert_eq!(from_git.1, ["conflict.txt"]);
|
||||
}
|
||||
|
||||
/// A chmod-only change is a modification `git status` lists and `git commit`
|
||||
/// records, so status keeps it (#2853): hidden, the Commit view could not show
|
||||
/// or commit what a push would carry. `core.fileMode=false` reports nothing.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_mode_only_change_is_reported_only_when_git_tracks_modes() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
for file_mode in ["true", "false"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
git(dir, &["config", "user.name", "Test User"]);
|
||||
git(dir, &["config", "user.email", "[email protected]"]);
|
||||
git(dir, &["config", "commit.gpgsign", "false"]);
|
||||
git(dir, &["config", "core.fileMode", file_mode]);
|
||||
std::fs::write(dir.join("script.sh"), "#!/bin/sh\n").unwrap();
|
||||
commit_all(dir, "base");
|
||||
std::fs::set_permissions(dir.join("script.sh"), std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let repo = Repo::discover(dir).unwrap().expect("repository found");
|
||||
let status = repo.status().unwrap();
|
||||
let entry = status.iter().find(|f| f.rel_path == "script.sh");
|
||||
if file_mode == "true" {
|
||||
assert_eq!(
|
||||
entry.and_then(|f| f.unstaged),
|
||||
Some(ChangeKind::Modified),
|
||||
"a mode-only change is modified"
|
||||
);
|
||||
} else {
|
||||
assert!(entry.is_none(), "core.fileMode=false hides mode changes");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// git answers status when gitoxide can't read a directory; in Restricted Mode that run
|
||||
/// must not execute the repository's filters.
|
||||
#[cfg(unix)]
|
||||
|
||||
@@ -235,7 +235,7 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
|
||||
let head_only = req.method == "HEAD";
|
||||
let response = if !matches!(req.method.as_str(), "GET" | "HEAD") {
|
||||
Response::Error(405, "Method Not Allowed")
|
||||
} else if !host_allowed(req.host.as_deref()) {
|
||||
} else if !host_allowed(req.host.as_deref(), root.port) {
|
||||
Response::Error(403, "Forbidden")
|
||||
} else {
|
||||
resolve(root, &req.target).await
|
||||
@@ -274,11 +274,8 @@ async fn serve(mut sock: TcpStream, root: &Root) -> std::io::Result<()> {
|
||||
sock.shutdown().await
|
||||
}
|
||||
|
||||
/// Loopback names only (DNS rebinding). Any port: a remote host's server is reached through
|
||||
/// a forwarded port of the client, and a browser names the port it connected to anyway.
|
||||
fn host_allowed(host: Option<&str>) -> bool {
|
||||
host.and_then(|h| h.rsplit_once(':'))
|
||||
.is_some_and(|(name, port)| matches!(name, "127.0.0.1" | "localhost") && port.parse::<u16>().is_ok())
|
||||
fn host_allowed(host: Option<&str>, port: u16) -> bool {
|
||||
host.is_some_and(|h| h == format!("127.0.0.1:{port}") || h == format!("localhost:{port}"))
|
||||
}
|
||||
|
||||
async fn resolve(root: &Root, target: &str) -> Response {
|
||||
@@ -427,9 +424,6 @@ mod tests {
|
||||
let other = url.replacen(token, "0123", 1);
|
||||
assert!(get(&other, None).await.starts_with("HTTP/1.1 404"));
|
||||
assert!(get(&url, Some("evil.example:80")).await.starts_with("HTTP/1.1 403"));
|
||||
assert!(get(&url, Some("127.0.0.1.evil.example:80")).await.starts_with("HTTP/1.1 403"));
|
||||
// Through a forwarded port of a remote client.
|
||||
assert!(get(&url, Some("127.0.0.1:61234")).await.starts_with("HTTP/1.1 200"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
Loaded 100 of 333 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача