From 57fd6def32cc51eafe3b787a02a627a3a51af12a Mon Sep 17 00:00:00 2001 From: JetSquirrel Date: Wed, 23 Sep 2026 17:02:13 +0800 Subject: [PATCH] Guard user data from registered files, dashboards and apps; per-tab results - re-attaching a registered file never replaces a table or view the user made: DuckLocal tags what it creates with a catalog comment and reports a name clash instead of CREATE OR REPLACE over the user's relation - .dash queries must be one read-only statement (DuckDB's own json_serialize_sql judgement, PIVOT allowed); the GUI refuses anything else before it runs, and check/lsp report it as a diagnostic - an app folder asks "Trust and run" once before any of its code or SQL runs; the answer is remembered per folder in settings - each query tab owns its results panel, and a run lands in the tab that started it, so export uses that tab's SQL - non-UTF-8 command-line paths no longer panic the GUI launch - docs and skill updated for the dashboard and app rules Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/analysis-app.md | 9 ++- docs/cli.md | 2 +- docs/zh/analysis-app.md | 7 +- docs/zh/cli.md | 2 +- skills/ducklocal/SKILL.md | 4 +- skills/ducklocal/references/cli.md | 2 +- src/analysis/apps.rs | 48 ++++++++++++ src/analysis/view.rs | 119 +++++++++++++++++++++++++++-- src/db.rs | 79 ++++++++++++++++++- src/excel.rs | 19 +++-- src/i18n.rs | 17 +++++ src/main.rs | 9 ++- src/spec/lsp.rs | 4 +- src/spec/mod.rs | 76 +++++++++++++++++- src/spec/view.rs | 11 ++- src/state.rs | 14 +++- src/ui/workspace.rs | 38 ++++----- 17 files changed, 405 insertions(+), 55 deletions(-) diff --git a/docs/analysis-app.md b/docs/analysis-app.md index d5dbff0..c24feb2 100644 --- a/docs/analysis-app.md +++ b/docs/analysis-app.md @@ -223,6 +223,13 @@ of other databases, `INSTALL`/`LOAD` of extensions, and queries against S3 views credentials are already configured. Treat an app's JavaScript as code you are choosing to run, exactly as you would a shell script. +So the choice is asked for. The first time a folder opens as an app — from the command line, +a drop, the picker, or a tab restored at launch — its tab says what the app's SQL can do and +waits: **View source** shows the entry file without running it, **Trust and run** runs it. +The answer is remembered per folder, so later launches and every reload after a save run +without asking again. `ducklocal export --html` runs the app you name on the command line +and does not ask. + What an app does **not** get is anything else the process could do: - **No filesystem, network, process, or environment module.** `fs`, `net`, `process` and @@ -247,7 +254,7 @@ Because it is the loading state, a statement an app only runs on a click is not A `.dash` file is a dashboard declared as data — query and plot blocks, no JavaScript — for the common case of standard plots over saved queries. The file format and `ducklocal check` validation are in [the CLI guide](cli.md#check-a-dashboard-spec). -Open one like an app: name it on the command line (`ducklocal dashboard.dash`) or drag the file onto the window, and it opens as a dashboard tab beside your queries and apps. The tab runs the spec's queries on the window's own connection — so a dashboard sees connection-local state such as `TEMP` tables, and queues with the editor's queries — and renders each plot as one panel of a vertical stack whose dividers drag to resize. A plot whose query fails shows the reason in its own panel; the rest of the dashboard still draws. The toolbar's reload re-reads the file and re-runs everything, and a reload that fails validation never replaces a working dashboard — the reason appears above it instead. Open dashboards are remembered between launches, exactly like apps. +Open one like an app: name it on the command line (`ducklocal dashboard.dash`) or drag the file onto the window, and it opens as a dashboard tab beside your queries and apps. The tab runs the spec's queries on the window's own connection — so a dashboard sees connection-local state such as `TEMP` tables, and queues with the editor's queries — and renders each plot as one panel of a vertical stack whose dividers drag to resize. Only read-only statements run: a query that could write (DDL, DML, `COPY`, `ATTACH`, …) is refused before it reaches the connection, so opening a `.dash` file someone sent you cannot change your data. A plot whose query fails shows the reason in its own panel; the rest of the dashboard still draws. The toolbar's reload re-reads the file and re-runs everything, and a reload that fails validation never replaces a working dashboard — the reason appears above it instead. Open dashboards are remembered between launches, exactly like apps. The tab is also an editor: its source view edits the file with completion and diagnostics, and writes back with the save button or ⌘S — a save whose spec no longer validates keeps the last working dashboard up and says why. Outside the GUI, `ducklocal lsp` serves the same completion, diagnostics, hover and go-to-definition to any LSP-capable editor (see [the CLI guide](cli.md#edit-a-dashboard-spec-with-lsp)). diff --git a/docs/cli.md b/docs/cli.md index 81e36ad..37fc2f3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -177,7 +177,7 @@ ducklocal check dashboard.dash ducklocal check dashboard.dash --database warehouse.duckdb ``` -A `query` block holds one `sql` attribute — one statement, as a heredoc or a string. A `plot` block holds `type` (one of `line`, `bar`, `area`, `scatter`, `table`), `query` (a reference like `query.latency` to a query block in the same file), `x` and `y` (result columns, as bare identifiers or quoted strings; `y` is optional for `table`), plus optional `series` and `title`. `#` and `//` comment to end of line. That is the whole language: no functions, no conditionals, no interpolation. +A `query` block holds one `sql` attribute — one read-only statement, as a heredoc or a string. `SELECT`, `WITH`, `FROM`-first, `VALUES`, `SHOW`, `DESCRIBE`, `SUMMARIZE` and `PIVOT` are accepted; anything that could write — DDL, DML, `COPY`, `ATTACH`, `INSTALL` — is a diagnostic, and the GUI refuses to run it, because a dashboard's queries run whenever the file is opened. A `plot` block holds `type` (one of `line`, `bar`, `area`, `scatter`, `table`), `query` (a reference like `query.latency` to a query block in the same file), `x` and `y` (result columns, as bare identifiers or quoted strings; `y` is optional for `table`), plus optional `series` and `title`. `#` and `//` comment to end of line. That is the whole language: no functions, no conditionals, no interpolation. Without `--database` the check is fully static — no table needs to exist and nothing executes. Each query's SQL is validated by the real DuckDB parser on a throwaway connection, the way `query` validates before running. With `--database PATH` (existing file, read-only) every query is additionally described — planned, not run — and each plot's `x`/`y`/`series` is checked against the columns the query actually returns; a `y` that is not numeric is an error for every type but `table`. diff --git a/docs/zh/analysis-app.md b/docs/zh/analysis-app.md index 25ce3c5..271ab09 100644 --- a/docs/zh/analysis-app.md +++ b/docs/zh/analysis-app.md @@ -196,6 +196,11 @@ npx --yes -p typescript tsc -p <应用目录>/jsconfig.json --noImplicitAny fals 包括 `COPY` 写文件、`ATTACH` 其他数据库、`INSTALL`/`LOAD` 扩展,以及查询已配置好的 S3 视图。请把应用 里的 JavaScript 当作你主动选择运行的代码,就像对待一个 shell 脚本那样。 +因此这个选择会明确征求你的同意。一个文件夹第一次作为应用打开时——无论来自命令行、拖放、选择器,还是启动时恢复的 +标签页——标签页会先说明应用的 SQL 能做什么并等待:**先看源码**只显示入口文件、不运行,**信任并运行**才会运行。 +同意按文件夹记住,之后的启动以及保存后的每次重新加载都不再询问。`ducklocal export --html` 运行的是你在命令行 +上点名的应用,不会询问。 + 应用拿不到的,是进程的其他能力: - **没有文件系统、网络、进程与环境变量模块。** 除非宿主授予,否则脚本无法使用 `fs`、`net`、 @@ -219,7 +224,7 @@ npx --yes -p typescript tsc -p <应用目录>/jsconfig.json --noImplicitAny fals `.dash` 文件把 dashboard 声明为数据——query 与 plot block,没有 JavaScript——适合「已保存查询 + 标准图表」的常见场景。文件格式与 `ducklocal check` 校验见 [CLI 指南](cli.md#校验-dashboard-规格文件)。 -打开方式与应用相同:在命令行指定(`ducklocal dashboard.dash`),或把文件拖到窗口上,就会以 dashboard 标签页的形式打开,与查询、应用并列。标签页在窗口自己的连接上执行规格里的查询——因此能看到 `TEMP` 表等连接级状态,也会与编辑器的查询排队——并把每个 plot 画成竖直堆叠中的一格,分隔条可拖动调整高度。某个 plot 的查询失败时,原因显示在它自己的格子里,其余部分照常绘制。工具栏的重新加载会重读文件并重跑所有查询;重载后的规格若不再通过校验,不会替换掉仍在工作的 dashboard,而是在上方显示原因。已打开的 dashboard 会像应用一样被记住,下次启动自动恢复。 +打开方式与应用相同:在命令行指定(`ducklocal dashboard.dash`),或把文件拖到窗口上,就会以 dashboard 标签页的形式打开,与查询、应用并列。标签页在窗口自己的连接上执行规格里的查询——因此能看到 `TEMP` 表等连接级状态,也会与编辑器的查询排队——并把每个 plot 画成竖直堆叠中的一格,分隔条可拖动调整高度。只有只读语句会被执行:可能写入的查询(DDL、DML、`COPY`、`ATTACH` 等)在到达连接之前就被拒绝,因此打开别人发来的 `.dash` 文件不会改动你的数据。某个 plot 的查询失败时,原因显示在它自己的格子里,其余部分照常绘制。工具栏的重新加载会重读文件并重跑所有查询;重载后的规格若不再通过校验,不会替换掉仍在工作的 dashboard,而是在上方显示原因。已打开的 dashboard 会像应用一样被记住,下次启动自动恢复。 标签页同时也是编辑器:源码视图可以直接修改文件,带补全和诊断,用保存按钮或 ⌘S 写回——保存后的规格若不再通过校验,仍在工作的 dashboard 不会被替换,并会显示原因。在 GUI 之外,`ducklocal lsp` 把同样的补全、诊断、悬停和跳转定义提供给任何支持 LSP 的编辑器(见 [CLI 指南](cli.md#用-lsp-编辑-dashboard-规格文件))。 diff --git a/docs/zh/cli.md b/docs/zh/cli.md index 3e9bb9a..23d865d 100644 --- a/docs/zh/cli.md +++ b/docs/zh/cli.md @@ -177,7 +177,7 @@ ducklocal check dashboard.dash ducklocal check dashboard.dash --database warehouse.duckdb ``` -`query` block 只含一个 `sql` 属性——一条语句,heredoc 或字符串。`plot` block 含 `type`(`line`、`bar`、`area`、`scatter`、`table` 之一)、`query`(指向同文件某个 query block 的引用,如 `query.latency`)、`x` 和 `y`(结果列名,可写裸标识符或带引号的字符串;`table` 类型不需要 `y`),以及可选的 `series`、`title`。`#` 和 `//` 注释到行尾。这就是全部语法:没有函数、没有条件、没有插值。 +`query` block 只含一个 `sql` 属性——一条只读语句,heredoc 或字符串。接受 `SELECT`、`WITH`、`FROM` 开头、`VALUES`、`SHOW`、`DESCRIBE`、`SUMMARIZE` 与 `PIVOT`;任何可能写入的语句——DDL、DML、`COPY`、`ATTACH`、`INSTALL`——都会报诊断,GUI 也拒绝执行,因为 dashboard 的查询在文件一打开时就会运行。`plot` block 含 `type`(`line`、`bar`、`area`、`scatter`、`table` 之一)、`query`(指向同文件某个 query block 的引用,如 `query.latency`)、`x` 和 `y`(结果列名,可写裸标识符或带引号的字符串;`table` 类型不需要 `y`),以及可选的 `series`、`title`。`#` 和 `//` 注释到行尾。这就是全部语法:没有函数、没有条件、没有插值。 不带 `--database` 时校验完全静态——表不需要存在,什么都不会执行。每条查询的 SQL 由真正的 DuckDB parser 在一次性连接上校验,与 `query` 执行前的校验相同。带 `--database PATH`(已存在的文件,只读打开)时,每条查询还会被 describe——只规划、不执行——并把每个 plot 的 `x`/`y`/`series` 与查询实际返回的列逐一核对;对 `table` 以外的类型,`y` 不是数值列也是错误。 diff --git a/skills/ducklocal/SKILL.md b/skills/ducklocal/SKILL.md index 38e7cc0..57989bb 100644 --- a/skills/ducklocal/SKILL.md +++ b/skills/ducklocal/SKILL.md @@ -40,13 +40,13 @@ plot "revenue" { } ``` -A `query` block holds one `sql` attribute (one statement, heredoc or string). A `plot` block holds `type` (`line`, `bar`, `area`, `scatter`, `table`), `query` (a `query.name` reference), `x` and `y` (result columns, bare identifiers or quoted strings; `y` optional for `table`), optional `series` and `title`. No functions, conditionals, or interpolation exist. There is a working example at `examples/analysis_app/dashboard.dash`. +A `query` block holds one `sql` attribute (one read-only statement — SELECT, WITH, FROM, VALUES, SHOW, DESCRIBE, SUMMARIZE or PIVOT — heredoc or string; DDL, DML, COPY, ATTACH and INSTALL are rejected). A `plot` block holds `type` (`line`, `bar`, `area`, `scatter`, `table`), `query` (a `query.name` reference), `x` and `y` (result columns, bare identifiers or quoted strings; `y` optional for `table`), optional `series` and `title`. No functions, conditionals, or interpolation exist. There is a working example at `examples/analysis_app/dashboard.dash`. Always validate before handing a spec over: `ducklocal check dashboard.dash`, or `ducklocal check dashboard.dash --database warehouse.duckdb` to also verify every `x`/`y`/`series` against the columns the queries actually return (a non-numeric `y` is an error outside `table`). A spec mistake is exit 2 with kind `spec`, one `file:line: message` per diagnostic — fix all of them, not just the first. To see it rendered, open the file in the GUI (`ducklocal dashboard.dash` or drag it onto the window): it becomes a dashboard tab, a resizable vertical stack of the plots with per-plot inline errors. ## Authoring an app -An analysis app is a folder holding `main.js`: a default-exported `View` subclass. `init(props, cx)` runs at load; `render()` returns the UI tree. The smallest working app: +An analysis app is a folder holding `main.js`: a default-exported `View` subclass. `init(props, cx)` runs at load; `render()` returns the UI tree. The first time a folder opens in the GUI its tab asks the user to **Trust and run** before any of its code or SQL runs — tell the user to expect that when you hand an app over. The smallest working app: ```js import { View, div } from "gpui-kit"; diff --git a/skills/ducklocal/references/cli.md b/skills/ducklocal/references/cli.md index 18afef9..ee96e38 100644 --- a/skills/ducklocal/references/cli.md +++ b/skills/ducklocal/references/cli.md @@ -53,7 +53,7 @@ ducklocal check dashboard.dash ducklocal check dashboard.dash --database warehouse.duckdb ``` -Validates a `.dash` file — `query "name" { sql = < Restored { } } +/// The `settings` key prefix under which a trusted app folder is recorded. +const TRUST_PREFIX: &str = "app_trusted:"; + +/// An app's SQL runs with the user's full database privileges — `COPY` to +/// files, `ATTACH`, `read_text` of anything readable — and apps open without a +/// click: from the command line, from a drop, and again at every launch. So +/// the first run of a folder waits for the user to say yes, once per folder. +/// The folder rather than its content is what is trusted: editing an app and +/// saving it is the authoring loop, and asking again on every save would train +/// the user to click through. +fn trust_key(directory: &Path) -> String { + let directory = directory + .canonicalize() + .unwrap_or_else(|_| directory.to_path_buf()); + format!("{TRUST_PREFIX}{}", directory.to_string_lossy()) +} + +/// Whether the user has said this folder's app may run. +pub fn is_trusted(directory: &Path) -> bool { + crate::history::get_setting(&trust_key(directory)) + .ok() + .flatten() + .is_some() +} + +/// Record that the user trusts this folder's app. +pub fn trust(directory: &Path) -> anyhow::Result<()> { + crate::history::set_setting(&trust_key(directory), &crate::history::now_timestamp()) +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn trust_is_remembered_per_folder() { + let _guard = crate::db::connection_guard(); + crate::history::with_test_history(|| { + let trusted = TempDir::new("trust_yes"); + trusted.app(); + let other = TempDir::new("trust_no"); + other.app(); + + assert!(!is_trusted(trusted.path())); + trust(trusted.path()).unwrap(); + assert!(is_trusted(trusted.path())); + // The same folder by another spelling is the same folder. + assert!(is_trusted(&trusted.path().join("."))); + assert!(!is_trusted(other.path())); + }); + } + /// A directory that removes itself, named for the test that made it. struct TempDir(PathBuf); diff --git a/src/analysis/view.rs b/src/analysis/view.rs index a6e70f2..5582a6f 100644 --- a/src/analysis/view.rs +++ b/src/analysis/view.rs @@ -12,7 +12,7 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::{Duration, Instant}; -use gpui_kit::component::button::Button; +use gpui_kit::component::button::{Button, ButtonVariants}; use gpui_kit::component::spinner::Spinner; use gpui_kit::component::{h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable}; use gpui_kit::*; @@ -41,6 +41,10 @@ pub struct AnalysisHost { /// could not be read. definition: Option, showing_definition: bool, + /// Whether the user has said this directory's app may run. Until then + /// nothing is loaded — not even on a file change — and the tab asks; see + /// [`crate::analysis::apps::is_trusted`] for why. + trusted: bool, /// Declared last on purpose: fields drop in declaration order, and the /// mounted view holds QuickJS handles into this runtime, so it has to be /// released first. @@ -65,6 +69,7 @@ impl AnalysisHost { watcher: None, definition: None, showing_definition: false, + trusted: false, runtime: None, }; match runtime { @@ -112,6 +117,7 @@ impl AnalysisHost { cx.notify(); return; } + self.trusted = crate::analysis::apps::is_trusted(&directory); self.directory = Some(directory.clone()); self.mounted = None; self.failure = None; @@ -122,6 +128,23 @@ impl AnalysisHost { cx.notify(); } + /// The user said yes: remember it for this folder and run the app. A + /// history store that cannot be written (a second instance holds it) + /// still trusts the folder for as long as this tab is open. + fn trust_and_run(&mut self, window: &mut Window, cx: &mut Context) { + let Some(directory) = self.directory.clone() else { + return; + }; + if let Err(error) = crate::analysis::apps::trust(&directory) { + tracing::warn!( + "Could not remember that {} is trusted: {error}", + directory.display() + ); + } + self.trusted = true; + self.reload(window, cx); + } + /// Mount the app again, the way the toolbar's Refresh does. pub fn refresh(&mut self, window: &mut Window, cx: &mut Context) { if self.showing_definition { @@ -139,6 +162,10 @@ impl AnalysisHost { let Some(directory) = self.directory.clone() else { return; }; + if !self.trusted { + cx.notify(); + return; + } // A directory that is gone is not a broken app: it is an app with no // directory, which is the state the tab starts from and can leave by // choosing another one. @@ -443,6 +470,69 @@ impl AnalysisHost { .into_any_element() } + /// The folder's app has not been agreed to: say what running it allows, + /// offer its source to read first, and run it only on a click. + fn render_untrusted(&self, cx: &mut Context) -> AnyElement { + let directory = self + .directory + .as_deref() + .map(|directory| directory.to_string_lossy().to_string()) + .unwrap_or_default(); + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_3() + .p_6() + .child( + Icon::new(IconName::TriangleAlert) + .large() + .text_color(cx.theme().warning), + ) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .child(tr("analysis.trust.title")), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .max_w_96() + .text_center() + .child(directory), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .max_w_96() + .text_center() + .child(tr("analysis.trust.body")), + ) + .child( + h_flex() + .gap_2() + .child( + Button::new("app-view-source") + .outline() + .small() + .label(tr("analysis.trust.view_source")) + .on_click(cx.listener(|this, _, _, cx| this.toggle_definition(cx))), + ) + .child( + Button::new("app-trust") + .primary() + .small() + .label(tr("analysis.trust.run")) + .on_click(cx.listener(|this, _, window, cx| { + this.trust_and_run(window, cx) + })), + ), + ) + .into_any_element() + } + fn render_loading(&self, cx: &App) -> AnyElement { v_flex() .size_full() @@ -476,19 +566,23 @@ enum Body { App, /// A directory was chosen and its app is being mounted. Loading, + /// A directory was chosen whose app the user has not yet agreed to run. + Untrusted, /// A directory was chosen and its app did not load. Failed, /// No directory is chosen. Empty, } -fn body(mounted: bool, directory: Option<&Path>, failure: Option<&str>) -> Body { +fn body(mounted: bool, directory: Option<&Path>, failure: Option<&str>, trusted: bool) -> Body { if mounted { Body::App } else if directory.is_none() { // A rejected or vanished directory leaves this state, with the reason // shown: the step that starts the work has to stay available. Body::Empty + } else if !trusted { + Body::Untrusted } else if failure.is_some() { Body::Failed } else { @@ -511,6 +605,7 @@ impl Render for AnalysisHost { self.mounted.is_some(), self.directory.as_deref(), self.failure.as_deref(), + self.trusted, ) { Body::App => self .mounted @@ -525,6 +620,7 @@ impl Render for AnalysisHost { }) .unwrap_or_else(|| self.render_empty(cx)), Body::Loading => self.render_loading(cx), + Body::Untrusted => self.render_untrusted(cx), Body::Failed => self.render_failure(cx), Body::Empty => self.render_empty(cx), }; @@ -558,17 +654,17 @@ mod tests { #[test] fn a_chosen_directory_that_has_not_mounted_is_loading_not_empty() { let directory = Path::new("/apps/sales"); - assert_eq!(body(false, Some(directory), None), Body::Loading); + assert_eq!(body(false, Some(directory), None, true), Body::Loading); // The empty state belongs to the tab that has no directory at all. - assert_eq!(body(false, None, None), Body::Empty); + assert_eq!(body(false, None, None, true), Body::Empty); } #[test] fn a_rejected_directory_stays_empty_so_its_action_stays_available() { let reason = "no main.js"; - assert_eq!(body(false, None, Some(reason)), Body::Empty); + assert_eq!(body(false, None, Some(reason), true), Body::Empty); assert_eq!( - body(false, Some(Path::new("/apps/sales")), Some(reason)), + body(false, Some(Path::new("/apps/sales")), Some(reason), true), Body::Failed ); } @@ -578,11 +674,20 @@ mod tests { // A reload that failed keeps the app up; the reason is drawn above // it, not instead of it. assert_eq!( - body(true, Some(Path::new("/apps/sales")), Some("boom")), + body(true, Some(Path::new("/apps/sales")), Some("boom"), true), Body::App ); } + #[test] + fn an_untrusted_app_asks_before_it_loads_or_fails() { + let directory = Path::new("/apps/sales"); + assert_eq!(body(false, Some(directory), None, false), Body::Untrusted); + assert_eq!(body(false, Some(directory), Some("boom"), false), Body::Untrusted); + // With no directory there is nothing to trust yet. + assert_eq!(body(false, None, None, false), Body::Empty); + } + #[test] fn a_rejection_says_which_directory_and_what_is_missing() { let path = Path::new("/apps/missing"); diff --git a/src/db.rs b/src/db.rs index a487c1d..e4b0ec5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -11,7 +11,7 @@ use std::collections::HashSet; use std::sync::{Arc, LazyLock, Mutex}; use anyhow::{anyhow, Result}; -use duckdb::Connection; +use duckdb::{Connection, OptionalExt}; use crate::i18n::trf; @@ -308,14 +308,48 @@ pub fn attach_data_file_as_of(conn: &Connection, path: &str, view_name: &str) -> } let reader = data_file_reader(&expanded) .ok_or_else(|| anyhow!(trf("error.unsupported_file_type", &[&expanded])))?; + ensure_replaceable_of(conn, &expanded, view_name)?; let quoted_ident = view_name.replace('"', "\"\""); let quoted_path = expanded.replace('\'', "''"); conn.execute_batch(&format!( - "CREATE OR REPLACE VIEW \"{quoted_ident}\" AS SELECT * FROM {reader}('{quoted_path}')" + "CREATE OR REPLACE VIEW \"{quoted_ident}\" AS SELECT * FROM {reader}('{quoted_path}'); + COMMENT ON VIEW \"{quoted_ident}\" IS '{OWNED_COMMENT}';" ))?; Ok(()) } +/// The catalog comment on every table and view DuckLocal creates from a file. +/// It is how a later re-attach tells its own relation, which it may replace, +/// from one the user made under the same name, which it must not. +pub(crate) const OWNED_COMMENT: &str = "ducklocal: attached file"; + +/// Refuse when `name` is taken in the current schema by a relation DuckLocal +/// did not create. Registered files are re-attached into whatever database is +/// open, and a `CREATE OR REPLACE` there would drop the user's own `orders` +/// table for a workbook's sheet — and in a file database, save that. +pub(crate) fn ensure_replaceable_of(conn: &Connection, path: &str, name: &str) -> Result<()> { + let foreign: Option = conn + .query_row( + "SELECT name FROM ( + SELECT table_name AS name, comment FROM duckdb_tables() + WHERE database_name = current_database() AND schema_name = current_schema() + UNION ALL + SELECT view_name, comment FROM duckdb_views() + WHERE database_name = current_database() AND schema_name = current_schema() + AND NOT internal + ) + WHERE lower(name) = lower(?1) AND coalesce(comment, '') != ?2 + LIMIT 1", + [name, OWNED_COMMENT], + |row| row.get(0), + ) + .optional()?; + match foreign { + Some(existing) => Err(anyhow!(trf("error.relation_taken", &[path, &existing]))), + None => Ok(()), + } +} + /// Re-import one sheet of a workbook under an explicit relation name; `sheet` /// of `None` means the first sheet. Used where the name is already known — /// re-attaching a registered file must keep the name the sidebar shows for it. @@ -530,6 +564,47 @@ mod tests { std::fs::remove_dir_all(&root).ok(); } + #[test] + fn re_attaching_never_replaces_a_relation_the_user_made() { + let conn = Connection::open_in_memory().unwrap(); + let root = std::env::temp_dir().join("ducklocal_reattach_foreign_test"); + std::fs::remove_dir_all(&root).ok(); + std::fs::create_dir_all(&root).unwrap(); + let csv = root.join("orders.csv"); + std::fs::write(&csv, "n\n1\n").unwrap(); + let xlsx = root.join("orders.xlsx"); + let mut book = rust_xlsxwriter::Workbook::new(); + let sheet = book.add_worksheet().set_name("Sheet1").unwrap(); + sheet.write_string(0, 0, "n").unwrap(); + sheet.write_number(1, 0, 1).unwrap(); + book.save(&xlsx).unwrap(); + + // Registered earlier as `orders`; now the open database has its own. + conn.execute_batch("CREATE TABLE orders(id INTEGER); INSERT INTO orders VALUES (42);") + .unwrap(); + let csv_error = attach_data_file_as_of(&conn, csv.to_str().unwrap(), "orders") + .unwrap_err() + .to_string(); + let sheet_error = + attach_excel_sheet_as_of(&conn, xlsx.to_str().unwrap(), Some("Sheet1"), "Orders") + .unwrap_err() + .to_string(); + assert!(csv_error.contains("orders"), "{csv_error}"); + assert!(sheet_error.contains("\"orders\""), "{sheet_error}"); + let kept: i64 = conn + .query_row("SELECT id FROM orders", [], |r| r.get(0)) + .unwrap(); + assert_eq!(kept, 42); + + // Its own relations DuckLocal still replaces on every re-attach. + attach_data_file_as_of(&conn, csv.to_str().unwrap(), "events").unwrap(); + attach_data_file_as_of(&conn, csv.to_str().unwrap(), "events").unwrap(); + attach_excel_sheet_as_of(&conn, xlsx.to_str().unwrap(), Some("Sheet1"), "sheet").unwrap(); + attach_excel_sheet_as_of(&conn, xlsx.to_str().unwrap(), Some("Sheet1"), "sheet").unwrap(); + + std::fs::remove_dir_all(&root).ok(); + } + /// Non-system views only: `duckdb_views()` also lists the catalog's own. fn user_view_count(conn: &Connection) -> i64 { conn.query_row( diff --git a/src/excel.rs b/src/excel.rs index 8c01d50..a790f8f 100644 --- a/src/excel.rs +++ b/src/excel.rs @@ -66,11 +66,20 @@ fn import( .map(|(name, kind)| format!("{} {}", quote(name), sql_type(*kind))) .collect::>() .join(", "); - let keyword = if temporary { "TEMP TABLE" } else { "TABLE" }; - conn.execute_batch(&format!( - "CREATE OR REPLACE {keyword} {} ({columns})", - quote(table_name) - ))?; + if temporary { + conn.execute_batch(&format!( + "CREATE OR REPLACE TEMP TABLE {} ({columns})", + quote(table_name) + ))?; + } else { + crate::db::ensure_replaceable_of(conn, path, table_name)?; + conn.execute_batch(&format!( + "CREATE OR REPLACE TABLE {name} ({columns}); + COMMENT ON TABLE {name} IS '{}';", + crate::db::OWNED_COMMENT, + name = quote(table_name), + ))?; + } let placeholders = (1..=names.len()) .map(|n| format!("?{n}")) diff --git a/src/i18n.rs b/src/i18n.rs index 2b886c1..45e7d60 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -363,6 +363,11 @@ static STRINGS: &[(&str, &str, &str)] = &[ // ── src/db.rs ─────────────────────────────────────────────────────── ("error.file_not_found", "文件不存在: {}", "File not found: {}"), + ( + "error.relation_taken", + "未挂载 {}:当前数据库已有名为 \"{}\" 的表或视图,DuckLocal 不会替换它。", + "Not attached {}: the database already has a table or view named \"{}\", which DuckLocal will not replace.", + ), ("error.unsupported_file_type", "不支持的文件类型: {}", "Unsupported file type: {}"), ( "error.excel_empty", @@ -474,6 +479,18 @@ static STRINGS: &[(&str, &str, &str)] = &[ "应用是留在该目录里的一个 JavaScript 视图——由 agent 或你自己编写;保存文件即会重新加载。", "An app is a JavaScript view left in that folder — by an agent, or by you. Saving the file reloads it.", ), + ( + "analysis.trust.title", + "要运行这个应用吗?", + "Run this app?", + ), + ( + "analysis.trust.body", + "应用的 SQL 拥有与 SQL 编辑器相同的权限:可以读写当前数据库、读取本机上任何可读的文件、COPY 到文件、ATTACH 其他数据库。只运行你信任的来源。同意后,这个文件夹以后会直接运行。", + "An app's SQL has the SQL editor's privileges: it can read and change the open database, read any file this machine can, COPY to files and ATTACH other databases. Run only apps from a source you trust. Once you agree, this folder runs without asking.", + ), + ("analysis.trust.run", "信任并运行", "Trust and run"), + ("analysis.trust.view_source", "先看源码", "View source"), ( "analysis.definition.unreadable", "无法读取 {}:{}", diff --git a/src/main.rs b/src/main.rs index 1267247..c9d049a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,9 +45,12 @@ fn main() { // Paths named on the command line: data files, folders, patterns, or a // database to open instead of the in-memory connection. `-psn_…` is what - // macOS appends when the app is launched from Finder or the Dock. - let paths: Vec = std::env::args() - .skip(1) + // macOS appends when the app is launched from Finder or the Dock. Lossy, + // like a drop on the window: `std::env::args` panics on a name that is + // not UTF-8, and a path that cannot be found is an error the UI reports. + let paths: Vec = args + .iter() + .map(|arg| arg.to_string_lossy().into_owned()) .filter(|arg| !arg.starts_with("-psn_")) .collect(); diff --git a/src/spec/lsp.rs b/src/spec/lsp.rs index f4e5442..dd64317 100644 --- a/src/spec/lsp.rs +++ b/src/spec/lsp.rs @@ -273,7 +273,7 @@ fn diagnose(source: &str, database: Option<&std::path::Path>) -> Vec) -> Vec Result { // SQL is checked with the real parser on a throwaway connection: nothing // executes, so no table needs to exist and no side effect can happen. for query in &spec.queries { - crate::cli::validate_sql(&query.sql).map_err(|error| { + validate_query_sql(&query.sql).map_err(|message| { spec_error( &path_display, query.line, - &format!("query {:?}: {}", query.name, error.message()), + &format!("query {:?}: {message}", query.name), ) })?; } @@ -150,6 +150,45 @@ pub fn check(args: &[OsString]) -> Result { .to_string()) } +/// A dashboard query's SQL, checked without running it: exactly one statement, +/// and one that only reads. A `.dash` file is something people send each +/// other, and the view runs its queries on open, on every change to the file +/// and on every launch that restores the tab — a `DROP`, `COPY … TO` or +/// `ATTACH` in it would run on the user's live connection before any plot +/// could say "not rows". +/// +/// "Only reads" is DuckDB's own judgement, not a keyword list: +/// `json_serialize_sql` serializes SELECT statements (CTEs, `FROM`-first, +/// `VALUES`, `TABLE`, `SHOW`, `DESCRIBE`, `SUMMARIZE` included) and refuses +/// everything else. Its one false refusal is `PIVOT`/`UNPIVOT`, which cannot +/// carry a write, so a statement led by either is let through. +pub(crate) fn validate_query_sql(sql: &str) -> Result<(), String> { + crate::cli::validate_sql(sql).map_err(|error| error.message().to_string())?; + let leading = sql + .trim_start() + .split(|c: char| !c.is_ascii_alphabetic()) + .next() + .unwrap_or("") + .to_ascii_lowercase(); + if matches!(leading.as_str(), "pivot" | "unpivot") { + return Ok(()); + } + let parser = duckdb::Connection::open_in_memory().map_err(|e| e.to_string())?; + let refused: bool = parser + .query_row( + "SELECT coalesce(json_serialize_sql(?1::VARCHAR)::JSON->>'error' = 'true', true)", + [sql], + |row| row.get(0), + ) + .map_err(|e| e.to_string())?; + if refused { + return Err("a dashboard query must be a single read-only statement \ + (SELECT, WITH, FROM, VALUES, SHOW, DESCRIBE, SUMMARIZE, PIVOT)" + .to_string()); + } + Ok(()) +} + /// The columns a query returns, per DuckDB's own description of it. fn describe( connection: &duckdb::Connection, @@ -188,3 +227,36 @@ fn spec_error_all(file: &str, diagnostics: Vec) -> CliError { code: 2, } } + +#[cfg(test)] +mod tests { + use super::validate_query_sql; + + #[test] + fn a_dashboard_query_may_only_read() { + for sql in [ + "SELECT 1", + "WITH x AS (SELECT 1 AS n) SELECT n FROM x", + "FROM range(3)", + "VALUES (1), (2)", + "SHOW TABLES", + "DESCRIBE SELECT 1", + "SUMMARIZE SELECT 1", + "PIVOT (SELECT 1 AS a, 2 AS b) ON a IN (1) USING sum(b)", + "SELECT * FROM read_csv('orders.csv') -- a trailing note", + ] { + assert!(validate_query_sql(sql).is_ok(), "{sql}"); + } + for sql in [ + "DROP TABLE orders", + "COPY (SELECT 1) TO '/tmp/out.csv'", + "ATTACH 'other.duckdb'", + "INSERT INTO t VALUES (1) RETURNING *", + "CREATE TABLE t AS SELECT 1", + "INSTALL httpfs", + "SELECT 1; DROP TABLE orders", + ] { + assert!(validate_query_sql(sql).is_err(), "{sql}"); + } + } +} diff --git a/src/spec/view.rs b/src/spec/view.rs index 9b04eba..9b1dd3c 100644 --- a/src/spec/view.rs +++ b/src/spec/view.rs @@ -675,17 +675,20 @@ fn load(path: &Path) -> Run { } }; - // Every query runs once, however many plots draw from it; a statement that - // is not a SELECT has nothing to plot, which is a per-plot failure. + // Every query runs once, however many plots draw from it, and only after + // it has been shown to read and nothing else: opening a file someone sent + // must not be what runs its `DROP` or `COPY … TO`. let outcomes: Vec> = spec .queries .iter() - .map(|query| match crate::query::run(&query.sql) { + .map(|query| match super::validate_query_sql(&query.sql) + .and_then(|()| crate::query::run(&query.sql).map_err(|e| format!("{e:#}"))) + { Ok(QueryOutcome::Rows(result)) => Ok(result), Ok(QueryOutcome::Affected { .. }) => { Err(trf("dashboard.query_not_rows", &[&query.name])) } - Err(e) => Err(format!("{e:#}")), + Err(e) => Err(e), }) .collect(); diff --git a/src/state.rs b/src/state.rs index a22f52a..3d924a1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -225,12 +225,18 @@ fn reattach_files(files: &[crate::history::AttachedFile]) -> Vec { for file in files { match registered_path(&file.path) { Ok(path) => { - match &file.sheet { + let attached = match &file.sheet { Some(sheet) => { - crate::db::attach_excel_sheet_as(&path, Some(sheet), &file.view_name).ok(); + crate::db::attach_excel_sheet_as(&path, Some(sheet), &file.view_name) } - None => { - crate::db::attach_data_file_as(&path, &file.view_name).ok(); + None => crate::db::attach_data_file_as(&path, &file.view_name), + }; + // A missing file stays quiet, as documented above; anything + // else — a name the open database already uses, above all — + // is the user's to hear about. + if let Err(e) = attached { + if std::path::Path::new(&path).exists() { + problems.push(e.to_string()); } } } diff --git a/src/ui/workspace.rs b/src/ui/workspace.rs index b5e56b9..ec523c2 100644 --- a/src/ui/workspace.rs +++ b/src/ui/workspace.rs @@ -52,6 +52,9 @@ pub struct QueryTab { pub id: u64, pub title: SharedString, pub editor: Entity, + /// This tab's own results. One panel shared by every tab showed tab A's + /// rows under tab B's editor — and exported them with A's SQL. + pub results: Entity, } /// An agent-authored app, open as a tab. @@ -201,7 +204,6 @@ pub struct Workspace { /// Set once the user asks for the editor on a connection with no data yet, /// which is otherwise the first-run screen's job to keep out of the way. editor_shown: bool, - results: Entity, rename_input: Option>, _subscriptions: Vec, /// Declared last on purpose: fields drop in declaration order, and every @@ -214,7 +216,6 @@ pub struct Workspace { impl Workspace { pub fn new(state: Entity, window: &mut Window, cx: &mut Context) -> Self { - let results = cx.new(|cx| ResultsPanel::new(window, cx)); let mut this = Self { state: state.clone(), tabs: Vec::new(), @@ -223,7 +224,6 @@ impl Workspace { running: false, explaining: false, editor_shown: false, - results, rename_input: None, _subscriptions: vec![ cx.subscribe(&state, |this, _, _: &ConnectionChanged, cx| { @@ -290,6 +290,7 @@ impl Workspace { id, title: trf("workspace.tab.default_title", &[&id.to_string()]).into(), editor, + results: cx.new(|cx| ResultsPanel::new(window, cx)), } } @@ -689,13 +690,16 @@ impl Workspace { self.save_dashboard(tab_id, window, cx); } - fn active_sql(&self, cx: &App) -> Option { + /// The active query's SQL, with the results panel its outcome belongs to: + /// captured when the run starts, so a result that lands after the user + /// switched tabs still goes to the tab that asked. + fn active_sql(&self, cx: &App) -> Option<(String, Entity)> { let tab = self .tabs .get(self.active) .and_then(WorkspaceTab::as_query)?; let sql = tab.editor.read(cx).value().to_string(); - (!sql.trim().is_empty()).then_some(sql) + (!sql.trim().is_empty()).then(|| (sql, tab.results.clone())) } fn open_rename_dialog(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { @@ -776,15 +780,14 @@ impl Workspace { if self.running || self.explaining { return; } - let Some(sql) = self.active_sql(cx) else { + let Some((sql, results)) = self.active_sql(cx) else { return; }; // ⌘↵ on the first-run screen means "I want to write SQL": show the // editor the results belong to instead of running behind it. self.editor_shown = true; self.running = true; - self.results - .update(cx, |results, cx| results.set_running(cx)); + results.update(cx, |results, cx| results.set_running(cx)); cx.notify(); let state = self.state.clone(); @@ -840,7 +843,7 @@ impl Workspace { }), Err(_) => None, }; - this.results.update(cx, |results, cx| { + results.update(cx, |results, cx| { results.set_outcome(outcome, sql.clone(), window, cx); }); state.update(cx, |s, cx| { @@ -884,12 +887,11 @@ impl Workspace { if self.explaining || self.running { return; } - let Some(sql) = self.active_sql(cx) else { + let Some((sql, results)) = self.active_sql(cx) else { return; }; self.explaining = true; - self.results - .update(cx, |results, cx| results.set_running(cx)); + results.update(cx, |results, cx| results.set_running(cx)); cx.notify(); cx.spawn_in(window, async move |this, cx| { @@ -900,7 +902,7 @@ impl Workspace { .await; this.update_in(cx, move |this, window, cx| { this.explaining = false; - this.results.update(cx, |results, cx| { + results.update(cx, |results, cx| { results.set_explain(result, window, cx); }); cx.notify(); @@ -1311,11 +1313,9 @@ impl Workspace { .child(tab.host.clone()) .into_any_element(), _ => { - let editor = self - .tabs - .get(self.active) - .and_then(WorkspaceTab::as_query) - .map(|tab| tab.editor.clone()); + let query = self.tabs.get(self.active).and_then(WorkspaceTab::as_query); + let editor = query.map(|tab| tab.editor.clone()); + let results = query.map(|tab| tab.results.clone()); div() .flex_1() .min_h_0() @@ -1331,7 +1331,7 @@ impl Workspace { resizable_panel() .size(px(RESULTS_PANEL_DEFAULT)) .size_range(px(RESULTS_PANEL_MIN)..px(RESULTS_PANEL_MAX)) - .child(self.results.clone()), + .children(results), ), ) .into_any_element()