From 2c9a6470fff4c54adf4f0d6fae3fbe01f616ade7 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:58:45 +0800 Subject: [PATCH] feat(project): add project trust and restricted execution mode (#517) Projects now open with a user-owned trust decision. The prompt offers Preview in Restricted Mode, Don't Open and Trust Project, with an optional trusted parent folder; preview is the default focus so Enter never grants trust. Restricted projects keep browsing, editing, saving, search and highlighting. Project-controlled execution is denied by default in the backend: run, debug, build, tools, language servers, plugins and Git commands that could honour repository configuration. Trusting a project enables those paths without reopening it, and decisions can be revoked in Settings. Terminals stay available when the user opens one, without project shell settings, environment or virtualenv activation. A shell prompt that runs git in the folder can still honour the repository's core.fsmonitor. Fixes #462 --- crates/ide-core/src/event.rs | 4 + crates/ide-host/src/environment.rs | 12 +- crates/ide-host/src/lib.rs | 21 ++ crates/ide-host/src/notebook.rs | 18 + crates/ide-host/src/ops/agent.rs | 12 + crates/ide-host/src/ops/build.rs | 7 +- crates/ide-host/src/ops/coverage.rs | 4 +- crates/ide-host/src/ops/debug.rs | 5 +- crates/ide-host/src/ops/env.rs | 2 +- crates/ide-host/src/ops/java.rs | 5 +- crates/ide-host/src/ops/notebook.rs | 4 +- crates/ide-host/src/ops/project.rs | 2 + crates/ide-host/src/ops/run.rs | 3 + crates/ide-host/src/ops/run/commands.rs | 4 + crates/ide-host/src/ops/sdk.rs | 23 +- crates/ide-host/src/ops/term.rs | 22 +- crates/ide-host/src/ops/tools.rs | 1 + crates/ide-host/src/ops/vcs.rs | 4 +- crates/ide-host/src/ops/vcs/commands.rs | 32 +- crates/ide-host/src/project.rs | 42 ++- crates/ide-host/src/rpc.rs | 12 +- crates/ide-host/src/trust.rs | 310 ++++++++++++++++++ crates/ide-host/tests/properties.rs | 1 + crates/ide-host/tests/remote_env.rs | 4 + crates/ide-host/tests/rpc.rs | 16 +- crates/ide-host/tests/run_configs.rs | 4 + crates/ide-host/tests/trust.rs | 244 ++++++++++++++ crates/ide-host/tests/trust_git_binary.rs | 36 ++ crates/ide-host/tests/vcs_encoding.rs | 1 + crates/ide-host/tests/vcs_roots.rs | 1 + crates/ide-host/tests/workspaces.rs | 1 + crates/ide-lsp/src/lib.rs | 40 ++- crates/ide-plugin/src/lib.rs | 34 +- crates/ide-plugin/tests/host.rs | 36 ++ crates/ide-run/src/process.rs | 16 +- crates/ide-run/tests/process.rs | 20 ++ crates/ide-vcs/src/cli.rs | 20 +- crates/ide-vcs/src/lib.rs | 100 +++++- crates/ide-vcs/src/manage.rs | 7 + crates/ide-vcs/src/ops.rs | 46 ++- crates/ide-vcs/src/remotes.rs | 11 +- crates/ide-vcs/src/repo.rs | 15 +- crates/ide-vcs/src/write.rs | 42 ++- crates/ide-vcs/tests/repo.rs | 51 +++ src-tauri/src/commands/app.rs | 38 +++ src-tauri/src/commands/coverage.rs | 8 +- src-tauri/src/commands/mod.rs | 4 + src-tauri/src/commands/plugin.rs | 10 +- src-tauri/src/lib.rs | 152 +++++---- src-tauri/src/remote_dev.rs | 22 ++ src-tauri/src/state.rs | 2 +- src/bindings.ts | 21 +- src/editor/core/controller.test.ts | 5 +- src/editor/core/view/statusItems.test.tsx | 1 + src/editor/navigation/gotoTest.test.ts | 2 +- src/features/agents/composerCommands.test.tsx | 1 + src/features/agents/index.ts | 2 +- src/features/agents/store.ts | 7 +- src/features/app/__tests__/windows.test.ts | 6 +- src/features/app/windows.ts | 3 +- src/features/build-tools/index.ts | 2 +- src/features/build-tools/store.ts | 2 +- src/features/code-insight/servers.test.ts | 4 +- .../debugger/breakpoints/exceptions.ts | 4 +- src/features/debugger/breakpoints/special.ts | 3 +- src/features/debugger/breakpoints/store.ts | 3 +- src/features/debugger/index.ts | 2 +- src/features/index.ts | 3 + src/features/libraries/index.ts | 4 +- src/features/plugins/index.ts | 2 +- src/features/project-tree/contextMenu.test.ts | 7 +- .../project-tree/newPopupModel.test.ts | 2 +- src/features/project-tree/roots.test.ts | 2 +- src/features/project-trust/index.tsx | 218 ++++++++++++ src/features/project-trust/trust.module.css | 57 ++++ src/features/project-trust/trust.test.tsx | 83 +++++ src/features/remote-dev/ConnectDialog.tsx | 4 +- src/features/remote-dev/remoteDev.test.ts | 22 +- src/features/run/contextRun.test.ts | 6 +- src/features/run/index.ts | 2 +- src/features/run/store.ts | 1 + .../services/ServicesTree.debug.test.tsx | 2 +- .../services/ServicesTree.reorder.test.tsx | 2 +- .../settings/languages/PythonStatus.tsx | 5 +- src/features/welcome/ProjectsPage.tsx | 3 +- src/features/welcome/recent.test.ts | 4 +- src/features/welcome/recent.ts | 4 +- src/features/welcome/session.ts | 3 +- src/platform/locales/text/zh.json | 18 + src/platform/project.ts | 2 + src/platform/projectRoots.test.ts | 3 +- src/platform/projectTrust.ts | 16 + src/platform/remote.ts | 40 +++ src/platform/remoteSettings.test.ts | 4 +- src/styles/tokens.css | 3 + src/ui/Dialog.module.css | 5 + src/ui/Dialog.tsx | 9 +- src/workbench/Workbench.test.tsx | 6 +- src/workbench/Workbench.tsx | 5 + src/workbench/contributions.ts | 8 + src/workbench/devSamples.tsx | 2 +- src/workbench/distractionFree.test.tsx | 6 +- src/workbench/projectWindow.test.ts | 2 +- 103 files changed, 1996 insertions(+), 168 deletions(-) create mode 100644 crates/ide-host/src/trust.rs create mode 100644 crates/ide-host/tests/trust.rs create mode 100644 crates/ide-host/tests/trust_git_binary.rs create mode 100644 src/features/project-trust/index.tsx create mode 100644 src/features/project-trust/trust.module.css create mode 100644 src/features/project-trust/trust.test.tsx create mode 100644 src/platform/projectTrust.ts diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 5e517ea1..5de98978 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -15,6 +15,10 @@ use std::sync::Arc; pub enum IdeEvent { /// Project window membership or selection changed. WindowGroupsChanged, + ProjectTrustChanged { + root: crate::IpcPath, + trusted: bool, + }, /// Files or directories changed on disk (after debouncing, ignore rules applied). FsChanged { created: Vec, diff --git a/crates/ide-host/src/environment.rs b/crates/ide-host/src/environment.rs index 0ef84ec5..7180b5ab 100644 --- a/crates/ide-host/src/environment.rs +++ b/crates/ide-host/src/environment.rs @@ -44,6 +44,7 @@ struct State { project: Changes, /// Project roots whose `postStartCommand` ran in this process. post_started: HashSet, + post_commands: std::collections::HashMap, } pub struct Environment { @@ -78,7 +79,12 @@ impl Environment { pub(crate) fn project_closed(&self) { if self.project_env { - self.state.lock().project.clear(); + let mut state = self.state.lock(); + for (_, token) in state.post_commands.drain() { + token.cancel(); + } + state.project.clear(); + drop(state); self.publish(); } } @@ -144,6 +150,7 @@ impl Environment { /// a failure is a notification. pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) { let cancel = self.cancel.child_token(); + self.state.lock().post_commands.insert(root.clone(), cancel.clone()); let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || { run_post_start(&cmd, &root, &sink, &cancel); }); @@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel /// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line /// to `on_line`; `Err` says how it failed. fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> { + if cancel.is_cancelled() { + return Err("cancelled".into()); + } let (program, args) = argv.split_first().ok_or("empty command")?; let mut child = ide_core::process::prepare(&mut Command::new(program)) .args(args) diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs index 0699b791..ccb01db4 100644 --- a/crates/ide-host/src/lib.rs +++ b/crates/ide-host/src/lib.rs @@ -12,6 +12,7 @@ mod proxy; mod requests; pub mod rpc; pub mod shell_env; +pub mod trust; pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention}; @@ -44,6 +45,7 @@ pub struct HostConfig { } pub struct Host { + pub trust: trust::TrustStore, pub config_dir: PathBuf, pub data_dir: PathBuf, pub languages: Arc, @@ -67,6 +69,7 @@ pub struct Host { /// One project per window, keyed by window label. projects: RwLock>>, requests: requests::Requests, + project_requests: RwLock>>, } impl Host { @@ -77,6 +80,7 @@ impl Host { let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json"))); ssh.set_configs(global_settings.effective().ssh.configs); let host = Self { + trust: trust::TrustStore::load(&config_dir, &data_dir), proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")), remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), ssh, @@ -95,6 +99,7 @@ impl Host { runtime, projects: RwLock::default(), requests: Default::default(), + project_requests: RwLock::default(), }; host.apply_proxy(); host @@ -119,6 +124,10 @@ impl Host { .ok_or_else(|| IdeError::not_found("no project is open in this window")) } + pub fn require_trust(&self, window: &str) -> IdeResult<()> { + self.project(window)?.require_trust() + } + /// The window that owns what `window` starts. pub fn owner_window<'a>(&self, window: &'a str) -> &'a str { (self.owner)(window) @@ -212,7 +221,19 @@ impl Host { self.requests.begin(id) } + pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken { + let token = self.begin_request(id); + self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id); + if self.project(window).is_ok_and(|p| !p.is_trusted()) { + token.cancel(); + } + token + } + pub fn end_request(&self, id: RequestId) { + for ids in self.project_requests.write().values_mut() { + ids.retain(|entry| *entry != id); + } self.requests.end(id); } diff --git a/crates/ide-host/src/notebook.rs b/crates/ide-host/src/notebook.rs index 63e17d04..fb5d65cb 100644 --- a/crates/ide-host/src/notebook.rs +++ b/crates/ide-host/src/notebook.rs @@ -407,6 +407,24 @@ impl NotebookService { } } + pub async fn restrict(&self) { + let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect(); + for document in &documents { + let mut state = document.state.lock(); + state.generation += 1; + if let Some(batch) = &state.active { + batch.cancel.cancel(); + } + } + for document in documents { + let _ = self.kernels.shutdown_kernel(&document.path).await; + let batch = document.state.lock().active.clone(); + if let Err(error) = self.drain(&document, batch).await { + tracing::warn!("notebook restriction: {error}"); + } + } + } + pub async fn shutdown(&self) { self.stop_admission(); self.kernels.shutdown().await; diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 70838e2c..47f089f9 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit) -> IdeRe } pub fn providers(host: &Host, window: &str) -> IdeResult> { + host.require_trust(window)?; let mut providers = host.project(window)?.agents.providers(); ide_agent::registry::mark_updates(®istry_paths(host), &mut providers); Ok(providers) @@ -88,6 +89,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option } pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let root = project.root.clone(); let requested = request.project_root.take(); @@ -99,6 +101,7 @@ pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> Ide /// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> { + host.require_trust(window)?; host.project(window)?.agents.prompt(&task, prompt, now).await } @@ -109,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe /// Sends a queued message now, interrupting the current turn. pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> { + host.require_trust(window)?; host.project(window)?.agents.send_queued_now(&task, &queued) } /// Changes a session setting (model, mode, thinking effort…). pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> { + host.require_trust(window)?; host.project(window)?.agents.set_config(&task, &option, value).await } /// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`). pub async fn fork(host: &Host, window: &str, task: String, turns: Option) -> IdeResult { + host.require_trust(window)?; host.project(window)?.agents.fork(&task, turns).await } /// Drops the task's turns after its first `turns`; returns the message of the first dropped one. pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult> { + host.require_trust(window)?; host.project(window)?.agents.rollback(&task, turns).await } /// What the agent reports about itself when it starts, without opening a session. pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult { + host.require_trust(window)?; host.project(window)?.agents.probe(&provider, refresh).await } @@ -139,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option) /// Answers a permission request; `option: None` cancels it. pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option) -> IdeResult<()> { + host.require_trust(window)?; host.project(window)?.agents.respond(&task, permission, option) } @@ -151,15 +160,18 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> { } pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> { + host.require_trust(window)?; host.project(window)?.agents.delete(&task, remove_worktree).await } pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult> { + host.require_trust(window)?; host.project(window)?.agents.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> { + host.require_trust(window)?; host.project(window)?.agents.base_text(&task, path.into_path_buf()).await } diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index d921a295..650db06d 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -76,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel { /// Maven and Gradle projects of the window's project, from the build files plus the last sync. pub async fn build_model(host: &Host, window: &str) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err) @@ -93,9 +94,10 @@ pub async fn build_sync( profiles: Vec, on_progress: Channel, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); - let token = host.begin_request(request); + let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { let current = load(&root, &cache, &settings); let env = match tool { @@ -223,11 +225,12 @@ pub async fn build_run( clear: Vec, on_event: Channel, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone()); let settings = project.settings.effective(); let config_dir = host.config_dir.clone(); - let token = host.begin_request(request); + let token = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { for path in clear { sink.emit(IdeEvent::Diagnostics { diff --git a/crates/ide-host/src/ops/coverage.rs b/crates/ide-host/src/ops/coverage.rs index adaabadf..007d701d 100644 --- a/crates/ide-host/src/ops/coverage.rs +++ b/crates/ide-host/src/ops/coverage.rs @@ -16,6 +16,7 @@ pub async fn blocking(f: impl FnOnce() -> IdeResult + Send /// How to run `config` with coverage: the command to launch and where its report will be, /// or which tool is missing. pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage")); blocking(move || { @@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I } /// Reads the report a coverage run left behind. -pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult { +pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult { + host.require_trust(window)?; blocking(move || ide_coverage::collect(&report)).await } diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs index fcba1485..492ce842 100644 --- a/crates/ide-host/src/ops/debug.rs +++ b/crates/ide-host/src/ops/debug.rs @@ -8,6 +8,7 @@ use std::path::PathBuf; /// Starts a session owned by this window: its events (and those of child sessions) go here only. pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult { + host.require_trust(window)?; let mut config = match_java_debug_encoding(config, cfg!(windows)); if config.adapter == "debugpy" && config.request == "launch" @@ -76,6 +77,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide let path = ide_sdk::apply::tool_command(&root, &settings, command)?; Some(path.to_string_lossy().into_owned()) }; + host.require_trust(window)?; let result = host.debugger.start_with(config, owner, custom).await; flush_output(); result @@ -307,11 +309,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul /// JVM to connect to `host:port`; returns the loopback port java-debug attaches to. pub async fn debug_listen_for_jvm( host: &Host, - _window: &str, + window: &str, request: ide_core::RequestId, jvm_host: String, port: u16, ) -> IdeResult { + host.require_trust(window)?; let token = host.begin_request(request); let result = jdwp::listen_for_jvm(&jvm_host, port, token).await; host.end_request(request); diff --git a/crates/ide-host/src/ops/env.rs b/crates/ide-host/src/ops/env.rs index 31772c0c..b877e9aa 100644 --- a/crates/ide-host/src/ops/env.rs +++ b/crates/ide-host/src/ops/env.rs @@ -9,7 +9,7 @@ use std::sync::Arc; /// Re-reads the login shell (and the window's project `remoteEnv` where it applies); /// running terminals and processes keep their environment. pub async fn reload(host: &Arc, window: &str) -> IdeResult { - let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone()); + let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone()); let sink = host.window_sink(window); let h = host.clone(); host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs index 7092a1b5..4eccfb01 100644 --- a/crates/ide-host/src/ops/java.rs +++ b/crates/ide-host/src/ops/java.rs @@ -20,7 +20,8 @@ pub async fn java_resolve_launch( build: bool, request: RequestId, ) -> IdeResult { - let cancel = host.begin_request(request); + host.require_trust(window)?; + let cancel = host.begin_project_request(window, request); let result = resolve_launch(host, window, module, test, build, &cancel).await; host.end_request(request); result @@ -104,6 +105,7 @@ async fn resolve_launch( /// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand). pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let module = project.root.join(module); project.lsp.java_debug_server(&module).await @@ -115,6 +117,7 @@ pub async fn java_build_workspace( window: &str, module: PathBuf, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let module = project.root.join(module); project.lsp.java_build(&module, &CancellationToken::new()).await diff --git a/crates/ide-host/src/ops/notebook.rs b/crates/ide-host/src/ops/notebook.rs index 409d0ff2..90d1e657 100644 --- a/crates/ide-host/src/ops/notebook.rs +++ b/crates/ide-host/src/ops/notebook.rs @@ -54,6 +54,7 @@ pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult IdeResult { + host.require_trust(window)?; let notebooks = host.project(window)?.notebooks.clone(); tokio::spawn(async move { notebooks.reload(&id, true).await }) .await @@ -84,8 +85,9 @@ pub async fn execute( request: RequestId, on_output: Channel, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; - let cancel = host.begin_request(request); + let cancel = host.begin_project_request(window, request); let _request = RequestGuard { host, request, cancel: cancel.clone() }; // Reserve the WHOLE batch and capture source before asynchronous discovery. let admission = project.notebooks.admit(&id, cells, cancel.clone())?; diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 7055d2e5..4eef23a6 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -10,6 +10,7 @@ use std::sync::Arc; #[derive(Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct ProjectInfo { + pub trusted: bool, pub root: ide_core::IpcPath, pub name: String, pub is_git: bool, @@ -22,6 +23,7 @@ pub fn info(project: &Project) -> ProjectInfo { let settings = project.settings.effective(); let custom = settings.general.project_name.trim(); ProjectInfo { + trusted: project.is_trusted(), name: if custom.is_empty() { project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() } else { diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 7160c311..21f21e0a 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -6,6 +6,7 @@ use std::time::Duration; /// Detected configs followed by user-defined ones (user ids win on conflict). pub fn configs(host: &Host, window: &str) -> IdeResult> { + host.require_trust(window)?; let project = host.project(window)?; let root = project.root.clone(); let user = ide_run::load_user_configs(&root)?; @@ -70,6 +71,7 @@ pub fn save_configs( } pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; if config.command.is_empty() { return Err(IdeError::invalid("run configuration has no command")); @@ -135,6 +137,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit encoding: Some(encoding).filter(|e| !e.trim().is_empty()), python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) }, }; + project.require_trust()?; let mut sink = project.sink.clone(); if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) { let previous = sink; diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs index 86754386..5fa95813 100644 --- a/crates/ide-host/src/ops/run/commands.rs +++ b/crates/ide-host/src/ops/run/commands.rs @@ -50,6 +50,7 @@ pub async fn run_test_reports( } pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult { + host.require_trust(window)?; let root = host.project(window)?.root.clone(); tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home))) .await @@ -57,6 +58,7 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu } pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult> { + host.require_trust(window)?; let project = host.project(window)?; tokio::task::spawn_blocking(move || { let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective()); @@ -67,6 +69,7 @@ pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult IdeResult> { + host.require_trust(window)?; if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") { return Ok(None); } @@ -119,6 +122,7 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I } pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) { return Err(IdeError::invalid("Start this Tomcat configuration before updating it")); diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs index 0efe04b0..3e06c85c 100644 --- a/crates/ide-host/src/ops/sdk.rs +++ b/crates/ide-host/src/ops/sdk.rs @@ -22,12 +22,14 @@ pub struct LocatedTools { /// Installed interpreters / toolchains of `kind`; project-local ones first. pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult> { + host.require_trust(window)?; let root = host.project(window).ok().map(|p| p.root.clone()); tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err) } /// The interpreter the project uses: the configured one, else its virtualenv. pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult> { + host.require_trust(window)?; let project = host.project(window)?; Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into)) } @@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult IdeResult> { + host.require_trust(window)?; let project = host.project(window)?; let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective()); tokio::task::spawn_blocking(move || { @@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult, near: Option, ) -> IdeResult { + host.require_trust(window)?; tokio::task::spawn_blocking(move || LocatedTools { tools: ide_sdk::locate::locate_tools(&names, near.as_deref()), compile_dir: ide_sdk::locate::compile_dir().into(), @@ -72,13 +76,14 @@ pub async fn sdk_locate_tools( /// `base -m venv location`; output lines stream through `on_output`. pub async fn sdk_python_create_venv( host: &Host, - _window: &str, + window: &str, base: PathBuf, location: PathBuf, request: RequestId, on_output: Channel, ) -> IdeResult { - let cancel = host.begin_request(request); + host.require_trust(window)?; + let cancel = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { ide_sdk::python::create_venv(&base, &location, &cancel, &|line| { let _ = on_output.send(line); @@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv( pub async fn sdk_python_packages( host: &Host, - _window: &str, + window: &str, python: PathBuf, request: RequestId, ) -> IdeResult> { - let cancel = host.begin_request(request); + host.require_trust(window)?; + let cancel = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err); host.end_request(request); @@ -107,7 +113,7 @@ pub async fn sdk_python_packages( #[allow(clippy::too_many_arguments)] pub async fn sdk_python_pip( host: &Host, - _window: &str, + window: &str, python: PathBuf, requirement: String, uninstall: bool, @@ -115,7 +121,8 @@ pub async fn sdk_python_pip( request: RequestId, on_output: Channel, ) -> IdeResult<()> { - let cancel = host.begin_request(request); + host.require_trust(window)?; + let cancel = host.begin_project_request(window, request); let result = tokio::task::spawn_blocking(move || { let send = |line: String| { let _ = on_output.send(line); diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs index 0fc9956a..20393ead 100644 --- a/crates/ide-host/src/ops/term.rs +++ b/crates/ide-host/src/ops/term.rs @@ -3,12 +3,24 @@ use crate::Host; use ide_core::{IdeResult, TerminalId}; use ide_terminal::{TerminalInfo, TerminalOptions}; -pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit) -> IdeResult { +pub fn spawn( + host: &Host, + window: &str, + mut options: TerminalOptions, + on_output: Emit, +) -> IdeResult { + if !host.project(window).is_ok_and(|p| p.is_trusted()) { + // Project terminal settings can otherwise start a command as soon as a tab restores. + let global = host.global_settings.effective().terminal; + options.shell = None; + options.args.clear(); + options.env = global.env; + } // Python › "Activate virtualenv": typed into the new shell, like PyCharm. - let activation = host - .project(window) - .ok() - .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())); + let activation = + host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| { + ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()) + }); let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?; if let Some(line) = activation { let _ = host.terminals.write(info.id, &format!(" {line}\r")); diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs index be3d1373..f87b5799 100644 --- a/crates/ide-host/src/ops/tools.rs +++ b/crates/ide-host/src/ops/tools.rs @@ -11,6 +11,7 @@ use std::path::PathBuf; /// The installer, reporting the executables chosen in the window's settings. fn tools(host: &Host, window: &str) -> IdeResult { + host.require_trust(window)?; let data = host.data_dir.clone(); let (settings, root) = match host.project(window) { Ok(project) => (project.settings.effective(), project.root.clone()), diff --git a/crates/ide-host/src/ops/vcs.rs b/crates/ide-host/src/ops/vcs.rs index 0fe319e3..ae499bfd 100644 --- a/crates/ide-host/src/ops/vcs.rs +++ b/crates/ide-host/src/ops/vcs.rs @@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult> { let project = host.project(window)?; host.blocking(move || { let settings = project.settings.effective(); - apply_git_executable(&settings); + if project.is_trusted() { + apply_git_executable(&settings); + } let repos = project.repos(); if repos.is_empty() { return Err(ide_core::IdeError::unavailable("project is not a git repository")); diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs index 754f1534..efb9a881 100644 --- a/crates/ide-host/src/ops/vcs/commands.rs +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -259,6 +259,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option) -> Id } pub async fn vcs_stage(host: &Host, window: &str, paths: Vec) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await } @@ -271,6 +272,7 @@ pub async fn vcs_add_to_ignore( paths: Vec, destination: IgnoreDestination, ) -> IdeResult> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { for path in &paths { @@ -292,16 +294,19 @@ pub async fn vcs_add_to_ignore( } pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await } pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await } pub async fn vcs_discard(host: &Host, window: &str, paths: Vec) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await } @@ -313,6 +318,7 @@ pub async fn vcs_commit( message: String, options: CommitOptions, ) -> IdeResult { + host.require_trust(window)?; // Commit hooks may run for a long time. let project = host.project(window)?; blocking(move || { @@ -331,6 +337,7 @@ pub async fn vcs_checkout( create: bool, options: Option, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; let options = options.unwrap_or_default(); blocking(move || { @@ -349,6 +356,7 @@ pub async fn vcs_create_branch( start: Option, checkout: bool, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; let recorded = checkout.then_some("Git checkout"); blocking(move || { @@ -360,6 +368,7 @@ pub async fn vcs_create_branch( /// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged. /// Returns the undone commit's message. pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option, commit: String) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await } @@ -370,6 +379,7 @@ pub async fn vcs_delete_branch( repo: Option, branch: String, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await } @@ -381,6 +391,7 @@ pub async fn vcs_restore_branch( repo: Option, deleted: ide_vcs::DeletedBranch, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await } @@ -392,16 +403,19 @@ pub async fn vcs_rename_branch( from: String, to: String, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await } pub async fn vcs_merge(host: &Host, window: &str, repo: Option, branch: String) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await } pub async fn vcs_rebase(host: &Host, window: &str, repo: Option, onto: String) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await } @@ -414,6 +428,7 @@ pub async fn vcs_stash_push( include_untracked: bool, keep_index: bool, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| { @@ -432,6 +447,7 @@ pub async fn vcs_stash_apply( reinstate_index: bool, branch: Option, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() { @@ -443,6 +459,7 @@ pub async fn vcs_stash_apply( } pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option, index: u32) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await } @@ -456,6 +473,7 @@ pub async fn vcs_remote( op: RemoteOp, on_output: Channel, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { @@ -514,6 +532,7 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S /// Stages only the selected hunks of a file's working-tree changes. /// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`. pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { let encoding = project.vcs_encoding(&path, Some(":0"))?; @@ -556,7 +575,8 @@ pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()> /// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected /// executable when `path` is empty. -pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult { +pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult { + host.require_trust(window)?; tokio::task::spawn_blocking(move || { let explicit = path.trim(); let exe = if explicit.is_empty() { @@ -598,6 +618,7 @@ pub async fn vcs_operation( op: GitOperation, on_output: Channel, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; let cancel = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { @@ -638,6 +659,7 @@ pub async fn vcs_create_tag( message: Option, force: bool, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force))) .await @@ -656,6 +678,7 @@ pub async fn vcs_merge_with( branches: Vec, options: MergeOptions, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options)) @@ -671,6 +694,7 @@ pub async fn vcs_rebase_with( upstream: String, options: RebaseOptions, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options)) @@ -684,6 +708,7 @@ pub async fn vcs_remotes(host: &Host, window: &str, repo: Option) -> Id } pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option, edit: RemoteEdit) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await } @@ -701,6 +726,7 @@ pub async fn vcs_add_worktree( commitish: String, new_branch: Option, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) @@ -715,6 +741,7 @@ pub async fn vcs_remove_worktree( path: PathBuf, force: bool, ) -> IdeResult<()> { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await } @@ -790,12 +817,14 @@ pub async fn vcs_apply_patch( repo: Option, patch: String, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { in_primary_repo(&project, &paths)?; @@ -819,6 +848,7 @@ pub async fn vcs_unshelve( remove: bool, path: Option, ) -> IdeResult { + host.require_trust(window)?; let project = host.project(window)?; blocking(move || { project.with_repo_recorded(None, Some("Unshelve"), |r| { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 1630411f..88a93c89 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -15,9 +15,11 @@ use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoo use parking_lot::{Mutex, RwLock}; use std::path::{Path, PathBuf}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Everything bound to one open project folder. pub struct Project { + pub(crate) trusted: Arc, pub root: PathBuf, /// Window-scoped event sink, for commands that emit project events. pub sink: SharedSink, @@ -80,7 +82,9 @@ impl Host { self.close_project(window); let sink: SharedSink = (self.sink_for)(window); // Before anything the project starts: its children get the project's environment. - let post_start = self.environment.project_opened(&root, &sink); + let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?)); + let post_start = + trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten(); let settings = SettingsStore::load(&self.config_dir, Some(&root)); let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root))); let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read()); @@ -114,6 +118,7 @@ impl Host { )?; mark("index"); let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone()); + lsp.set_execution_allowed(trusted.load(Ordering::Acquire)); lsp.set_content_roots(extra_roots.clone()); let settings = Arc::new(settings); { @@ -208,9 +213,13 @@ impl Host { root: root.clone(), sink: sink.clone(), runtime: self.runtime.clone(), + trusted: trusted.clone(), })); let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?)); + for repo in repos.read().all() { + repo.set_execution_allowed(trusted.load(Ordering::Acquire)); + } let watcher = { let repos = repos.clone(); let settings = settings.clone(); @@ -224,6 +233,7 @@ impl Host { let remote = self.remote.clone(); let runtime = self.runtime.clone(); let lsp = lsp.clone(); + let trusted = trusted.clone(); Watcher::start( &root.clone(), excludes.clone(), @@ -241,7 +251,7 @@ impl Host { documents.redetect_languages(); } record_external_changes(&history, &changed, &batch.removed); - if !batch.removed.is_empty() { + if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() { delete_deployed( &runtime, remote.clone(), @@ -331,6 +341,7 @@ impl Host { ); ide_agent::registry::maintain(crate::ops::agent::registry_paths(self)); let project = Arc::new(Project { + trusted, root, sink, languages, @@ -446,6 +457,7 @@ impl Host { sink.clone(), ); let project = Arc::new(Project { + trusted: Arc::new(AtomicBool::new(true)), sink, languages, settings, @@ -476,6 +488,18 @@ impl Host { } impl Project { + pub fn is_trusted(&self) -> bool { + self.trusted.load(Ordering::Acquire) + } + + pub fn require_trust(&self) -> IdeResult<()> { + if self.is_trusted() { + Ok(()) + } else { + Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution.")) + } + } + /// LightEdit: follow external changes to the files of `dir` (its own entries only). pub fn watch_light_dir(&self, dir: &Path) { if !self.light { @@ -550,7 +574,11 @@ impl Project { /// The project's git repositories. pub fn repos(&self) -> Repos { - self.repos.read().clone() + let repos = self.repos.read().clone(); + for repo in repos.all() { + repo.set_execution_allowed(self.is_trusted()); + } + repos } /// Runs `f` with the repository whose root is `at`, the project's primary repository @@ -582,7 +610,9 @@ impl Project { fn run_in(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { let settings = self.settings.effective(); - apply_git_executable(&settings); + if self.is_trusted() { + apply_git_executable(&settings); + } repo.set_ignored_names(settings.ignored_names()); f(repo) } @@ -981,6 +1011,7 @@ impl DocumentObserver for HistoryObserver { /// Uploads saved files: remote files' local copies, and automatic upload to the default server. struct DeployObserver { + trusted: Arc, remote: Arc, settings: Arc, root: PathBuf, @@ -990,6 +1021,9 @@ struct DeployObserver { impl DocumentObserver for DeployObserver { fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) { + if !self.trusted.load(Ordering::Acquire) { + return; + } let (remote, settings, root, sink, path) = (self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf()); self.runtime.spawn(async move { diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs index 811a9c53..e34db1e4 100644 --- a/crates/ide-host/src/rpc.rs +++ b/crates/ide-host/src/rpc.rs @@ -24,7 +24,7 @@ use std::path::PathBuf; use std::pin::Pin; use std::sync::{Arc, OnceLock}; -pub const PROTOCOL_VERSION: u32 = 1; +pub const PROTOCOL_VERSION: u32 = 2; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -138,6 +138,15 @@ impl Dispatcher { /// terminals, runs and VCS status. pub fn standard() -> Self { let mut d = Self::default(); + d.add("project_trust_status", |c| async move { c.host.trust.status(&c.arg::("path")?) }) + .add("project_trust_list", |c| async move { c.host.trust.decisions() }) + .add("project_trust_set", |c| async move { + c.host.set_project_trust(&c.arg::("path")?, c.arg("trusted")?, c.arg("folder")?).await + }) + .add("project_trust_remove_location", |c| async move { + c.host.trust.remove_location(&c.arg::("path")?)?; + c.host.refresh_project_trust().await + }); d.add("open_project", |c| async move { let path: PathBuf = c.arg("path")?; let project = ops::project::open(&c.host, &c.window, &path).await?; @@ -291,6 +300,7 @@ impl Handler for ServerHandler { let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else { return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd))); }; + self.host.authorize_command(&call.window, &call.cmd).map_err(error_reply)?; let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() }; // Requests run on blocking-capable threads: wait for the command here. self.host.runtime.block_on(handler(c)).map_err(error_reply) diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs new file mode 100644 index 00000000..64ec419d --- /dev/null +++ b/crates/ide-host/src/trust.rs @@ -0,0 +1,310 @@ +//! User-owned project execution decisions. Project settings never grant trust. +use ide_core::{IdeError, IdeResult}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Default, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TrustDecisions { + pub projects: Vec, + pub locations: Vec, + pub restricted: Vec, +} + +pub struct TrustStore { + file: PathBuf, + state: Mutex>, +} + +impl TrustStore { + pub fn load(config: &Path, data: &Path) -> Self { + let file = data.join("project-trust.json"); + let state = match std::fs::read(&file) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + let projects = ide_workspace::RecentProjects::load(config) + .paths() + .into_iter() + .filter_map(|p| dunce::canonicalize(p).ok()) + .collect(); + let migrated = TrustDecisions { projects, ..Default::default() }; + persist(&file, &migrated).map(|()| migrated) + } + Err(e) => Err(e.into()), + }; + Self { file, state: Mutex::new(state) } + } + + pub fn decisions(&self) -> IdeResult { + self.state.lock().clone() + } + + pub fn trusted(&self, path: &Path) -> IdeResult { + let root = dunce::canonicalize(path)?; + let decisions = self.decisions()?; + Ok(!decisions.restricted.contains(&root) + && (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p)))) + } + + pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> { + let root = match dunce::canonicalize(path) { + Ok(root) => root, + Err(_) if !trusted && path.is_absolute() => path.to_path_buf(), + Err(error) => return Err(error.into()), + }; + let mut state = self.state.lock(); + let mut next = state.clone()?; + next.projects.retain(|p| p != &root); + next.restricted.retain(|p| p != &root); + if trusted { + next.projects.push(root.clone()); + if folder && let Some(parent) = root.parent() { + let parent = parent.to_path_buf(); + if !next.locations.contains(&parent) { + next.locations.push(parent); + } + } + } else { + next.restricted.push(root); + } + persist(&self.file, &next)?; + *state = Ok(next); + Ok(()) + } + + pub fn remove_location(&self, path: &Path) -> IdeResult<()> { + let mut state = self.state.lock(); + let mut next = state.clone()?; + next.locations.retain(|p| p != path); + persist(&self.file, &next)?; + *state = Ok(next); + Ok(()) + } +} + +fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> { + if let Some(parent) = file.parent() { + std::fs::create_dir_all(parent)?; + } + let bytes = serde_json::to_vec_pretty(state).map_err(|e| IdeError::internal(e.to_string()))?; + ide_vfs::write_atomic(file, &bytes)?; + Ok(()) +} + +impl crate::Host { + pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> { + self.trust.decide(path, trusted, folder)?; + self.refresh_project_trust().await + } + + pub async fn refresh_project_trust(&self) -> IdeResult<()> { + for project in self.projects().into_iter().filter(|p| !p.light) { + let trusted = self.trust.trusted(&project.root).unwrap_or(false); + let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel); + if previous == trusted { + continue; + } + if !trusted { + project.lsp.set_execution_allowed(false); + } + project.repos(); + if trusted { + if let Some(cmd) = self.environment.project_opened(&project.root, &project.sink) { + self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone()); + } + project.lsp.set_execution_allowed(true); + } else { + self.environment.project_closed(); + project.agents.shutdown(); + self.processes.kill_project(&project.root); + project.notebooks.restrict().await; + let windows: Vec<_> = self + .projects + .read() + .iter() + .filter(|(_, current)| std::sync::Arc::ptr_eq(current, &project)) + .map(|(window, _)| window.clone()) + .collect(); + for window in windows { + let requests = self.project_requests.write().remove(&window).unwrap_or_default(); + for request in requests { + self.cancel_request(request); + } + for session in self.debugger.sessions(Some(&window)) { + let _ = self.debugger.stop(session.id).await; + } + } + } + project.lsp.reconfigure().await; + project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted }); + } + Ok(()) + } +} + +/// Commands which only inspect/edit source or operate user-owned application state. +/// New execution commands are denied until deliberately classified here. +pub fn restricted_command_allowed(cmd: &str) -> bool { + if [ + "fs_", + "doc_", + "syntax_", + "search_", + "nav_", + "history_", + "spell_", + "analysis_", + "structural_", + "refactor_", + "bytes_", + "window_", + "window_group_", + "term_", + "settings_", + "project_trust_", + "ssh_", + "git_credential", + ] + .iter() + .any(|p| cmd.starts_with(p)) + { + return true; + } + matches!( + cmd, + "app_info" + | "app_metrics" + | "app_quit" + | "app_cancel_quit" + | "app_restart" + | "languages" + | "menu_refresh" + | "menu_model" + | "menu_configure" + | "menu_set_disabled" + | "lsp_servers" + | "lsp_resend_diagnostics" + | "recent_projects" + | "remove_recent_project" + | "open_project" + | "close_project" + | "project_current" + | "project_roots" + | "index_status" + | "jvm_file_presentations" + | "deploy_coverage" + | "find_files" + | "find_symbols" + | "mark_recent" + | "remove_recent" + | "recent_files" + | "web_goto" + | "path_completions" + | "web_unresolved_references" + | "project_rename" + | "light_edit_open" + | "workspace_create" + | "workspace_update" + | "workspace_attach" + | "cancel_request" + | "cancel_task" + | "log_frontend" + | "env_reload" + | "remote_invoke" + | "remote_current" + | "remote_disconnect" + | "remote_connect" + | "remote_list" + | "remote_open" + | "remote_of" + | "web_browsers" + | "web_url" + | "web_open" + | "debug_attachable_processes" + | "agent_subscribe" + | "agent_entries" + | "agent_commands" + | "agent_unqueue" + | "agent_cancel" + | "agent_archive" + | "agent_unarchive" + | "agent_registry_list" + | "agent_registry_install" + | "agent_registry_remove" + | "coverage_import" + | "system_font_families" + | "plugin_subscribe" + | "plugin_list" + | "plugin_set_active_file" + | "plugin_set_theme" + | "plugin_set_theme_frame" + | "plugin_catalog" + | "notebook_open" + | "notebook_get" + | "notebook_edit" + | "notebook_save" + | "notebook_undo" + | "notebook_redo" + | "notebook_reload" + | "notebook_close" + | "notebook_interrupt" + | "run_stop" + | "run_list" + | "debug_stop" + | "debug_sessions" + | "vcs_clone" + | "vcs_init" + | "vcs_status" + | "vcs_roots" + | "vcs_branch_info" + | "vcs_line_hunks" + | "vcs_file_at" + | "vcs_blame" + | "vcs_log" + | "vcs_commit_files" + | "vcs_commits_changes" + | "vcs_branches" + | "vcs_stashes" + | "vcs_conflict_versions" + | "vcs_diff_text" + | "vcs_diff_hunks" + | "vcs_detected_git" + | "vcs_config_get" + | "vcs_tags" + | "vcs_remotes" + | "vcs_worktrees" + | "vcs_diff_with_worktree" + | "vcs_diff_revisions" + | "vcs_merge_base" + | "vcs_shelves" + | "vcs_shelf_patch" + | "vcs_changelists_load" + ) || cmd.starts_with("plugin:") +} + +impl crate::Host { + pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> { + if restricted_command_allowed(command) { + return Ok(()); + } + self.project(window)?.require_trust() + } +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct TrustStatus { + pub root: ide_core::IpcPath, + pub trusted: bool, + pub decided: bool, +} +impl TrustStore { + pub fn status(&self, path: &Path) -> IdeResult { + let root = dunce::canonicalize(path)?; + let trusted = self.trusted(&root)?; + let state = self.decisions()?; + Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted }) + } +} diff --git a/crates/ide-host/tests/properties.rs b/crates/ide-host/tests/properties.rs index a9760fe4..097d92a6 100644 --- a/crates/ide-host/tests/properties.rs +++ b/crates/ide-host/tests/properties.rs @@ -73,6 +73,7 @@ async fn properties_search_git_and_document_coordinates_agree() { let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n"; std::fs::write(&path, original).unwrap(); let host = host(&base); + host.trust.decide(&root, true, false).unwrap(); let project = host.open_project("main", &root).unwrap(); for _ in 0..500 { if !project.repos().is_empty() { diff --git a/crates/ide-host/tests/remote_env.rs b/crates/ide-host/tests/remote_env.rs index 87e14177..5f1fbf7c 100644 --- a/crates/ide-host/tests/remote_env.rs +++ b/crates/ide-host/tests/remote_env.rs @@ -100,6 +100,7 @@ fn server_environment_from_login_shell_and_devcontainer() { "postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"], }"#, ); + host.trust.decide(&project, true, false).unwrap(); runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap(); assert_eq!(child_var("FROM_PROJECT"), "two-demo"); assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH")); @@ -158,6 +159,7 @@ fn server_environment_from_login_shell_and_devcontainer() { // Reopening the project doesn't run postStartCommand again (once per server start). host.close_project("main"); assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project"); + host.trust.decide(&project, true, false).unwrap(); runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap(); std::thread::sleep(Duration::from_millis(500)); assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n"); @@ -165,6 +167,7 @@ fn server_environment_from_login_shell_and_devcontainer() { // A failing postStartCommand is a notification; the project opens regardless. let failing = tmp.join("failing"); write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#); + host.trust.decide(&failing, true, false).unwrap(); runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap(); wait_for("the failure notification", || { events.0.lock().iter().any(|e| { @@ -176,6 +179,7 @@ fn server_environment_from_login_shell_and_devcontainer() { // A broken file is a warning, not an error. let broken = tmp.join("broken"); write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": "); + host.trust.decide(&broken, true, false).unwrap(); runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap(); assert!(events.0.lock().iter().any(|e| matches!(e, IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied"))); diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs index ca2bd49e..d16f408f 100644 --- a/crates/ide-host/tests/rpc.rs +++ b/crates/ide-host/tests/rpc.rs @@ -70,11 +70,24 @@ async fn serves_projects_files_documents_search_and_runs() { let client = Arc::new(Client::default()); let peer = Peer::connect(cr, cw, client.clone()); - let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap(); + let info: ServerInfo = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap(); assert_eq!(info.protocol, rpc::PROTOCOL_VERSION); let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap(); assert_eq!(opened["name"], "demo"); + assert_eq!(opened["trusted"], false); + invoke(&peer, "index_status", json!({})).await.unwrap(); + assert_eq!(invoke(&peer, "lsp_servers", json!({})).await.unwrap(), json!([])); + invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap(); + let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap(); + assert!(files.is_array()); + for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] { + let error = invoke(&peer, command, json!({})).await.unwrap_err(); + assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}"); + } + let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) }; + assert!(peer.request::<_, Value>("invoke", call, None).await.is_err()); + invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap(); let current = invoke(&peer, "project_current", json!({})).await.unwrap(); assert_eq!(current["name"], "demo"); @@ -247,6 +260,7 @@ async fn local_deployment_runs_on_the_project_host() { tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw)); let (cr, cw) = tokio::io::split(client_io); let peer = Peer::connect(cr, cw, Arc::new(Client::default())); + invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap(); invoke(&peer, "open_project", json!({"path": project})).await.unwrap(); let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"}); invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()})) diff --git a/crates/ide-host/tests/run_configs.rs b/crates/ide-host/tests/run_configs.rs index 93d3ca5c..339121f7 100644 --- a/crates/ide-host/tests/run_configs.rs +++ b/crates/ide-host/tests/run_configs.rs @@ -21,6 +21,7 @@ async fn removed_detections_stay_removed_and_overrides_restore() { let root = dunce::canonicalize(tmp.path()).unwrap(); std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap(); let host = host(&root.join("app")); + host.trust.decide(&root, true, false).unwrap(); host.open_project("main", &root).unwrap(); let detected = ops::run::configs(&host, "main").unwrap(); assert!(detected.len() >= 2); @@ -58,6 +59,7 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() { let root = dunce::canonicalize(tmp.path()).unwrap(); std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap(); let host = host(&root.join("app")); + host.trust.decide(&root, true, false).unwrap(); host.open_project("main", &root).unwrap(); ops::project::set_settings( &host, @@ -88,6 +90,8 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() { let workspace = root.join("workspace"); ide_workspace::workspace::Workspace::save(&workspace, "Native", vec![member.clone()], true).unwrap(); let host = host(&root.join("app")); + host.trust.decide(&workspace, true, false).unwrap(); + host.trust.decide(&member, true, false).unwrap(); host.open_project("main", &workspace).unwrap(); let configs = ops::run::configs(&host, "main").unwrap(); let build = configs.iter().find(|c| c.kind == "cmake" && c.args.first().is_some_and(|a| a == "--build")).unwrap(); diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs new file mode 100644 index 00000000..011d5946 --- /dev/null +++ b/crates/ide-host/tests/trust.rs @@ -0,0 +1,244 @@ +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops, trust::TrustStore}; +use std::path::Path; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +fn host(base: &Path, environment: bool) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + 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: environment, + })) +} + +#[test] +fn persists_user_decisions_locations_revocation_and_one_time_migration() { + let tmp = tempfile::tempdir().unwrap(); + let config = tmp.path().join("config"); + let data = tmp.path().join("data"); + let a = tmp.path().join("projects/a"); + let b = tmp.path().join("projects/b"); + std::fs::create_dir_all(a.join(".openidea")).unwrap(); + std::fs::create_dir_all(b.join(".openidea")).unwrap(); + std::fs::write(a.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap(); + std::fs::write(b.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap(); + let recent = ide_workspace::RecentProjects::load(&config); + recent.touch(&a, None).unwrap(); + let trust = TrustStore::load(&config, &data); + assert!(trust.trusted(&a).unwrap()); + assert!(!trust.status(&b).unwrap().decided); + recent.touch(&b, None).unwrap(); + let trust = TrustStore::load(&config, &data); + assert!(!trust.trusted(&b).unwrap(), "migration is not repeated"); + trust.decide(&a, true, true).unwrap(); + assert!(trust.trusted(&b).unwrap()); + trust.decide(&b, false, false).unwrap(); + assert!(!trust.trusted(&b).unwrap(), "revocation overrides a trusted location"); + let trust = TrustStore::load(&config, &data); + assert!(!trust.trusted(&b).unwrap()); + trust.remove_location(&dunce::canonicalize(a.parent().unwrap()).unwrap()).unwrap(); + assert!(trust.trusted(&a).unwrap()); + trust.decide(&a, false, false).unwrap(); + assert!(!TrustStore::load(&config, &data).trusted(&a).unwrap()); + std::fs::write(data.join("project-trust.json"), "broken").unwrap(); + assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed"); +} + +#[cfg(unix)] +fn script(root: &Path, name: &str, marker: &str) { + use std::os::unix::fs::PermissionsExt; + let path = root.join(name); + std::fs::write(&path, format!("#!/bin/sh\nprintf executed > '{marker}'\n")).unwrap(); + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap(); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn malicious_project_stays_editable_then_starts_in_place_after_trust() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("malicious"); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + script(&root, "hook", "hook.marker"); + script(&root, "server", "lsp.marker"); + script(&root, "gradlew", "build.marker"); + std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap(); + std::fs::write(root.join("main.py"), "print('safe')\n").unwrap(); + std::fs::write(root.join(".devcontainer.json"), r#"{"postStartCommand":["./hook"]}"#).unwrap(); + std::fs::write(root.join(".openidea/settings.toml"), format!( + "[languageServers.trust-witness]\nlanguages = ['python']\ncommand = {:?}\n[languageServers.pyright]\nenabled = false\n", root.join("server").to_str().unwrap() + )).unwrap(); + let host = host(&tmp.path().join("app"), true); + let project = host.open_project("main", &root).unwrap(); + assert!(!project.is_trusted()); + let doc = ops::doc::open(&host, "main", root.join("main.py")).await.unwrap(); + ops::doc::save(&host, "main", doc.id).await.unwrap(); + assert!(project.index.wait_idle(Duration::from_secs(10))); + assert!(project.index.status().files > 0); + let sync = || { + ops::build::build_sync( + &host, + "main", + ide_build::BuildTool::Gradle, + ide_core::RequestId(472), + false, + vec![], + ops::Channel::new(|_| {}), + ) + }; + assert!(sync().await.is_err(), "restricted build marker: {}", root.join("build.marker").exists()); + assert!( + ops::sdk::sdk_locate_tools(&host, "missing-window", vec!["server".into()], Some(root.clone())).await.is_err() + ); + for command in [ + "run_start", + "debug_start", + "notebook_execute", + "coverage_collect", + "profiler_collect", + "docker_compose_op", + "agent_prompt", + "sdk_detect", + "build_sync", + ] { + assert!(host.authorize_command("main", command).is_err(), "{command}"); + assert!(host.authorize_command("missing-window", command).is_err(), "{command}"); + } + tokio::time::sleep(Duration::from_millis(200)).await; + for marker in ["hook.marker", "lsp.marker", "build.marker"] { + assert!(!root.join(marker).exists(), "{marker}"); + } + host.set_project_trust(&root, true, false).await.unwrap(); + assert!(Arc::ptr_eq(&project, &host.project("main").unwrap())); + assert_eq!(project.documents.snapshot(doc.id).unwrap().text, doc.text); + sync().await.unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !root.join("hook.marker").exists() || !root.join("lsp.marker").exists() { + assert!(Instant::now() < deadline, "trusted services must start"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(root.join("build.marker").exists()); + host.set_project_trust(&root, false, false).await.unwrap(); + assert!(!project.is_trusted()); + assert!(sync().await.is_err()); + ops::doc::save(&host, "main", doc.id).await.unwrap(); + host.shutdown().await; +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restricted_terminal_keeps_user_input_but_ignores_project_startup_commands() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(&root).unwrap(); + let host = host(&tmp.path().join("app"), false); + host.global_settings + .set( + ide_workspace::SettingsScope::Global, + "terminal.env", + serde_json::json!({"ZDOTDIR": root, "ENV": "/dev/null", "BASH_ENV": "/dev/null"}), + ) + .unwrap(); + host.open_project("main", &root).unwrap(); + let terminal = ops::term::spawn( + &host, + "main", + ide_terminal::TerminalOptions { + cwd: root.clone().into(), + shell: Some("/bin/sh".into()), + args: vec!["-c".into(), "touch automatic.marker".into()], + env: Default::default(), + cols: 80, + rows: 24, + shell_integration: false, + }, + Arc::new(|_| {}), + ) + .unwrap(); + host.terminals.write(terminal.id, "printf user > terminal.marker; exit\n").unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + while !root.join("terminal.marker").exists() { + assert!(Instant::now() < deadline, "user terminal input must run"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!(!root.join("automatic.marker").exists()); + host.shutdown().await; +} + +#[cfg(unix)] +#[test] +fn trusted_locations_use_canonical_directory_boundaries() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("trusted/project"); + let other = tmp.path().join("trusted-other/project"); + std::fs::create_dir_all(&root).unwrap(); + std::fs::create_dir_all(&other).unwrap(); + std::os::unix::fs::symlink(other.parent().unwrap(), root.parent().unwrap().join("escape")).unwrap(); + let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data")); + trust.decide(&root, true, true).unwrap(); + assert!(!trust.trusted(&other).unwrap()); + let alias = root.parent().unwrap().join("escape/project"); + assert!(!trust.trusted(&alias).unwrap()); + assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap()); +} + +#[cfg(unix)] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() { + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap().join("project"); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let git = |args: &[&str]| { + let output = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + }; + git(&["init", "-q"]); + git(&["config", "user.name", "Test"]); + git(&["config", "user.email", "test@example.com"]); + git(&["config", "commit.gpgsign", "false"]); + git(&["config", "core.hooksPath", ".git/hooks"]); + let file = root.join("safe.txt"); + std::fs::write(&file, "safe\n").unwrap(); + script(&root, ".git/hooks/pre-commit", "git-hook.marker"); + let executable = root.join(".openidea/git-witness"); + std::fs::write(&executable, "#!/bin/sh\nprintf executed > git-executable.marker\nexec git \"$@\"\n").unwrap(); + std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write( + root.join(".openidea/settings.toml"), + format!("[vcs.git]\nexecutable = {:?}\n", executable.to_str().unwrap()), + ) + .unwrap(); + let host = host(&tmp.path().join("app"), false); + let project = host.open_project("main", &root).unwrap(); + assert!(!project.with_repo(|repo| repo.status()).unwrap().is_empty()); + assert!(ops::vcs::vcs_stage(&host, "main", vec![file.clone()]).await.is_err()); + assert!(!root.join("git-hook.marker").exists()); + assert!(!root.join("git-executable.marker").exists()); + host.set_project_trust(&root, true, false).await.unwrap(); + ops::vcs::vcs_stage(&host, "main", vec![file]).await.unwrap(); + project.with_repo(|repo| repo.commit("trusted", false, false)).unwrap(); + assert!(root.join("git-executable.marker").exists()); + assert!(root.join("git-hook.marker").exists()); + ide_vcs::set_git_executable(None); + host.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn revocation_still_applies_when_a_project_folder_was_removed() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("project"); + std::fs::create_dir(&root).unwrap(); + let host = host(&tmp.path().join("app"), false); + host.trust.decide(&root, true, true).unwrap(); + let project = host.open_project("main", &root).unwrap(); + std::fs::remove_dir_all(&root).unwrap(); + host.set_project_trust(&project.root, false, false).await.unwrap(); + assert!(!project.is_trusted()); + host.shutdown().await; +} diff --git a/crates/ide-host/tests/trust_git_binary.rs b/crates/ide-host/tests/trust_git_binary.rs new file mode 100644 index 00000000..0002b46b --- /dev/null +++ b/crates/ide-host/tests/trust_git_binary.rs @@ -0,0 +1,36 @@ +//! The configured Git binary is process-wide, so it has its own test binary. +#![cfg(unix)] +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use std::os::unix::fs::PermissionsExt; +use std::sync::Arc; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn restricted_status_does_not_select_the_project_git_for_other_windows() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let host = Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + 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, + })); + let root = base.join("malicious"); + std::fs::create_dir_all(root.join(".openidea")).unwrap(); + let marker = base.join("git.marker"); + let git = root.join("git"); + std::fs::write(&git, format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display())).unwrap(); + std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap(); + std::fs::write(root.join(".openidea/settings.toml"), format!("[vcs.git]\nexecutable = {:?}\n", git)).unwrap(); + ide_vcs::init(&root).unwrap(); + + host.open_project("main", &root).unwrap(); + ops::vcs::status(&host, "main").await.unwrap(); + + let _ = ide_vcs::git_version(None); + assert!(!marker.exists(), "another window's next Git command would run the project's binary"); +} diff --git a/crates/ide-host/tests/vcs_encoding.rs b/crates/ide-host/tests/vcs_encoding.rs index 7bb1442a..71b8e6a4 100644 --- a/crates/ide-host/tests/vcs_encoding.rs +++ b/crates/ide-host/tests/vcs_encoding.rs @@ -35,6 +35,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() { runtime: tokio::runtime::Handle::current(), project_environment: false, }); + host.trust.decide(&root, true, false).unwrap(); let project = host.open_project("main", &root).unwrap(); for _ in 0..500 { if !project.repos().is_empty() { diff --git a/crates/ide-host/tests/vcs_roots.rs b/crates/ide-host/tests/vcs_roots.rs index c0ee818b..d0431a1a 100644 --- a/crates/ide-host/tests/vcs_roots.rs +++ b/crates/ide-host/tests/vcs_roots.rs @@ -38,6 +38,7 @@ async fn patches_read_and_write_the_requested_repository() { runtime: tokio::runtime::Handle::current(), project_environment: false, }); + host.trust.decide(&project_root, true, false).unwrap(); let project = host.open_project("main", &project_root).unwrap(); for _ in 0..500 { if project.repos().all().len() == 2 { diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index 42742cc3..86a8d9cd 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -30,6 +30,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di project_environment: false, }); std::fs::write(root.join("internal-needle.txt"), "needle").unwrap(); + host.trust.decide(&root, true, false).unwrap(); let project = host.open_project("main", &root).unwrap(); assert_eq!(ops::project::info(&project).name, "Full stack"); assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path()); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 5604023c..b64fd0ac 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -174,6 +174,7 @@ pub struct LspManager { candidates_hook: RwLock>>, excludes_hook: RwLock>>, shutting_down: AtomicBool, + execution_allowed: AtomicBool, /// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure. shown_messages: Mutex>, /// Virtual (non-`file:`) URIs a server returned, by the server that owns them. @@ -305,6 +306,7 @@ impl LspManager { candidates_hook: RwLock::new(None), excludes_hook: RwLock::new(None), shutting_down: AtomicBool::new(false), + execution_allowed: AtomicBool::new(true), shown_messages: Mutex::new(HashSet::new()), virtual_origins: Mutex::new(HashMap::new()), virtual_docs: Mutex::new(HashMap::new()), @@ -372,6 +374,9 @@ impl LspManager { /// The definitions of the servers `language` uses. fn language_servers(&self, language: &LanguageId) -> Vec { + if !self.execution_allowed.load(Ordering::Acquire) { + return Vec::new(); + } let Some(cfg) = self.languages.get(language) else { return Vec::new() }; match self.servers_hook.read().clone() { Some(hook) => hook(language, &cfg.language_servers), @@ -418,7 +423,7 @@ impl LspManager { } fn ensure_started(&self, server: &Arc) { - if self.shutting_down.load(Ordering::Acquire) { + if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) { return; } let Some(generation) = server.begin_start() else { return }; @@ -436,7 +441,13 @@ impl LspManager { // The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP // servers may take a while to start listening. let launched = tokio::task::spawn_blocking(move || { + if !me.execution_allowed.load(Ordering::Acquire) { + return Err(LaunchError::Config("Project is in Restricted Mode".into())); + } let mut cfg = me.configured(&tail); + if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { + return Err(LaunchError::Config("Project is in Restricted Mode".into())); + } tail.set_effective_config(cfg.clone()); let prepare = me.prepare_hook.read().clone(); if !cancel.is_cancelled() @@ -453,6 +464,9 @@ impl LspManager { let missing = !me.launcher.available(&cfg, &root); return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) }); } + if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() { + return Err(LaunchError::Config("Project is in Restricted Mode".into())); + } me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel) }) .await @@ -2008,6 +2022,10 @@ impl LspManager { /// The server's base configuration with the settings hook applied. fn configured(&self, server: &Server) -> LanguageServerConfig { let mut cfg = server.config.clone(); + if !self.execution_allowed.load(Ordering::Acquire) { + cfg.launch_error = Some("Project is in Restricted Mode".into()); + return cfg; + } if let Some(hook) = self.config_hook.read().clone() { hook(&mut cfg, &server.root); } @@ -2018,7 +2036,27 @@ impl LspManager { /// (e.g. a new project interpreter); stopped ones pick it up on their next start. /// A change of `settings` alone reaches a running server without a restart. /// Servers no longer enabled stop; newly enabled ones attach to open documents. + pub fn set_execution_allowed(&self, allowed: bool) { + let previous = self.execution_allowed.swap(allowed, Ordering::AcqRel); + if !allowed { + for server in self.servers.lock().iter() { + server.start_token().cancel(); + } + } + if allowed && !previous { + for server in self.servers.lock().iter() { + server.reset_for_restart(); + self.ensure_started(server); + } + } + } + pub async fn reconfigure(&self) { + if !self.execution_allowed.load(Ordering::Acquire) { + let servers = self.servers.lock().clone(); + join_all(servers.iter().map(|s| self.stop_instance(s, true))).await; + return; + } self.stop_unlisted().await; let Some(me) = self.me.upgrade() else { return }; let servers: Vec> = self.servers.lock().clone(); diff --git a/crates/ide-plugin/src/lib.rs b/crates/ide-plugin/src/lib.rs index 093ff88d..72446576 100644 --- a/crates/ide-plugin/src/lib.rs +++ b/crates/ide-plugin/src/lib.rs @@ -92,7 +92,10 @@ impl LoadedPlugin { } } +type ProjectPolicy = Arc bool + Send + Sync>; + pub struct PluginHost { + project_policy: RwLock, engine: PluginEngine, plugins_dir: PathBuf, sink: Arc, @@ -116,6 +119,7 @@ impl PluginHost { .and_then(|s| serde_json::from_str(&s).ok()) .unwrap_or_default(); Ok(Self { + project_policy: RwLock::new(Arc::new(|_| false)), engine: PluginEngine::new().map_err(IdeError::internal)?, plugins_dir, sink, @@ -159,7 +163,30 @@ impl PluginHost { } /// Loads new dev plugins from `/.openidea/plugins/` and unloads vanished ones. + pub fn set_project_policy(&self, policy: ProjectPolicy) { + *self.project_policy.write() = policy; + } + + pub fn restrict_project(&self, root: &Path) { + let _guard = self.lifecycle.lock(); + let ids: Vec<_> = self + .plugins + .read() + .iter() + .filter(|(_, p)| p.dev_root.as_deref() == Some(root)) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + self.unload(&id); + } + self.emit(None, PluginEvent::CommandsChanged); + } + pub fn sync_project(&self, root: &Path) { + if !(self.project_policy.read())(root) { + self.restrict_project(root); + return; + } let _guard = self.lifecycle.lock(); let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins")); let stale: Vec = self @@ -448,7 +475,12 @@ impl PluginHost { // ---- internals ---- fn get(&self, id: &str) -> IdeResult> { - self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}"))) + let plugin = + self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?; + if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) { + return Err(IdeError::unavailable("Project is in Restricted Mode")); + } + Ok(plugin) } fn is_loaded_from(&self, dir: &Path) -> bool { diff --git a/crates/ide-plugin/tests/host.rs b/crates/ide-plugin/tests/host.rs index 573851cd..a644cca9 100644 --- a/crates/ide-plugin/tests/host.rs +++ b/crates/ide-plugin/tests/host.rs @@ -380,6 +380,7 @@ fn state_persists_across_restarts() { #[test] fn dev_plugins_follow_the_project() { let f = fixture(Limits::default()); + f.host.set_project_policy(Arc::new(|_| true)); let dev = f.project.join(DEV_PLUGINS_DIR).join("toy"); copy_dir(&sample("language-toy"), &dev); f.host.sync_project(&f.project); @@ -474,3 +475,38 @@ fn catalog_lists_sample_packages() { let x = catalog.iter().find(|c| c.id == "x").unwrap(); assert!(x.problem.as_deref().unwrap().contains("plugin.wasm")); } + +#[test] +fn restricted_dev_plugin_has_no_activation_registration() { + let f = fixture(Limits::default()); + let dev = f.project.join(DEV_PLUGINS_DIR).join("hello"); + copy_dir(&sample("hello"), &dev); + let manifest = dev.join("plugin.toml"); + let text = std::fs::read_to_string(&manifest).unwrap(); + std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap(); + assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty()); + let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let policy = trusted.clone(); + f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire))); + f.host.sync_project(&f.project); + let untrusted = f.host.list(Some(&f.project)); + assert!( + !untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"), + "untrusted development plugin registered its activation witness" + ); + assert!(untrusted.plugins.is_empty()); + assert!(f.host.info_of("hello").is_err()); + + // Positive control: activation itself registers these commands, not the manifest. + trusted.store(true, std::sync::atomic::Ordering::Release); + f.host.sync_project(&f.project); + let info = f.host.info_of("hello").unwrap(); + assert_eq!(info.status, PluginStatus::Active); + assert!(info.commands.iter().any(|c| c.id == "word-count")); + trusted.store(false, std::sync::atomic::Ordering::Release); + assert!(f.host.reload("hello").is_err()); + assert!(f.host.set_enabled("hello", true).is_err()); + assert!(f.run("hello", "word-count", None).is_err()); + f.host.sync_project(&f.project); + assert!(f.host.list(Some(&f.project)).plugins.is_empty()); +} diff --git a/crates/ide-run/src/process.rs b/crates/ide-run/src/process.rs index 1179c968..0808e952 100644 --- a/crates/ide-run/src/process.rs +++ b/crates/ide-run/src/process.rs @@ -46,6 +46,7 @@ struct StopCommand { } struct Entry { + project_root: PathBuf, id: RunId, config_id: String, name: String, @@ -337,7 +338,7 @@ impl ProcessManager { return Err(IdeError::invalid("Go build and run requires the output console")); } if extras.emulate_terminal { - return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp); + return self.start_pty(root, config, &inv, &cwd, &env, stop, options, sink, on_output, temp); } let mut cmd = Command::new(&inv.program); @@ -384,6 +385,7 @@ impl ProcessManager { _ => None, }; let entry = Arc::new(Entry { + project_root: root.to_path_buf(), id, config_id: config.id.clone(), name: config.name.clone(), @@ -444,6 +446,7 @@ impl ProcessManager { #[allow(clippy::too_many_arguments)] fn start_pty( &self, + root: &Path, config: &RunConfig, inv: &Invocation, cwd: &Path, @@ -493,6 +496,7 @@ impl ProcessManager { let id = RunId::next(); let entry = Arc::new(Entry { + project_root: root.to_path_buf(), id, config_id: config.id.clone(), name: config.name.clone(), @@ -640,6 +644,16 @@ impl ProcessManager { } /// Kills every running process tree at once (shutting down). + pub fn kill_project(&self, root: &Path) { + for entry in self.runs.lock().values().filter(|e| e.project_root == root) { + if !entry.exited.load(Ordering::Acquire) + && let Some(pid) = *entry.pid.lock() + { + kill_tree(pid, true); + } + } + } + pub fn kill_all(&self) { for entry in self.runs.lock().values() { entry.stopping.store(true, Ordering::Release); diff --git a/crates/ide-run/tests/process.rs b/crates/ide-run/tests/process.rs index e94b0e66..c4af2bca 100644 --- a/crates/ide-run/tests/process.rs +++ b/crates/ide-run/tests/process.rs @@ -474,6 +474,26 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() { } } +#[test] +fn revoking_one_project_stops_only_its_runs() { + let h = harness(); + let other = tempfile::tempdir().unwrap(); + let (mine, _) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30"])); + let theirs = + h.pm.start( + other.path(), + &cfg("theirs", "/bin/sh", &["-c", "sleep 30"]), + &StartOptions::default(), + h.sink.clone(), + Box::new(|_| {}), + ) + .unwrap(); + h.pm.kill_project(h.root.path()); + wait_exit(&h, mine, Duration::from_secs(5)); + assert!(h.pm.list().iter().any(|run| run.id == theirs.id && run.running)); + h.pm.stop(theirs.id).unwrap(); +} + #[test] #[ignore = "requires CMake, CTest and a C++ compiler"] fn cmake_configure_discovers_builds_and_runs_a_multifile_target() { diff --git a/crates/ide-vcs/src/cli.rs b/crates/ide-vcs/src/cli.rs index a0e39080..54809482 100644 --- a/crates/ide-vcs/src/cli.rs +++ b/crates/ide-vcs/src/cli.rs @@ -202,7 +202,25 @@ pub(crate) fn run_paths(root: &Path, args: &[&str], paths: &[String]) -> IdeResu /// Builds a `git` command with the environment every invocation needs. pub(crate) fn command(root: &Path) -> IdeResult { - let mut cmd = Command::new(git_binary()?); + let restricted = !crate::execution_allowed(); + let binary = if restricted { + detected_git().ok_or_else(|| IdeError::unavailable("git executable not found"))?.to_path_buf() + } else { + git_binary()? + }; + let mut cmd = Command::new(binary); + if restricted { + for setting in [ + "core.fsmonitor=false", + "core.hooksPath=", + "diff.external=", + "core.attributesFile=", + "submodule.recurse=false", + ] { + cmd.arg("-c").arg(setting); + } + cmd.env_remove("GIT_EXTERNAL_DIFF").env("GIT_NO_LAZY_FETCH", "1"); + } cmd.current_dir(root) .arg("-c") .arg("core.quotepath=off") diff --git a/crates/ide-vcs/src/lib.rs b/crates/ide-vcs/src/lib.rs index 6777005f..ac6f18de 100644 --- a/crates/ide-vcs/src/lib.rs +++ b/crates/ide-vcs/src/lib.rs @@ -484,8 +484,16 @@ pub use roots::Repos; pub fn clone(url: &str, dest: &Path, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { let parent = dest.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new(".")); std::fs::create_dir_all(parent)?; + let dest = if dest.is_absolute() { dest.to_path_buf() } else { std::env::current_dir()?.join(dest) }; let dest = dest.to_string_lossy(); - cli::run_streaming(parent, &["clone", "--progress", "--", url, &dest], cancel, on_output) + // Cloning must not inherit an enclosing project's executable Git settings. + let cwd = tempfile::tempdir()?; + cli::run_streaming( + cwd.path(), + &["-c", "core.hooksPath=", "clone", "--progress", "--", url, &dest], + cancel, + on_output, + ) } /// Creates an empty repository in `dir` (`git init`). @@ -502,6 +510,7 @@ pub fn diff_hunks(old_text: &str, new_text: &str) -> Vec { /// An open git repository (worktree). Cheap to keep around; all reads re-check the /// on-disk state, so it never needs to be reopened after external changes. pub struct Repo { + execution_allowed: std::sync::atomic::AtomicBool, root: PathBuf, git_dir: PathBuf, inner: std::sync::Mutex, @@ -515,27 +524,33 @@ impl Repo { } /// The worktree root. pub fn root(&self) -> &Path { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); &self.root } /// Untracked files with one of these names (or under such a folder) are not reported. pub fn set_ignored_names(&self, names: IgnoredNames) { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); *self.ignored.write().unwrap_or_else(|p| p.into_inner()) = names; } /// Staged (HEAD↔index) and unstaged (index↔worktree, incl. untracked) changes, /// sorted by path. Conflicted files report `unstaged: Conflicted` and no staged kind. pub fn status(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); status::status(self) } pub fn branch_info(&self) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); refs::branch_info(self) } /// Current branch (short name; `None` when detached) and HEAD commit, without /// upstream tracking (cheap enough to call for every repository on each refresh). pub fn head(&self) -> IdeResult<(Option, Option)> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); refs::head(&self.gix()?) } /// Merge / rebase / cherry-pick… in progress (cheap: no ref walking). pub fn operation(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); Ok(refs::operation(&self.gix()?)) } /// Content of `path` at `rev` (default HEAD); `None` if absent there. @@ -547,12 +562,14 @@ impl Repo { encoding: impl Into, ) -> IdeResult> { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let rel = self.rel_path(path)?; let repo = self.gix()?; Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned())) } /// Raw revision bytes, for encoding detection when the working file is absent. pub fn file_bytes_at(&self, path: &Path, rev: Option<&str>) -> IdeResult>> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); repo::blob_at(&self.gix()?, &self.rel_path(path)?, rev.unwrap_or("HEAD")) } /// Stage selected decoded hunks, retaining the index encoding, BOM and line endings. @@ -564,6 +581,7 @@ impl Repo { encoding: impl Into, ) -> IdeResult<()> { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stage_hunks(self, path, current, selected, encoding) } /// Hunks of `current_text` against HEAD (or the index when `against_index`). @@ -580,6 +598,7 @@ impl Repo { encoding: impl Into, ) -> IdeResult> { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let Ok(rel) = self.rel_path(path) else { return Ok(Vec::new()) }; if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") { return Ok(Vec::new()); @@ -609,6 +628,7 @@ impl Repo { encoding: impl Into, ) -> IdeResult> { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::blame(self, path, current_text, revision, encoding) } /// Commits newest-first in topological order (children before parents). @@ -619,43 +639,52 @@ impl Repo { /// `skip`/`limit` paginate over the filtered result; `limit == 0` means no limit. /// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: `. pub fn log(&self, query: &LogQuery) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); history::log(self, query) } /// Files changed by `commit` relative to its first parent (renames detected). pub fn commit_files(&self, commit: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); history::commit_files(self, commit) } /// Combined changes of several commits (oldest first): per file, before the first commit /// that touched it ↔ after the last one (IDEA's multi-commit Git Log selection). pub fn commits_changes(&self, commits: &[String]) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); history::commits_changes(self, commits) } /// Local and remote branches; the current branch first, then most recent first. pub fn branches(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); refs::branches(self) } /// Stash entries, newest (`stash@{0}`) first. pub fn stashes(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); refs::stashes(self) } /// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff /// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged. pub fn diff_text(&self, path: &Path, staged: bool, encoding: impl Into) -> IdeResult { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::diff_text(self, path, staged, encoding) } /// `git config --get ` (all scopes); `None` when unset. pub fn config_get(&self, key: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let out = cli::run_raw(&self.root, &["config", "--get", key], None)?; Ok((out.code == Some(0)).then(|| out.stdout_str().trim().to_string())) } // ---- writes (git CLI) ---- pub fn stage(&self, paths: &[PathBuf]) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stage(self, paths) } pub fn unstage(&self, paths: &[PathBuf]) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::unstage(self, paths) } /// Applies a unified-diff patch to the index (partial staging of hunks/lines). @@ -665,16 +694,19 @@ impl Repo { patch: &str, encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stage_patch(self, patch, &|path| encoding(path).map(Into::into)) } /// Discards unstaged changes: tracked paths are restored from the index (staged /// changes are kept), untracked files and directories are deleted. pub fn discard(&self, paths: &[PathBuf]) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::discard(self, paths) } /// Commits the index; returns the new HEAD commit id. An empty message with /// `amend` keeps the previous message. pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() }; write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into())) } @@ -685,12 +717,14 @@ impl Repo { options: &CommitOptions, encoding: &dyn Fn(&Path) -> IdeResult, ) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::commit(self, message, options, &|path| encoding(path).map(Into::into)) } /// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch /// (`origin/x`) gets a local tracking branch and any other revision is checked out /// detached. pub fn checkout(&self, branch: &str, create: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::checkout(self, branch, create) } /// [`Repo::checkout`] as IDEA does it: local changes in the way are reported (or @@ -698,62 +732,75 @@ impl Repo { /// is behind fast-forwards that branch, while one that is ahead is reported (or /// rebased / reset per `options.diverged`). pub fn checkout_with(&self, branch: &str, create: bool, options: &CheckoutOptions) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::checkout_with(self, branch, create, options) } /// Creates branch `name` at `start` (default HEAD), optionally switching to it. A /// remote-tracking start point (`origin/x`) becomes the new branch's upstream. pub fn create_branch(&self, name: &str, start: Option<&str>, checkout: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::create_branch(self, name, start, checkout) } /// Undo Commit: resets the current branch to the parent of `commit` (which must be /// HEAD and not a merge) keeping its changes staged; undoing the initial commit /// leaves an unborn branch. Returns the undone commit's message. pub fn undo_commit(&self, commit: &str) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::undo_commit(self, commit) } /// Deletes a local branch (`git branch -d`, `-D` when `force`). pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::delete_branch(self, branch, force) } /// Deletes a local branch as IDEA does: without asking, force-deleting one that is not /// fully merged (reported as `unmerged`), and returning what [`Repo::restore_branch`] /// needs to undo it. pub fn delete_branch_restorable(&self, branch: &str) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::delete_branch_restorable(self, branch) } /// Recreates a deleted branch at its old tip, with its upstream. pub fn restore_branch(&self, deleted: &DeletedBranch) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::restore_branch(self, deleted) } /// Renames a local branch (`git branch -m`); its upstream configuration moves with it. pub fn rename_branch(&self, from: &str, to: &str) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::rename_branch(self, from, to) } /// Merges `branch` into HEAD; local changes are stashed and restored around it. On /// conflicts an error is returned and the repository is left mid-merge /// (`branch_info().operation == Some("merge")`). pub fn merge(&self, branch: &str) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::merge(self, branch) } /// Rebases the current branch onto `onto`; local changes are stashed and restored. pub fn rebase(&self, onto: &str) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::rebase(self, onto) } /// `keep_index`: changes already staged stay in the index (and the working tree). pub fn stash_push(&self, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stash_push(self, message, include_untracked, keep_index) } /// `reinstate_index`: also restore what was staged when the stash was made. pub fn stash_apply(&self, index: u32, pop: bool, reinstate_index: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stash_apply(self, index, pop, reinstate_index) } /// Creates and checks out `branch` at the commit the stash was made on, applies the /// stash there (index included) and drops it. pub fn stash_branch(&self, index: u32, branch: &str) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stash_branch(self, index, branch) } /// Removes `stash@{index}` without applying it. pub fn stash_drop(&self, index: u32) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::stash_drop(self, index) } /// Deletes a branch on a remote (`git push --delete `); `branch` is the @@ -764,16 +811,19 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::delete_remote_branch(self, branch, cancel, on_output) } /// Network operations stream progress lines through `on_output`. /// Fetches all remotes (pruning deleted branches). pub fn fetch(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); cli::run_streaming(&self.root, &["fetch", "--progress", "--all", "--prune"], cancel, on_output) } /// Update Project: pulls the upstream by merge or rebase. Local changes are stashed /// and restored; the merge mode fast-forwards when possible whatever `pull.ff` says. pub fn pull(&self, rebase: bool, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let mode: &[&str] = if rebase { &["--rebase"] } else { &["--no-rebase", "--ff"] }; let mut args = vec!["pull", "--progress", "--autostash"]; args.extend_from_slice(mode); @@ -788,6 +838,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::push(self, force_with_lease, set_upstream, None, cancel, on_output) } /// [`Repo::push`] that also pushes tags (Push dialog › Push tags). @@ -799,6 +850,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::push(self, force_with_lease, set_upstream, tags, cancel, on_output) } /// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log @@ -811,6 +863,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::push_branch( self, branch, @@ -829,11 +882,13 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::push_branch(self, branch, options, cancel, on_output) } /// Fast-forwards a local branch that is not checked out to its upstream (Branches › /// Update). Fails when the branch has diverged. pub fn update_branch(&self, branch: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); write::update_branch(self, branch, cancel, on_output) } // ---- in-progress operations, history rewriting, tags ---- @@ -841,13 +896,16 @@ impl Repo { /// Continues the merge / rebase / cherry-pick / revert in progress; fails while /// conflicts are unresolved. pub fn continue_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::continue_op(self, cancel, on_output) } pub fn abort_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::abort_op(self, cancel, on_output) } /// Skips the current commit of a rebase / cherry-pick / revert. pub fn skip_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::skip_op(self, cancel, on_output) } /// Cherry-picks `commits` in order (oldest first). Stops mid-way on conflicts. @@ -857,10 +915,12 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::cherry_pick(self, commits, cancel, on_output) } /// Reverts `commits` in order (newest first) with git's default messages. pub fn revert(&self, commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::revert(self, commits, cancel, on_output) } /// Interactive rebase of `base..HEAD` (`None`: from the root) following `entries`, @@ -873,6 +933,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::interactive_rebase(self, base, entries, cancel, on_output) } /// Moves the current branch to `commit` (`git reset --`). @@ -883,26 +944,32 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::reset(self, commit, mode, cancel, on_output) } /// Creates tag `name` on `commit` (annotated when `message` is given). pub fn create_tag(&self, name: &str, commit: &str, message: Option<&str>, force: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::create_tag(self, name, commit, message, force) } pub fn tags(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::tags(self) } /// Files that differ between revisions `from` and `to`. pub fn diff_revisions(&self, from: &str, to: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::diff_revisions(self, from, to) } /// Tracked files whose working-tree content differs from `rev`. pub fn diff_with_worktree(&self, rev: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::diff_with_worktree(self, rev) } /// `(repo-relative path, added lines, deleted lines)` of the tracked files whose /// working-tree content differs from `rev`. pub fn numstat_with_worktree(&self, rev: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::numstat_with_worktree(self, rev) } @@ -910,37 +977,45 @@ impl Repo { /// Patch of the local changes of `paths` against HEAD (untracked files included). pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let rel = self.rel_paths(paths)?; Ok(patch::working_patch(self, &rel, reverse, true)?.0) } /// Patch of the changes made by `commits` (oldest first). pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); patch::commits_patch(self, commits, reverse) } pub fn patch_preview(&self, patch: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); patch::preview(self, patch) } /// Applies a patch to the working tree, 3-way merging when it does not apply cleanly. pub fn apply_patch(&self, patch: &str) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); patch::apply(self, patch) } /// Saves the changes of `paths` under `shelf_dir` and reverts them. pub fn shelve(&self, shelf_dir: &Path, name: &str, paths: &[PathBuf]) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let rel = self.rel_paths(paths)?; shelf::shelve(self, shelf_dir, name, &rel) } pub fn unshelve(&self, shelf_dir: &Path, id: &str, remove: bool) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); shelf::unshelve(self, shelf_dir, id, remove) } /// Who is on each side of the conflicts of the operation in progress. pub fn merge_sides(&self) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); ops::merge_sides(self) } /// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge /// dialog shows them (sides swapped during a rebase). pub fn merge_versions(&self, path: &Path, encoding: impl Into) -> IdeResult { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let (base, ours, theirs) = self.conflict_versions(path, encoding)?; let sides = self.merge_sides()?; let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) }; @@ -957,6 +1032,7 @@ impl Repo { encoding: impl Into, ) -> IdeResult<(Option, Option, Option)> { let encoding = encoding.into(); + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let rel = self.rel_path(path)?; let repo = self.gix()?; let stage = |n: u8| -> IdeResult> { @@ -965,3 +1041,25 @@ impl Repo { Ok((stage(1)?, stage(2)?, stage(3)?)) } } + +thread_local! { static EXECUTION_ALLOWED: std::cell::Cell = const { std::cell::Cell::new(true) }; } +struct ExecutionScope(bool); +impl ExecutionScope { + fn enter(allowed: bool) -> Self { + Self(EXECUTION_ALLOWED.with(|current| current.replace(current.get() && allowed))) + } +} +impl Drop for ExecutionScope { + fn drop(&mut self) { + EXECUTION_ALLOWED.with(|current| current.set(self.0)); + } +} +pub(crate) fn execution_allowed() -> bool { + EXECUTION_ALLOWED.with(|current| current.get()) +} +impl Repo { + pub fn set_execution_allowed(&self, allowed: bool) { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + self.execution_allowed.store(allowed, std::sync::atomic::Ordering::Release); + } +} diff --git a/crates/ide-vcs/src/manage.rs b/crates/ide-vcs/src/manage.rs index 2276e715..7e5dccd9 100644 --- a/crates/ide-vcs/src/manage.rs +++ b/crates/ide-vcs/src/manage.rs @@ -210,6 +210,7 @@ pub(crate) fn parse_worktrees(text: &str) -> Vec { impl Repo { /// Merges `branches` into the current branch (Git › Merge…). pub fn merge_with(&self, branches: &[String], options: &MergeOptions) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = merge_args(branches, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); run(self.root(), &args, None).map(|_| ()) @@ -217,6 +218,7 @@ impl Repo { /// Rebases the current branch onto `upstream` (or `upstream..HEAD` onto `options.onto`). pub fn rebase_with(&self, upstream: &str, options: &RebaseOptions) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = rebase_args(upstream, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); run(self.root(), &args, None).map(|_| ()) @@ -232,6 +234,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let args = pull_args(remote, branches, rebase, options)?; let args: Vec<&str> = args.iter().map(String::as_str).collect(); cli::run_streaming(self.root(), &args, cancel, on_output) @@ -239,6 +242,7 @@ impl Repo { /// Manage Remotes: adds, removes or edits a remote. pub fn edit_remote(&self, edit: &RemoteEdit) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); match edit { RemoteEdit::Add { name, url } => { let (name, url) = (arg(name, "remote name")?, arg(url, "URL")?); @@ -262,6 +266,7 @@ impl Repo { /// Working trees of the repository, the main one first. pub fn worktrees(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let out = run(self.root(), &["worktree", "list", "--porcelain"], None)?; Ok(parse_worktrees(&out.stdout_str())) } @@ -269,6 +274,7 @@ impl Repo { /// Git › New Worktree…: checks out `commitish` at `path`, on a new branch `new_branch` /// when given (otherwise `commitish` must be a branch not checked out elsewhere). pub fn add_worktree(&self, path: &Path, commitish: &str, new_branch: Option<&str>) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let path = path.to_string_lossy().into_owned(); let commitish = arg(commitish, "revision")?; let mut args: Vec = vec!["worktree".into(), "add".into()]; @@ -281,6 +287,7 @@ impl Repo { } pub fn remove_worktree(&self, path: &Path, force: bool) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let path = path.to_string_lossy().into_owned(); let mut args = vec!["worktree", "remove"]; if force { diff --git a/crates/ide-vcs/src/ops.rs b/crates/ide-vcs/src/ops.rs index 3795d0ef..d3508f42 100644 --- a/crates/ide-vcs/src/ops.rs +++ b/crates/ide-vcs/src/ops.rs @@ -70,16 +70,9 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult { /// Three-way merge of file contents (`git merge-file --diff3`): changes that don't /// conflict are merged, each conflict is left as a diff3 block. pub(crate) fn merge_file(r: &Repo, ours: &str, base: &str, theirs: &str) -> IdeResult { - struct TempDir(std::path::PathBuf); - impl Drop for TempDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - let dir = TempDir(r.git_dir.join(format!("openidea-merge-{}", std::process::id()))); - std::fs::create_dir_all(&dir.0)?; + let dir = tempfile::tempdir()?; let file = |name: &str, text: &str| -> IdeResult { - let path = dir.0.join(name); + let path = dir.path().join(name); std::fs::write(&path, text)?; Ok(path.to_string_lossy().into_owned()) }; @@ -345,6 +338,38 @@ pub(crate) fn tags(r: &Repo) -> IdeResult> { /// Tracked files that differ between `rev` and the working tree (Compare with Local). pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult> { check_commits(&[rev.to_string()])?; + if !crate::execution_allowed() { + let out = run( + r.root(), + &["diff", "--cached", "--name-status", "-z", "--no-ext-diff", "--no-textconv", rev, "--"], + None, + )?; + let mut paths: std::collections::BTreeSet = + parse_name_status(&out.stdout_str()).into_iter().map(|c| c.path).collect(); + paths.extend(r.status()?.into_iter().filter(|s| s.unstaged != Some(ChangeKind::Untracked)).map(|s| s.rel_path)); + let repo = r.gix()?; + let mut changes = Vec::new(); + for path in paths { + let old = crate::repo::blob_at(&repo, &path, rev)?; + let new = match std::fs::read(r.abs_path(&path)) { + Ok(bytes) => Some(bytes), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(e.into()), + }; + if old == new { + continue; + } + let kind = if old.is_none() { + ChangeKind::Added + } else if new.is_none() { + ChangeKind::Deleted + } else { + ChangeKind::Modified + }; + changes.push(ChangedFile { path, kind, old_path: None }); + } + return Ok(changes); + } let out = run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", rev, "--"], None)?; Ok(parse_name_status(&out.stdout_str())) } @@ -372,7 +397,8 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> { /// Files that differ between two revisions (Update Project's "N files updated"). pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult> { check_commits(&[from.to_string(), to.to_string()])?; - let out = run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", from, to, "--"], None)?; + let out = + run(r.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to, "--"], None)?; Ok(parse_name_status(&out.stdout_str())) } diff --git a/crates/ide-vcs/src/remotes.rs b/crates/ide-vcs/src/remotes.rs index 4b9bc965..0049a777 100644 --- a/crates/ide-vcs/src/remotes.rs +++ b/crates/ide-vcs/src/remotes.rs @@ -40,12 +40,14 @@ pub(crate) fn parse_remotes(text: &str) -> Vec { impl Repo { /// Configured remotes with their fetch URLs. pub fn remotes(&self) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let out = cli::run(self.root(), &["remote", "-v"], None)?; Ok(parse_remotes(&out.stdout_str())) } /// Whether `rev` names a commit present in the object database. pub fn has_commit(&self, rev: &str) -> bool { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); check_rev(rev).is_ok() && cli::run_raw(self.root(), &["cat-file", "-e", &format!("{rev}^{{commit}}")], None) .is_ok_and(|o| o.code == Some(0)) @@ -60,6 +62,7 @@ impl Repo { cancel: &CancellationToken, on_output: &dyn Fn(String), ) -> IdeResult<()> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); check_rev(remote)?; for r in refspecs { check_rev(r)?; @@ -71,6 +74,7 @@ impl Repo { /// The best common ancestor of two revisions, if any. pub fn merge_base(&self, a: &str, b: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); check_rev(a)?; check_rev(b)?; let out = cli::run_raw(self.root(), &["merge-base", a, b], None)?; @@ -79,9 +83,14 @@ impl Repo { /// Files changed between two revisions (renames detected). pub fn diff_files(&self, from: &str, to: &str) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); check_rev(from)?; check_rev(to)?; - let out = cli::run(self.root(), &["diff", "--name-status", "-z", "-M", "--no-ext-diff", from, to, "--"], None)?; + let out = cli::run( + self.root(), + &["diff", "--name-status", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to, "--"], + None, + )?; Ok(crate::ops::parse_name_status(&out.stdout_str())) } } diff --git a/crates/ide-vcs/src/repo.rs b/crates/ide-vcs/src/repo.rs index 0e5ddece..34372474 100644 --- a/crates/ide-vcs/src/repo.rs +++ b/crates/ide-vcs/src/repo.rs @@ -47,7 +47,13 @@ pub(crate) fn discover(path: &Path) -> IdeResult> { let common_dir = absolute(repo.common_dir())?; let files = config_files(&git_dir, &common_dir); let cache = Cache { config_stamp: stamp(&files), config_files: files, repo: ts }; - Ok(Some(Repo { root, git_dir, inner: std::sync::Mutex::new(cache), ignored: Default::default() })) + Ok(Some(Repo { + execution_allowed: std::sync::atomic::AtomicBool::new(true), + root, + git_dir, + inner: std::sync::Mutex::new(cache), + ignored: Default::default(), + })) } pub(crate) fn absolute(p: &Path) -> IdeResult { @@ -70,6 +76,11 @@ impl Repo { /// A thread-local gitoxide handle; reopened when the git configuration changed /// on disk (e.g. after `push --set-upstream` or `git config` in a terminal). pub(crate) fn gix(&self) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); + if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) { + return gix::open_opts(&self.git_dir, gix::open::Options::default().filter_config_section(|_| false)) + .map_err(gx); + } let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner()); let now = stamp(&cache.config_files); if now != cache.config_stamp { @@ -89,6 +100,7 @@ impl Repo { /// Repository-relative path with `/` separators. Accepts absolute paths inside the /// worktree or paths already relative to it. pub(crate) fn rel_path(&self, path: &Path) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let rel = if path.is_absolute() { self.strip_root(path) .ok_or_else(|| IdeError::invalid(format!("{} is outside the repository", path.display())))? @@ -140,6 +152,7 @@ impl Repo { /// Absolute path for a repository-relative path. pub(crate) fn abs_path(&self, rel: &str) -> PathBuf { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let mut p = self.root.clone(); for part in rel.split('/').filter(|s| !s.is_empty()) { p.push(part); diff --git a/crates/ide-vcs/src/write.rs b/crates/ide-vcs/src/write.rs index 0b45d3c8..e31701be 100644 --- a/crates/ide-vcs/src/write.rs +++ b/crates/ide-vcs/src/write.rs @@ -16,6 +16,7 @@ const UNCOMMITTED_AUTHOR: &str = "Not Committed Yet"; impl Repo { pub(crate) fn rel_paths(&self, paths: &[PathBuf]) -> IdeResult> { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); paths .iter() .map(|p| { @@ -26,6 +27,7 @@ impl Repo { } pub(crate) fn has_head(&self) -> IdeResult { + let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire)); let repo = self.gix()?; Ok(!repo.head().map_err(gx)?.is_unborn()) } @@ -86,11 +88,17 @@ pub(crate) fn blame( if !tracked_in_head { return Ok(uncommitted_lines(line_count)); } - let out = run( - r.root(), - &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], - Some(&contents), - )?; + // Restricted blame reads committed blobs only. Feeding working-tree bytes to + // Git's --contents path would run configured clean filters. + let out = if crate::execution_allowed() { + run( + r.root(), + &["--literal-pathspecs", "blame", "--porcelain", "--contents", "-", revision, "--", &rel], + Some(&contents), + )? + } else { + run(r.root(), &["--literal-pathspecs", "blame", "--no-textconv", "--porcelain", revision, "--", &rel], None)? + }; let mut lines = crate::blame::parse_porcelain(&out.stdout_str()); for line in &mut lines { if line.commit.bytes().all(|b| b == b'0') { @@ -98,6 +106,14 @@ pub(crate) fn blame( line.summary = String::new(); } } + if !crate::execution_allowed() { + let base = r.file_at(path, Some(revision), encoding)?.unwrap_or_default(); + for hunk in crate::diff::line_hunks(&base, &text).into_iter().rev() { + let start = (hunk.old_start as usize).min(lines.len()); + let end = (start + hunk.old_lines as usize).min(lines.len()); + lines.splice(start..end, uncommitted_lines(hunk.lines as usize)); + } + } // Defensive: always exactly one entry per line. lines.truncate(line_count); while lines.len() < line_count { @@ -161,6 +177,16 @@ fn uncommitted_lines(n: usize) -> Vec { pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult { let rel = r.rel_path(path)?; + if !crate::execution_allowed() { + let old = r.file_at(path, Some(if staged { "HEAD" } else { ":0" }), encoding)?.unwrap_or_default(); + let new = if staged { + r.file_at(path, Some(":0"), encoding)?.unwrap_or_default() + } else { + std::fs::read(r.abs_path(&rel)).map(|b| decode(&b, encoding).into_owned()).unwrap_or_default() + }; + let selected: Vec<_> = (0..crate::diff::line_hunks(&old, &new).len()).collect(); + return Ok(crate::diff::build_partial_patch(&rel, &old, &new, &selected)); + } let base = ["-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "diff.external=", "--literal-pathspecs"]; let in_index = { @@ -221,7 +247,11 @@ pub(crate) fn stage_patch(r: &Repo, patch: &str, encoding: &dyn Fn(&Path) -> Ide /// Paths with unresolved conflicts (index stages 1–3). pub(crate) fn unmerged_paths(r: &Repo) -> IdeResult> { - let out = run(r.root(), &["diff", "--name-only", "--diff-filter=U", "-z"], None)?; + let out = run( + r.root(), + &["diff", "--cached", "--no-ext-diff", "--no-textconv", "--name-only", "--diff-filter=U", "-z"], + None, + )?; Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect()) } diff --git a/crates/ide-vcs/tests/repo.rs b/crates/ide-vcs/tests/repo.rs index 33588d27..6db9d76f 100644 --- a/crates/ide-vcs/tests/repo.rs +++ b/crates/ide-vcs/tests/repo.rs @@ -1534,3 +1534,54 @@ fn gbk_text_patch_keeps_utf8_paths_and_encoded_payload() { assert!(repo.stage_patch(&patch, &|_| Ok(encoding_rs::GBK)).is_err()); assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), encoding_rs::GBK.encode(new).0.as_ref()); } + +#[cfg(unix)] +#[test] +fn restricted_reads_ignore_executable_repository_configuration() { + let f = Fixture::new(); + f.write("a.txt", "one\ntwo\n"); + f.commit_all("initial"); + f.write(".gitattributes", "*.txt filter=evil diff=evil\n"); + f.write("a.txt", "one\nchanged\n"); + for key in ["filter.evil.clean", "diff.evil.textconv", "diff.external", "core.fsmonitor"] { + f.git(&["config", key, "touch execution.marker; cat"]); + } + let repo = f.repo(); + repo.set_execution_allowed(false); + assert!(!repo.status().unwrap().is_empty()); + assert!(repo.diff_text(&f.path("a.txt"), false, encoding_rs::UTF_8).unwrap().contains("changed")); + assert_eq!(repo.blame(&f.path("a.txt"), None, None, encoding_rs::UTF_8).unwrap().len(), 2); + assert!(!repo.diff_with_worktree("HEAD").unwrap().is_empty()); + assert!(!repo.log(&LogQuery::default()).unwrap().is_empty()); + repo.merge_versions(&f.path("a.txt"), encoding_rs::UTF_8).unwrap(); + assert!(!f.path("execution.marker").exists()); + // Positive control: the same filter really executes without restriction. + f.git(&["-c", "core.fsmonitor=false", "diff", "--no-ext-diff", "--no-textconv"]); + assert!(f.path("execution.marker").exists()); +} + +#[cfg(unix)] +#[test] +fn restricted_conflict_preview_uses_host_owned_temporary_files() { + let f = Fixture::new(); + f.write("a.txt", "base\n"); + f.commit_all("base"); + f.git(&["checkout", "-b", "other"]); + f.write("a.txt", "other\n"); + f.commit_all("other"); + f.git(&["checkout", "main"]); + f.write("a.txt", "mine\n"); + f.commit_all("mine"); + let output = Command::new("git").current_dir(&f.dir).args(["merge", "other"]).output().unwrap(); + assert!(!output.status.success()); + let trap = f.path(&format!(".git/openidea-merge-{}", std::process::id())); + std::fs::create_dir_all(&trap).unwrap(); + let marker = f._tmp.path().join("user-data"); + std::fs::write(&marker, "untouched").unwrap(); + std::os::unix::fs::symlink(&marker, trap.join("ours")).unwrap(); + let repo = f.repo(); + repo.set_execution_allowed(false); + let versions = repo.merge_versions(&f.path("a.txt"), encoding_rs::UTF_8).unwrap(); + assert!(versions.merged.unwrap().contains("<<<<<<<")); + assert_eq!(std::fs::read_to_string(marker).unwrap(), "untouched"); +} diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 529a398e..e68485c6 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -266,3 +266,41 @@ pub fn log_frontend(level: LogLevel, message: String) { LogLevel::Error => tracing::error!(target: "frontend", "{message}"), } } + +#[tauri::command] +#[specta::specta] +pub fn project_trust_status(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + state.trust.status(&path) +} + +#[tauri::command] +#[specta::specta] +pub fn project_trust_list(state: State<'_, AppState>) -> IdeResult { + state.trust.decisions() +} + +#[tauri::command] +#[specta::specta] +pub async fn project_trust_set( + state: State<'_, AppState>, + path: PathBuf, + trusted: bool, + folder: bool, +) -> IdeResult<()> { + state.host.set_project_trust(&path, trusted, folder).await?; + if !trusted { + state.plugins.restrict_project(&dunce::canonicalize(&path).unwrap_or(path)); + } + Ok(()) +} + +#[tauri::command] +#[specta::specta] +pub async fn project_trust_remove_location(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { + state.trust.remove_location(&path)?; + state.host.refresh_project_trust().await?; + for project in state.host.projects().into_iter().filter(|p| !p.is_trusted()) { + state.plugins.restrict_project(&project.root); + } + Ok(()) +} diff --git a/src-tauri/src/commands/coverage.rs b/src-tauri/src/commands/coverage.rs index ea331287..b775dd2d 100644 --- a/src-tauri/src/commands/coverage.rs +++ b/src-tauri/src/commands/coverage.rs @@ -23,8 +23,12 @@ pub async fn coverage_prepare( /// Reads the report a coverage run left behind. #[tauri::command] #[specta::specta] -pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult { - ide_host::ops::coverage::coverage_collect(&state.host, "", report).await +pub async fn coverage_collect( + window: Window, + state: State<'_, AppState>, + report: CoverageReportSpec, +) -> IdeResult { + ide_host::ops::coverage::coverage_collect(&state.host, window.label(), report).await } /// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3eeea13c..46784eeb 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -64,6 +64,10 @@ pub fn builder() -> tauri_specta::Builder { app::recent_projects, app::remove_recent_project, app::open_project, + app::project_trust_status, + app::project_trust_list, + app::project_trust_set, + app::project_trust_remove_location, workspace::workspace_create, workspace::workspace_attach, workspace::workspace_update, diff --git a/src-tauri/src/commands/plugin.rs b/src-tauri/src/commands/plugin.rs index 2536906d..0f94dc2a 100644 --- a/src-tauri/src/commands/plugin.rs +++ b/src-tauri/src/commands/plugin.rs @@ -36,8 +36,16 @@ fn deliver(client: Option<&str>, event: PluginEvent) { } /// Creates the host over `/plugins` and loads installed plugins in the background. -pub fn create_host(_app: &AppHandle, data_dir: &Path) -> IdeResult> { +pub fn create_host( + _app: &AppHandle, + data_dir: &Path, + project_host: &Arc, +) -> IdeResult> { let host = Arc::new(PluginHost::new(data_dir.join("plugins"), Arc::new(deliver), Limits::default())?); + let weak = Arc::downgrade(project_host); + host.set_project_policy(Arc::new(move |root| { + weak.upgrade().is_some_and(|host| host.trust.trusted(root).unwrap_or(false)) + })); let loader = host.clone(); std::thread::Builder::new().name("plugin-load".into()).spawn(move || loader.load_all())?; Ok(host) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 660b8943..779118e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -84,76 +84,100 @@ pub fn run() { // Paths macOS asked to open (Finder's Open With, the Dock) before setup ran: the first window takes them. let early_opens: Arc>>> = Arc::default(); let setup_opens = early_opens.clone(); - app.invoke_handler(builder.invoke_handler()) - .setup(move |app| { - builder.mount_events(app); - let state = AppState::new(app.handle().clone())?; - let windows = WindowsState::default(); - if let Ok(cwd) = std::env::current_dir() - && let Some(path) = windows::cli_path(&std::env::args().collect::>(), &cwd) - { - windows.set_launch("main", windows::path_launch(path)); + let invoke = Arc::new(builder.invoke_handler()); + app.invoke_handler(move |request| { + let webview = request.message.webview_ref(); + if let Some(state) = webview.try_state::() { + let label = AppState::owner_window(webview.label()); + if let Some(connection) = state.remote_dev.get(label) { + let label = label.to_owned(); + let command = request.message.command().to_owned(); + let invoke = invoke.clone(); + tauri::async_runtime::spawn(async move { + match connection.authorize_command(&label, &command).await { + Ok(()) => { + invoke(request); + } + Err(error) => request.resolver.reject(error), + } + }); + return true; } - let menu_state = menu::MenuState::default(); - menu::install(app.handle(), &menu_state, &state.recent.list()); - app.manage(state); - app.manage(windows); - app.manage(menu_state); - if let Some(paths) = setup_opens.lock().take() { - windows::open_paths_at_launch(app.handle(), paths); + if let Err(error) = state.host.authorize_command(webview.label(), request.message.command()) { + request.resolver.reject(error); + return true; } - app.on_menu_event(on_menu_event); - windows::create_main_window(app.handle())?; - Ok(()) - }) - .on_window_event(|window, event| match event { - tauri::WindowEvent::Focused(true) => { - if let Some(w) = window.try_state::() { - w.focused(window.label()); - } + } + invoke(request) + }) + .setup(move |app| { + builder.mount_events(app); + let state = AppState::new(app.handle().clone())?; + let windows = WindowsState::default(); + if let Ok(cwd) = std::env::current_dir() + && let Some(path) = windows::cli_path(&std::env::args().collect::>(), &cwd) + { + windows.set_launch("main", windows::path_launch(path)); + } + let menu_state = menu::MenuState::default(); + menu::install(app.handle(), &menu_state, &state.recent.list()); + app.manage(state); + app.manage(windows); + app.manage(menu_state); + if let Some(paths) = setup_opens.lock().take() { + windows::open_paths_at_launch(app.handle(), paths); + } + app.on_menu_event(on_menu_event); + windows::create_main_window(app.handle())?; + Ok(()) + }) + .on_window_event(|window, event| match event { + tauri::WindowEvent::Focused(true) => { + if let Some(w) = window.try_state::() { + w.focused(window.label()); } - tauri::WindowEvent::Destroyed => { - window_groups::destroyed(window.app_handle(), window.label()); - if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) - { - let _ = settings.destroy(); - } - if let Some(state) = window.try_state::() { - state.close_project(window.label()); - } - if let Some(w) = window.try_state::() { - w.forget(window.label()); - } - if !windows::is_settings_window(window.label()) { - windows::continue_quit(window.app_handle(), Some(window.label())); - } + } + tauri::WindowEvent::Destroyed => { + window_groups::destroyed(window.app_handle(), window.label()); + if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) { + let _ = settings.destroy(); } - _ => {} - }) - .build(context) - .expect("error while running OpenIDEA") - .run(move |app, event| { - #[cfg(target_os = "macos")] - if let tauri::RunEvent::Opened { urls } = &event { - let paths = windows::opened_paths(urls); - if app.try_state::().is_some() { - windows::open_paths(app, paths); - } else { - early_opens.lock().get_or_insert_default().extend(paths); - } + if let Some(state) = window.try_state::() { + state.close_project(window.label()); } - #[cfg(not(target_os = "macos"))] - let _ = &early_opens; - if let tauri::RunEvent::Exit = event { - if let Some(state) = app.try_state::() { - state.host.shutdown_notebooks_on_exit(); - } - // Cleanup must finish before update/restart replaces this process. - if app.try_state::().is_some_and(|w| w.restart_requested()) { - tauri::process::restart(&app.env()); - } + if let Some(w) = window.try_state::() { + w.forget(window.label()); } - }); + if !windows::is_settings_window(window.label()) { + windows::continue_quit(window.app_handle(), Some(window.label())); + } + } + _ => {} + }) + .build(context) + .expect("error while running OpenIDEA") + .run(move |app, event| { + #[cfg(target_os = "macos")] + if let tauri::RunEvent::Opened { urls } = &event { + let paths = windows::opened_paths(urls); + if app.try_state::().is_some() { + windows::open_paths(app, paths); + } else { + early_opens.lock().get_or_insert_default().extend(paths); + } + } + #[cfg(not(target_os = "macos"))] + let _ = &early_opens; + if let tauri::RunEvent::Exit = event { + if let Some(state) = app.try_state::() { + state.host.shutdown_notebooks_on_exit(); + } + // Cleanup must finish before update/restart replaces this process. + if app.try_state::().is_some_and(|w| w.restart_requested()) { + tauri::process::restart(&app.env()); + } + } + }); } fn window_state_flags() -> tauri_plugin_window_state::StateFlags { diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 8b5f97be..e194a191 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -53,6 +53,28 @@ type Channels = Arc, u64)>>>; #[derive(Default)] pub struct Connections(Mutex>>); +impl Connection { + pub async fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> { + if ide_host::trust::restricted_command_allowed(command) { + return Ok(()); + } + let project: Option = self + .peer + .request( + "invoke", + Invoke { window: window.to_owned(), cmd: "project_current".into(), args: serde_json::json!({}) }, + None, + ) + .await + .map_err(ide_host::rpc::error_from_reply)?; + if project.is_some_and(|p| p.trusted) { + Ok(()) + } else { + Err(IdeError::unavailable("Project is in Restricted Mode")) + } + } +} + impl Connections { pub fn get(&self, window: &str) -> Option> { self.0.lock().get(window).cloned() diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 7f265e72..67413569 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -92,7 +92,7 @@ impl AppState { recent: RecentProjects::load(&config_dir), askpass: start_askpass(&app), sink: global_sink, - plugins: crate::commands::plugin::create_host(&app, &data_dir)?, + plugins: crate::commands::plugin::create_host(&app, &data_dir, &host)?, web: ide_web::WebServer::new(), remote_dev: crate::remote_dev::Connections::default(), host, diff --git a/src/bindings.ts b/src/bindings.ts index 8b04d143..05f530ce 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -11,6 +11,10 @@ export const commands = { recentProjects: () => __TAURI_INVOKE("recent_projects"), removeRecentProject: (path: string) => typedError(__TAURI_INVOKE("remove_recent_project", { path })), openProject: (path: string) => typedError(__TAURI_INVOKE("open_project", { path })), + projectTrustStatus: (path: string) => typedError(__TAURI_INVOKE("project_trust_status", { path })), + projectTrustList: () => typedError(__TAURI_INVOKE("project_trust_list")), + projectTrustSet: (path: string, trusted: boolean, folder: boolean) => typedError(__TAURI_INVOKE("project_trust_set", { path, trusted, folder })), + projectTrustRemoveLocation: (path: string) => typedError(__TAURI_INVOKE("project_trust_remove_location", { path })), workspaceCreate: (path: string, name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_create", { path, name, projects })), workspaceAttach: (path: string) => typedError(__TAURI_INVOKE("workspace_attach", { path })), workspaceUpdate: (name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_update", { name, projects })), @@ -28,6 +32,7 @@ export const commands = { lightEditOpen: (path: string) => typedError(__TAURI_INVOKE("light_edit_open", { path })), /** The project open in this window (a settings window: its parent's), if any. */ projectCurrent: () => __TAURI_INVOKE<{ + trusted: boolean, root: IpcPath, name: string, isGit: boolean, @@ -3463,7 +3468,7 @@ export type IdeError = { export type IdeEvent = /** Project window membership or selection changed. */ -{ type: "windowGroupsChanged" } | +{ type: "windowGroupsChanged" } | { type: "projectTrustChanged"; root: IpcPath; trusted: boolean } | /** Files or directories changed on disk (after debouncing, ignore rules applied). */ { type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } | /** Excluded or ignored folders whose contents changed; they stay out of `FsChanged`. */ @@ -4770,6 +4775,7 @@ export type ProjectFileSet = export type ProjectInfo = ProjectInfo_Serialize | ProjectInfo_Deserialize; export type ProjectInfo_Deserialize = { + trusted: boolean, root: IpcPath, name: string, isGit: boolean, @@ -4778,6 +4784,7 @@ export type ProjectInfo_Deserialize = { }; export type ProjectInfo_Serialize = { + trusted: boolean, root: IpcPath, name: string, isGit: boolean, @@ -6111,6 +6118,18 @@ export type TransferReport = { errors: TransferError[], }; +export type TrustDecisions = { + projects: string[], + locations: string[], + restricted: string[], +}; + +export type TrustStatus = { + root: IpcPath, + trusted: boolean, + decided: boolean, +}; + export type TypeScriptSettings = { /** * `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`), diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 1d17e2af..085a9e29 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -173,7 +173,7 @@ describe("EditorController", () => { it("carries the plain project's editor tabs into its automatic workspace", async () => { const settings = {}; useProject.setState({ - project: { root: "/attach-a", name: "A", isGit: false, settings }, + project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings }, settings, lightEdit: false, }); @@ -185,6 +185,7 @@ describe("EditorController", () => { project: { root: "/attach-workspace", name: "A", + trusted: true, isGit: false, settings, workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] }, @@ -230,7 +231,7 @@ describe("EditorController", () => { it("builds IDEA's editor tab menu", async () => { useProject.setState({ - project: { name: "p", root: "/p", isGit: true, settings: {} } as never, + project: { name: "p", root: "/p", trusted: true, isGit: true, settings: {} } as never, settings: {} as never, }); const ids = [ diff --git a/src/editor/core/view/statusItems.test.tsx b/src/editor/core/view/statusItems.test.tsx index bb21b74c..79efab35 100644 --- a/src/editor/core/view/statusItems.test.tsx +++ b/src/editor/core/view/statusItems.test.tsx @@ -82,6 +82,7 @@ it("shows the member and its directories in workspace breadcrumbs", async () => root: "/workspace", name: "Workspace", settings: {}, + trusted: true, isGit: false, workspace: { version: 1, name: "Workspace", projects: ["/a", "/b"] }, }, diff --git a/src/editor/navigation/gotoTest.test.ts b/src/editor/navigation/gotoTest.test.ts index 92fc13c4..f21efaae 100644 --- a/src/editor/navigation/gotoTest.test.ts +++ b/src/editor/navigation/gotoTest.test.ts @@ -24,7 +24,7 @@ function deps(files: string[], roots = ROOTS) { beforeEach(() => { useProject.setState({ - project: { name: "p", root: "/p", isGit: false, settings: {} } as never, + project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never, settings: {} as never, }); }); diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index c79b1245..41498a3f 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -125,6 +125,7 @@ describe("workspace task project", () => { const project = { root: "/workspace", name: "Workspace", + trusted: true, isGit: false, workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] }, } as ProjectInfo; diff --git a/src/features/agents/index.ts b/src/features/agents/index.ts index e19dabb2..84d5ec9d 100644 --- a/src/features/agents/index.ts +++ b/src/features/agents/index.ts @@ -44,7 +44,7 @@ export function activate(): () => void { const disposers = [ activateSettingsPages(), useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) sync(s.project?.root); + if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) sync(s.project?.root); }), registerToolWindow({ id: TOOL_WINDOW, diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 8983037f..459b6c65 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -165,7 +165,12 @@ export async function connect(): Promise { }; let tasks: AgentTask[], providers: AgentProvider[]; try { - [tasks, providers] = await Promise.all([call(commands.agentSubscribe(channel)), call(commands.agentProviders())]); + [tasks, providers] = await Promise.all([ + call(commands.agentSubscribe(channel)), + useProject.getState().project?.trusted === false + ? Promise.resolve([] as AgentProvider[]) + : call(commands.agentProviders()), + ]); } catch (e) { if (gen === generation) useAgents.setState({ loaded: true, error: e instanceof Error ? e.message : String(e) }); return; diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index bf63ac8a..955f0e7f 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -10,7 +10,7 @@ const windowTakeLaunch = vi.fn(); const lightEditOpen = vi.fn(async (_path: string) => ({ status: "ok", data: useProject.getState().settings ?? {} })); const fsReadDir = vi.fn(async (_path: string) => ({ status: "ok", data: [] })); const openProject = vi.fn(async (path: string) => { - const project = { root: path, name: path, isGit: false, settings: {} }; + const project = { root: path, name: path, trusted: true, isGit: false, settings: {} }; useProject.setState({ project, lightEdit: false } as never); return project; }); @@ -61,7 +61,7 @@ provideEditorService({ }); const ok = (data: unknown) => ({ status: "ok", data }); -const projectState = (root: string) => ({ project: { root, name: root, isGit: false, settings: {} } }); +const projectState = (root: string) => ({ project: { root, name: root, trusted: true, isGit: false, settings: {} } }); beforeEach(() => { for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch]) f.mockReset(); @@ -246,6 +246,7 @@ describe("Attach", () => { const before = { root: "/workspace", name: "A", + trusted: true, isGit: false, settings: {}, workspace: { version: 1, name: "A", projects: ["/p/a"] }, @@ -269,6 +270,7 @@ it("removes the first workspace member while retaining the workspace state root" const project = { root: "/workspace", name: "A", + trusted: true, isGit: false, settings: {}, workspace: { version: 1, name: "A", projects: ["/a", "/b"] }, diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index cc53050d..f49133b1 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -9,7 +9,7 @@ */ import { executeAction, type Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; -import { call, commands, isTauri } from "@/platform/ipc"; +import { call, commands, isCancelled, isTauri } from "@/platform/ipc"; import { basename, confirmLeaveProject, @@ -39,6 +39,7 @@ function desktopOnly(): boolean { } function openFailed(path: string, e: unknown) { + if (isCancelled(e)) return; notifications().notify({ level: "error", title: t("app.window.openFailed", { name: basename(path) }), diff --git a/src/features/build-tools/index.ts b/src/features/build-tools/index.ts index 50634475..e80e103e 100644 --- a/src/features/build-tools/index.ts +++ b/src/features/build-tools/index.ts @@ -154,7 +154,7 @@ export function activate(): () => void { if (s.model !== prev.model || s.root !== prev.root) apply(s.root ? s.model : null, registered); }), useProject.subscribe((s, prev) => { - if (s.project?.root === prev.project?.root) return; + if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; resetBuild(); if (s.project) void loadModel(); }), diff --git a/src/features/build-tools/store.ts b/src/features/build-tools/store.ts index 95676481..2b6c8a5d 100644 --- a/src/features/build-tools/store.ts +++ b/src/features/build-tools/store.ts @@ -158,7 +158,7 @@ export async function loadModel(): Promise { current: {}, }); } - if (!root || !isTauri()) return; + if (!root || useProject.getState().project?.trusted === false || !isTauri()) return; useBuild.setState({ loading: true }); try { const [model, configs] = await Promise.all([ diff --git a/src/features/code-insight/servers.test.ts b/src/features/code-insight/servers.test.ts index b9e85dd1..ceded594 100644 --- a/src/features/code-insight/servers.test.ts +++ b/src/features/code-insight/servers.test.ts @@ -27,7 +27,7 @@ describe("server status", () => { afterEach(() => dispose()); it("keeps a status that arrived after the snapshot was requested", async () => { - useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never }); + useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never }); dispose = installServerEvents(); const id = 1 as ServerId; const info: ServerInfo = { id, name: "jdtls", languages: [], root: "/p", status: "starting", pid: null }; @@ -47,7 +47,7 @@ describe("server status", () => { }); it("drops a server the backend removed, even if an older snapshot still lists it", async () => { - useProject.setState({ project: { name: "p", root: "/p", isGit: false, settings: {} } as never }); + useProject.setState({ project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never }); dispose = installServerEvents(); const info = (id: number, name: string): ServerInfo => ({ id: id as ServerId, diff --git a/src/features/debugger/breakpoints/exceptions.ts b/src/features/debugger/breakpoints/exceptions.ts index 47ee867b..81b99dde 100644 --- a/src/features/debugger/breakpoints/exceptions.ts +++ b/src/features/debugger/breakpoints/exceptions.ts @@ -5,6 +5,7 @@ * which applies them to every session (adapter defaults for filters without a choice). */ import { create } from "zustand"; +import { useProject } from "@/platform/project"; import { call, commands, isTauri, type ExceptionFilter } from "@/platform/ipc"; interface ExceptionState { @@ -128,7 +129,8 @@ export function mergeKnown( } async function push() { - if (!isTauri()) return; + if (!isTauri() || !useExceptionBreakpoints.getState().root || useProject.getState().project?.trusted === false) + return; try { await call(commands.debugSetExceptionBreakpoints(useExceptionBreakpoints.getState().choices)); } catch (e) { diff --git a/src/features/debugger/breakpoints/special.ts b/src/features/debugger/breakpoints/special.ts index f2886832..873d0578 100644 --- a/src/features/debugger/breakpoints/special.ts +++ b/src/features/debugger/breakpoints/special.ts @@ -5,6 +5,7 @@ * them. Entries remember the source line they were created on for the gutter icon. */ import { create } from "zustand"; +import { useProject } from "@/platform/project"; import { call, commands, isTauri, type DataBreakpoint, type FunctionBreakpoint } from "@/platform/ipc"; import { newBreakpointId, type BreakpointStatus } from "./model"; import { useBreakpoints } from "./store"; @@ -121,7 +122,7 @@ function scheduleSync() { } export async function syncSpecial() { - if (!isTauri()) return; + if (!isTauri() || !useSpecialBreakpoints.getState().root || useProject.getState().project?.trusted === false) return; const { methods, fields } = useSpecialBreakpoints.getState(); const muted = useBreakpoints.getState().muted; try { diff --git a/src/features/debugger/breakpoints/store.ts b/src/features/debugger/breakpoints/store.ts index 9899609a..01b53a94 100644 --- a/src/features/debugger/breakpoints/store.ts +++ b/src/features/debugger/breakpoints/store.ts @@ -4,6 +4,7 @@ * The backend keeps them across sessions and returns verification states. */ import { create } from "zustand"; +import { useProject } from "@/platform/project"; import { call, commands, isTauri } from "@/platform/ipc"; import { makeBreakpoint, @@ -132,7 +133,7 @@ export function syncFile(path: string): Promise { } async function sendFile(path: string) { - if (!isTauri()) return; + if (!isTauri() || useProject.getState().project?.trusted === false) return; const { byFile, muted } = useBreakpoints.getState(); const list = byFile[path] ?? []; sent.set( diff --git a/src/features/debugger/index.ts b/src/features/debugger/index.ts index ed0bb12c..fa5db45f 100644 --- a/src/features/debugger/index.ts +++ b/src/features/debugger/index.ts @@ -81,7 +81,7 @@ export function activate(): () => void { }), useProject.subscribe((s, prev) => { const root = s.project?.root ?? null; - if (root !== (prev.project?.root ?? null)) { + if (root !== (prev.project?.root ?? null) || s.project?.trusted !== prev.project?.trusted) { resetSessions(); loadBreakpoints(root); loadExceptionBreakpoints(root); diff --git a/src/features/index.ts b/src/features/index.ts index ae77994e..2678ade5 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -4,6 +4,7 @@ * a disposer. The editor provides the editor service; app must install its launch * barrier before welcome can restore a remembered project. */ +import { activate as projectTrust } from "./project-trust"; import { activate as editor } from "@/editor/core"; import { activate as editorLsp } from "@/editor/lsp"; import { activate as welcome } from "./welcome"; @@ -46,6 +47,7 @@ import { activate as remoteDev } from "./remote-dev"; type Activate = () => void | (() => void); const features: Array<[string, Activate]> = [ + ["project-trust", projectTrust], ["editor", editor], ["editor-lsp", editorLsp], ["app", app], @@ -107,6 +109,7 @@ export { SettingsWindow, settingsWindowParent } from "./settings"; */ export function activateSettingsWindowFeatures(parent: string): () => void { const pages: Array<[string, Activate]> = [ + ["project-trust", projectTrust], ["locale", appLocale], ["settings", () => activateSettingsWindow(parent)], ["templates", templatesPages], diff --git a/src/features/libraries/index.ts b/src/features/libraries/index.ts index acc0f625..622f88cc 100644 --- a/src/features/libraries/index.ts +++ b/src/features/libraries/index.ts @@ -90,6 +90,7 @@ const sdkKey = (s: ReturnType) => { const l = s.settings?.languages; return JSON.stringify([ s.project?.root ?? null, + s.project?.trusted, l?.python?.interpreter, l?.java?.jdk, l?.node?.interpreter, @@ -116,7 +117,8 @@ export function activate(): () => void { }; const refresh = () => { const id = ++seq; - if (!useProject.getState().project || !isTauri()) return clear(); + if (!useProject.getState().project || useProject.getState().project?.trusted === false || !isTauri()) + return clear(); call(commands.sdkProjectLibraries()) .then((groups) => { if (id !== seq) return; diff --git a/src/features/plugins/index.ts b/src/features/plugins/index.ts index 2b6c729c..a5776723 100644 --- a/src/features/plugins/index.ts +++ b/src/features/plugins/index.ts @@ -70,7 +70,7 @@ export function activate(): () => void { startPluginBridge(), // Dev plugins (`/.openidea/plugins`) depend on the open project. useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) void refreshPlugins(); + if (s.project?.root !== prev.project?.root || s.project?.trusted !== prev.project?.trusted) void refreshPlugins(); }), ]; void refreshPlugins(); diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index 4e31c1b5..b1970bb6 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -53,7 +53,7 @@ let disposers: Array<() => void> = []; beforeEach(() => { useProjectTree.setState({ root: ROOT }); useProject.setState({ - project: { name: "p", root: ROOT, isGit: true, settings: {} } as never, + project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never, settings: {} as never, }); disposers = [ @@ -179,7 +179,7 @@ describe("project view context menu", () => { it("offers Compare With… in a non-git project too", () => { useProject.setState({ - project: { name: "p", root: ROOT, isGit: false, settings: {} } as never, + project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never, settings: {} as never, }); const l = labels(contextMenuItems([node(`${ROOT}/a.py`)])); @@ -192,7 +192,7 @@ describe("project view context menu", () => { disposers.forEach((d) => d()); disposers = []; useProject.setState({ - project: { name: "p", root: ROOT, isGit: true, settings: {} } as never, + project: { name: "p", root: ROOT, trusted: true, isGit: true, settings: {} } as never, settings: {} as never, }); const l = labels(contextMenuItems([node(`${ROOT}/a.py`)])); @@ -208,6 +208,7 @@ it("offers workspace removal for every member root", () => { root: "/workspace", name: "A", settings: {}, + trusted: true, isGit: false, workspace: { version: 1, name: "A", projects: ["/a", "/b"] }, }, diff --git a/src/features/project-tree/newPopupModel.test.ts b/src/features/project-tree/newPopupModel.test.ts index e6a720f9..8653ed5e 100644 --- a/src/features/project-tree/newPopupModel.test.ts +++ b/src/features/project-tree/newPopupModel.test.ts @@ -20,7 +20,7 @@ let disposers: Array<() => void> = []; beforeEach(() => { useProjectTree.setState({ root: ROOT }); useProject.setState({ - project: { name: "p", root: ROOT, isGit: false, settings: {} } as never, + project: { name: "p", root: ROOT, trusted: true, isGit: false, settings: {} } as never, settings: {} as never, }); disposers = [ diff --git a/src/features/project-tree/roots.test.ts b/src/features/project-tree/roots.test.ts index b4f1885b..ed604c6f 100644 --- a/src/features/project-tree/roots.test.ts +++ b/src/features/project-tree/roots.test.ts @@ -69,7 +69,7 @@ describe("creating packages", () => { setProjectFsForTests(fs, "/p"); initProjectTree("/p", "p"); useProject.setState({ - project: { name: "p", root: "/p", isGit: false, settings: {} } as never, + project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never, settings: {} as never, }); useProjectRoots.setState({ root: "/p", detected: MAVEN }); diff --git a/src/features/project-trust/index.tsx b/src/features/project-trust/index.tsx new file mode 100644 index 00000000..a00226cb --- /dev/null +++ b/src/features/project-trust/index.tsx @@ -0,0 +1,218 @@ +import { useEffect, useState } from "react"; +import { create } from "zustand"; +import { executeAction, registerActions } from "@/platform/actions"; +import { call, commands, onIdeEvent, type TrustDecisions } from "@/platform/ipc"; +import { dirname, useProject } from "@/platform/project"; +import { initializeRemoteTrustMigration } from "@/platform/remote"; +import { provideProjectTrustPrompt } from "@/platform/projectTrust"; +import { registerOverlay, registerSettingsPage, registerWorkbenchBanner } from "@/workbench/contributions"; +import { Button, Checkbox, Dialog, T } from "@/ui"; +import styles from "./trust.module.css"; + +const usePrompt = create<{ path: string | null; folder: boolean; finish: ((open: boolean) => void) | null }>(() => ({ + path: null, + folder: false, + finish: null, +})); + +/** One line; a long path loses its middle, so the final folder stays visible. */ +function PathLine({ path }: { path: string }) { + const cut = Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")); + return ( + + {path.slice(0, Math.max(cut, 0))} + {path.slice(Math.max(cut, 0))} + + ); +} + +function TrustDialog() { + const { path, folder } = usePrompt(); + return ( + void executeAction("project.dontOpen")} + buttons={[ + { + label: "Preview in Restricted Mode", + autoFocus: true, + onClick: () => void executeAction("project.previewRestricted"), + }, + { label: "Don't Open", onClick: () => void executeAction("project.dontOpen") }, + { label: "Trust Project", role: "default", onClick: () => void executeAction("project.trust") }, + ]} + footerStart={ + usePrompt.setState({ folder })} + className={styles.folderOption} + label={ + + + Trust projects in + {" "} + + + } + /> + } + > +
+ +

+ + If you don't trust the source, preview the project in Restricted Mode, where project commands and tools + can't run. + +

+

+ Loading, running or building a project may execute potentially malicious code from its build scripts. +

+
+
+ ); +} + +function RestrictedBanner() { + const restricted = useProject((s) => s.project?.trusted === false); + return restricted ? ( +
+ + Restricted Mode: project commands and tools are disabled. + + +
+ ) : null; +} + +function TrustSettings() { + const [decisions, setDecisions] = useState(null); + const [error, setError] = useState(""); + const refresh = () => + call(commands.projectTrustList()) + .then(setDecisions) + .catch((e: unknown) => setError(String(e))); + useEffect(() => { + void refresh(); + }, []); + const revoke = async (action: string, path: string) => { + try { + await executeAction(action, path); + await refresh(); + } catch (e) { + setError(String(e)); + } + }; + return ( +
+

+ Trust decisions are saved in your user data and take effect immediately. +

+ {error &&

{error}

} +

+ Trusted projects +

+ {decisions?.projects.map((path) => ( +
+ {path} + +
+ ))} +

+ Trusted locations +

+ {decisions?.locations.map((path) => ( +
+ {path} + +
+ ))} +
+ ); +} + +async function decide(trusted: boolean) { + const pending = usePrompt.getState(); + const path = pending.path ?? useProject.getState().project?.root; + if (!path) return; + await call(commands.projectTrustSet(path, trusted, trusted && pending.folder)); + const project = await commands.projectCurrent(); + if (project) useProject.setState({ project, settings: project.settings }); + pending.finish?.(true); +} + +export function activate(): () => void { + initializeRemoteTrustMigration(); + const disposers = [ + provideProjectTrustPrompt(async (path) => { + const status = await call(commands.projectTrustStatus(path)); + if (status.decided) return true; + usePrompt.getState().finish?.(false); + return new Promise((resolve) => + usePrompt.setState({ + path: status.root, + folder: false, + finish: (open) => { + usePrompt.setState({ path: null, finish: null, folder: false }); + resolve(open); + }, + }), + ); + }), + registerOverlay({ id: "project.trustDialog", component: TrustDialog }), + registerWorkbenchBanner({ id: "project.restrictedMode", component: RestrictedBanner }), + registerSettingsPage({ id: "projectTrust", title: "Project Trust", group: "Security", component: TrustSettings }), + registerActions([ + { id: "project.trust", title: "Trust Project", category: "Project", run: () => decide(true) }, + { + id: "project.previewRestricted", + title: "Preview in Restricted Mode", + category: "Project", + hidden: true, + run: () => decide(false), + }, + { + id: "project.dontOpen", + title: "Don't Open", + category: "Project", + hidden: true, + run: () => usePrompt.getState().finish?.(false), + }, + { + id: "project.revokeTrust", + title: "Revoke Trust", + category: "Project", + run: async ({ arg }) => { + const path = typeof arg === "string" ? arg : useProject.getState().project?.root; + if (path) await call(commands.projectTrustSet(path, false, false)); + }, + }, + { + id: "project.removeTrustedLocation", + title: "Remove Trusted Location", + category: "Project", + hidden: true, + run: async ({ arg }) => { + if (typeof arg === "string") await call(commands.projectTrustRemoveLocation(arg)); + }, + }, + ]), + onIdeEvent("projectTrustChanged", (e) => { + const project = useProject.getState().project; + if (project?.root === e.root) useProject.setState({ project: { ...project, trusted: e.trusted } }); + }), + ]; + return () => { + usePrompt.getState().finish?.(false); + disposers.reverse().forEach((dispose) => dispose()); + }; +} diff --git a/src/features/project-trust/trust.module.css b/src/features/project-trust/trust.module.css new file mode 100644 index 00000000..5e419c0d --- /dev/null +++ b/src/features/project-trust/trust.module.css @@ -0,0 +1,57 @@ +.content { + display: flex; + flex-direction: column; + gap: var(--space-3); +} +.content p { + margin: 0; +} +.pathLine { + display: flex; + min-width: 0; + white-space: nowrap; +} +.content > .pathLine { + color: var(--fg-secondary); +} +.pathHead { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} +.pathTail { + flex: none; + max-width: 70%; + overflow: hidden; + text-overflow: ellipsis; +} +.folderOption { + max-width: 100%; +} +.folderLabel { + display: flex; + gap: var(--space-2); + min-width: 0; +} +.folderPrefix { + flex: none; +} +.banner { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + padding: var(--space-2) var(--space-4); + background: var(--bg-elev); + color: var(--fg); + border-bottom: 1px solid var(--border); +} +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} +.path { + overflow-wrap: anywhere; +} diff --git a/src/features/project-trust/trust.test.tsx b/src/features/project-trust/trust.test.tsx new file mode 100644 index 00000000..076d107c --- /dev/null +++ b/src/features/project-trust/trust.test.tsx @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { confirmProjectTrust } from "@/platform/projectTrust"; +import { executeAction } from "@/platform/actions"; +import { emitLocalIdeEvent } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { OverlayHost } from "@/workbench/OverlayHost"; +import { useContributions } from "@/workbench/contributions"; +import { activate } from "./index"; + +const status = vi.fn(); +const decide = vi.fn(); +vi.mock("@/platform/ipc", async (original) => { + const real = await original(); + return { + ...real, + commands: { + ...real.commands, + projectTrustStatus: (path: string) => status(path), + projectTrustSet: (...args: unknown[]) => decide(...args), + projectCurrent: async () => null, + }, + }; +}); +let dispose: () => void; +beforeEach(() => { + status + .mockReset() + .mockImplementation(async (root: string) => ({ status: "ok", data: { root, trusted: false, decided: false } })); + decide.mockReset().mockResolvedValue({ status: "ok", data: null }); + useProject.setState({ project: null }); + dispose = activate(); +}); +afterEach(() => dispose()); + +it("offers three choices, cancels without a trust decision, and supports restricted preview", async () => { + render(); + const cancelled = confirmProjectTrust("/new").catch(() => "cancelled"); + await screen.findByRole("dialog"); + fireEvent.click(screen.getByRole("button", { name: "Don't Open" })); + expect(await cancelled).toBe("cancelled"); + expect(decide).not.toHaveBeenCalled(); + const preview = confirmProjectTrust("/new"); + await screen.findByRole("dialog"); + fireEvent.click(screen.getByRole("button", { name: "Preview in Restricted Mode" })); + await act(async () => preview); + expect(decide).toHaveBeenCalledWith("/new", false, false); +}); + +it("saves the parent-folder choice and reuses a persisted decision", async () => { + render(); + const opening = confirmProjectTrust("/projects/new"); + await screen.findByRole("dialog"); + fireEvent.click(screen.getByRole("checkbox", { name: /Trust projects in/ })); + fireEvent.click(screen.getByRole("button", { name: "Trust Project" })); + await act(async () => opening); + expect(decide).toHaveBeenCalledWith("/projects/new", true, true); + status.mockResolvedValue({ status: "ok", data: { trusted: true, decided: true } }); + await act(async () => confirmProjectTrust("/projects/other")); + expect(screen.queryByRole("dialog")).toBeNull(); +}); + +it("updates the running project's banner after a backend trust transition", async () => { + useProject.setState({ project: { root: "/p", name: "P", isGit: false, trusted: false, settings: {} } }); + const Banner = useContributions.getState().workbenchBanners.get("project.restrictedMode")!.component; + render(); + expect(screen.getByRole("status")).toHaveTextContent("Restricted Mode"); + await act(async () => executeAction("project.trust")); + expect(decide).toHaveBeenCalledWith("/p", true, false); + act(() => emitLocalIdeEvent({ type: "projectTrustChanged", root: "/p", trusted: true })); + expect(screen.queryByRole("status")).toBeNull(); +}); + +it("shows and trusts the canonical folder when opening through a symlink", async () => { + status.mockResolvedValue({ status: "ok", data: { root: "/real/projects/new", trusted: false, decided: false } }); + render(); + const opening = confirmProjectTrust("/aliases/new"); + await screen.findByRole("dialog"); + fireEvent.click(screen.getByRole("checkbox", { name: /Trust projects in \/real\/projects/ })); + fireEvent.click(screen.getByRole("button", { name: "Trust Project" })); + await act(async () => opening); + expect(decide).toHaveBeenCalledWith("/real/projects/new", true, true); +}); diff --git a/src/features/remote-dev/ConnectDialog.tsx b/src/features/remote-dev/ConnectDialog.tsx index 7e8eab8e..3a47357d 100644 --- a/src/features/remote-dev/ConnectDialog.tsx +++ b/src/features/remote-dev/ConnectDialog.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { executeAction } from "@/platform/actions"; import { useTx } from "@/platform/i18n"; -import { call, commands, type RemoteEntry } from "@/platform/ipc"; +import { call, commands, isCancelled, type RemoteEntry } from "@/platform/ipc"; import { useRemoteProgress } from "@/platform/remote"; import { sshLabel } from "@/platform/remoteSettings"; import { useWorkflowSettings } from "@/platform/workflowSettings"; @@ -57,7 +57,7 @@ function ConnectDialog() { if (await openRemoteProject(ssh, target)) closeConnectDialog(); else setBusy(false); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e)); setBusy(false); } }; diff --git a/src/features/remote-dev/remoteDev.test.ts b/src/features/remote-dev/remoteDev.test.ts index 49e0ecf1..68f94a93 100644 --- a/src/features/remote-dev/remoteDev.test.ts +++ b/src/features/remote-dev/remoteDev.test.ts @@ -1,6 +1,14 @@ import { describe, expect, it } from "vitest"; import type { RemoteEntry } from "@/platform/ipc"; -import { forgetRemote, parseRemoteLocation, recentRemotes, rememberRemote, remoteLocation } from "@/platform/remote"; +import { + initializeRemoteTrustMigration, + takeLegacyRemoteTrust, + forgetRemote, + parseRemoteLocation, + recentRemotes, + rememberRemote, + remoteLocation, +} from "@/platform/remote"; import { browsableFolders, listedDirectory, remoteParent } from "./model"; const entry = (path: string, isDir = true): RemoteEntry => ({ @@ -57,3 +65,15 @@ describe("remote project locations", () => { expect(recentRemotes().map((r) => r.path)).toEqual(["/q"]); }); }); + +it("migrates only the original remote recents once, scoped to the saved host", () => { + localStorage.clear(); + rememberRemote({ sshConfig: "a", path: "/old", host: "me@old", name: "old", openedAtMs: 1 }); + initializeRemoteTrustMigration(); + rememberRemote({ sshConfig: "a", path: "/new", host: "me@old", name: "new", openedAtMs: 2 }); + initializeRemoteTrustMigration(); + expect(takeLegacyRemoteTrust("a", "/new", "me@old")).toBe(false); + expect(takeLegacyRemoteTrust("a", "/old", "me@other")).toBe(false); + expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(true); + expect(takeLegacyRemoteTrust("a", "/old", "me@old")).toBe(false); +}); diff --git a/src/features/run/contextRun.test.ts b/src/features/run/contextRun.test.ts index 3d19e40a..69727595 100644 --- a/src/features/run/contextRun.test.ts +++ b/src/features/run/contextRun.test.ts @@ -42,7 +42,7 @@ const labels = (items: Item[]): string[] => describe("run context menu group", () => { beforeEach(() => { - useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never }); + useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never }); useRun.setState({ configs: [npmTest], loaded: true }); }); afterEach(() => { @@ -127,7 +127,7 @@ describe("menuGroupItems", () => { describe("debug entry", () => { beforeEach(() => { - useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never }); + useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never }); useRun.setState({ configs: [], loaded: true }); }); afterEach(() => useProject.setState({ project: null })); @@ -150,7 +150,7 @@ describe("run gutter popup", () => { ); beforeEach(() => { - useProject.setState({ project: { name: "app", root: ROOT, isGit: false, settings: {} } as never }); + useProject.setState({ project: { name: "app", root: ROOT, trusted: true, isGit: false, settings: {} } as never }); useRun.setState({ configs: [npmTest], loaded: true }); }); afterEach(() => { diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 90b0249e..495dcf27 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -516,7 +516,7 @@ export function activate(): () => void { ); }), useProject.subscribe((s, prev) => { - if (s.project?.root === prev.project?.root) return; + if (s.project?.root === prev.project?.root && s.project?.trusted === prev.project?.trusted) return; resetRuns(); useStackTraces.setState({ tabs: [], dialog: null }); if (s.project) void loadConfigs(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 092e1e0f..38a3c1b3 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -164,6 +164,7 @@ function writeSelected(root: string, id: string | null) { } export async function loadConfigs(): Promise { + if (useProject.getState().project?.trusted === false) return; const root = useProject.getState().project?.root; if (!root) { useRun.setState({ configs: [], loaded: false, selectedId: null }); diff --git a/src/features/services/ServicesTree.debug.test.tsx b/src/features/services/ServicesTree.debug.test.tsx index 1904d9f9..1b3cde06 100644 --- a/src/features/services/ServicesTree.debug.test.tsx +++ b/src/features/services/ServicesTree.debug.test.tsx @@ -49,7 +49,7 @@ let dispose: () => void; beforeEach(() => { localStorage.clear(); useProject.setState({ - project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) }, + project: { root: "/services-debug", name: "app", isGit: false, settings: validateSettings({}) } as never, }); useRun.setState({ configs: [config], sessions: [], loaded: true }); useDebug.setState({ sessions: {}, console: {}, active: null }); diff --git a/src/features/services/ServicesTree.reorder.test.tsx b/src/features/services/ServicesTree.reorder.test.tsx index eb91d735..8ec8f36f 100644 --- a/src/features/services/ServicesTree.reorder.test.tsx +++ b/src/features/services/ServicesTree.reorder.test.tsx @@ -23,7 +23,7 @@ const config = (id: string, kind: string): RunConfig => ({ debugAdapter: null, }); function project(root: string) { - useProject.setState({ project: { root, name: "app", isGit: false, settings: validateSettings({}) } }); + useProject.setState({ project: { root, name: "app", trusted: true, isGit: false, settings: validateSettings({}) } }); } let dispose: Array<() => void>; const dockerRoots = [{ key: "docker:one", label: "Docker", icon: "file-docker" }]; diff --git a/src/features/settings/languages/PythonStatus.tsx b/src/features/settings/languages/PythonStatus.tsx index 1d1b884a..e5cf882b 100644 --- a/src/features/settings/languages/PythonStatus.tsx +++ b/src/features/settings/languages/PythonStatus.tsx @@ -67,13 +67,14 @@ async function hasPythonFiles(): Promise { function PythonStatus() { const root = useProject((s) => s.project?.root); + const trusted = useProject((s) => s.project?.trusted !== false); const configured = useProject((s) => s.settings?.languages?.python?.interpreter ?? ""); const status = usePopup((s) => s.status); const open = usePopup((s) => s.open); const anchor = useRef(null); useEffect(() => { reset(); - if (!root || !isTauri()) return; + if (!root || !trusted || !isTauri()) return; let live = true; let sequence = 0; let detected: Promise | undefined; @@ -121,7 +122,7 @@ function PythonStatus() { off.forEach((dispose) => dispose()); reset(); }; - }, [root, configured]); + }, [root, configured, trusted]); // The widget only renders once its project's status is in the store, so the popup action // sees the same state as the button without waiting for an effect to publish a flag. if (!popupShown() || !status) return null; diff --git a/src/features/welcome/ProjectsPage.tsx b/src/features/welcome/ProjectsPage.tsx index b879aab6..a5c21879 100644 --- a/src/features/welcome/ProjectsPage.tsx +++ b/src/features/welcome/ProjectsPage.tsx @@ -14,6 +14,7 @@ import { Kbd, SearchField, Spinner, + isPopupOpen, listNavigate, showContextMenu, T, @@ -263,7 +264,7 @@ export function ProjectsPage() { aria-label="Search projects" aria-controls={`${baseId}-list`} aria-activedescendant={items.length ? optionId(current) : undefined} - autoFocus + autoFocus={!isPopupOpen()} data-autofocus onKeyDown={onKeyDown} className={styles.search} diff --git a/src/features/welcome/recent.test.ts b/src/features/welcome/recent.test.ts index 99b0ac1c..4534d688 100644 --- a/src/features/welcome/recent.test.ts +++ b/src/features/welcome/recent.test.ts @@ -68,9 +68,9 @@ describe("session restore", () => { it("remembers the open project and forgets it on explicit close", () => { const stop = trackSession(); - useProject.setState({ project: { root: "/w/a", name: "a", isGit: false, settings: {} } }); + useProject.setState({ project: { root: "/w/a", name: "a", trusted: true, isGit: false, settings: {} } }); expect(lastSessionProject()).toBe("/w/a"); - useProject.setState({ project: { root: "/w/b", name: "b", isGit: false, settings: {} } }); + useProject.setState({ project: { root: "/w/b", name: "b", trusted: true, isGit: false, settings: {} } }); expect(lastSessionProject()).toBe("/w/b"); useProject.setState({ project: null }); expect(lastSessionProject()).toBeNull(); diff --git a/src/features/welcome/recent.ts b/src/features/welcome/recent.ts index bed647f1..98cc4b4c 100644 --- a/src/features/welcome/recent.ts +++ b/src/features/welcome/recent.ts @@ -1,7 +1,7 @@ /** Recent projects: loading, filtering, opening and removal. */ import { create } from "zustand"; import { executeAction, getAction } from "@/platform/actions"; -import { commands, IpcError, isTauri, type RecentProject } from "@/platform/ipc"; +import { commands, IpcError, isCancelled, isTauri, type RecentProject } from "@/platform/ipc"; import { openProject, useProject } from "@/platform/project"; import { connectRemote, forgetRemote, parseRemoteLocation, recentRemotes, remoteLocation } from "@/platform/remote"; import { tx } from "@/platform/i18n"; @@ -113,6 +113,7 @@ export async function openRecent(project: Pick): await openProject(project.path); return true; } catch (e) { + if (isCancelled(e)) return false; const message = e instanceof Error ? e.message : String(e); const missing = e instanceof IpcError ? e.kind === "notFound" : /not found|no such/i.test(message); notifications().notify({ @@ -138,6 +139,7 @@ async function openRemote( await connectRemote(remote.sshConfig, remote.path); }); } catch (e) { + if (isCancelled(e)) return false; notifications().notify({ level: "error", title: tx`Cannot open ${project.name}`, diff --git a/src/features/welcome/session.ts b/src/features/welcome/session.ts index c772a1d5..2a6002c7 100644 --- a/src/features/welcome/session.ts +++ b/src/features/welcome/session.ts @@ -5,7 +5,7 @@ */ import { getCurrentWindow } from "@tauri-apps/api/window"; import { create } from "zustand"; -import { commands, isTauri, type Settings } from "@/platform/ipc"; +import { commands, isCancelled, isTauri, type Settings } from "@/platform/ipc"; import { openProject, startupLaunchHandled, useProject } from "@/platform/project"; import { connectRemote, parseRemoteLocation, projectLocation } from "@/platform/remote"; import { notifications } from "@/workbench/contributions"; @@ -123,6 +123,7 @@ async function reopen(path: string) { } catch (e) { write(SESSION_KEY, null); windowProject(null); + if (isCancelled(e)) return; notifications().notify({ level: "warning", title: `Cannot reopen ${name}`, diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 88591de9..52e650e9 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,22 @@ { + "Project opening cancelled": "已取消打开项目", + "Project is in Restricted Mode": "项目处于受限模式", + "Project is in Restricted Mode. Trust the project to enable execution.": "项目处于受限模式。信任项目后可启用执行功能。", + "Trust and Open Project?": "信任并打开项目?", + "Preview in Restricted Mode": "以受限模式预览", + "Don't Open": "不打开", + "If you don't trust the source, preview the project in Restricted Mode, where project commands and tools can't run.": "如果不信任来源,请以受限模式预览项目,此模式下不会运行项目命令和工具。", + "Loading, running or building a project may execute potentially malicious code from its build scripts.": "加载、运行或构建项目可能会执行其构建脚本中的恶意代码。", + "Trust projects in": "信任此文件夹中的项目:", + "Restricted Mode: project commands and tools are disabled.": "受限模式:项目命令和工具已禁用。", + "Trust Project": "信任项目", + "Trust decisions are saved in your user data and take effect immediately.": "信任决定保存在您的用户数据中,并立即生效。", + "Trusted projects": "受信任的项目", + "Trusted locations": "受信任的位置", + "Revoke Trust": "撤销信任", + "Project Trust": "项目信任", + "Security": "安全", + "Remove Trusted Location": "移除受信任的位置", "Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块", "Attach": "附加", "Remove from Workspace": "从工作区移除", diff --git a/src/platform/project.ts b/src/platform/project.ts index 81a94d7a..014996ec 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -1,4 +1,5 @@ /** The project open in this window. */ +import { confirmProjectTrust } from "./projectTrust"; import { create } from "zustand"; import { call, commands, type ProjectInfo, type Settings } from "./ipc"; import { isVirtualUri, virtualContainer, virtualName, virtualParent } from "./virtualDocs"; @@ -48,6 +49,7 @@ export function startupLaunchHandled(): Promise { } export async function openProject(path: string): Promise { + await confirmProjectTrust(path); useProject.setState({ opening: true }); try { const project = await call(commands.openProject(path)); diff --git a/src/platform/projectRoots.test.ts b/src/platform/projectRoots.test.ts index 4ad0b30a..3f6ff72d 100644 --- a/src/platform/projectRoots.test.ts +++ b/src/platform/projectRoots.test.ts @@ -86,7 +86,7 @@ describe("effective roots", () => { describe("project roots store", () => { beforeEach(() => { useProject.setState({ - project: { name: "p", root: "/p", isGit: false, settings: {} } as never, + project: { name: "p", root: "/p", trusted: true, isGit: false, settings: {} } as never, settings: { projectStructure: { excluded: ["out"] } } as never, }); useProjectRoots.setState({ root: "/p", detected: DETECTED }); @@ -122,6 +122,7 @@ it("lists workspace members as scopes and admits their files without admitting w root: "/workspace", name: "Workspace", settings: {}, + trusted: true, isGit: false, workspace: { version: 1, name: "Workspace", projects: ["/z", "/a"] }, }, diff --git a/src/platform/projectTrust.ts b/src/platform/projectTrust.ts new file mode 100644 index 00000000..58a0252f --- /dev/null +++ b/src/platform/projectTrust.ts @@ -0,0 +1,16 @@ +import { IpcError } from "./ipc"; + +let prompt: ((path: string) => Promise) | null = null; + +export function provideProjectTrustPrompt(value: (path: string) => Promise): () => void { + prompt = value; + return () => { + if (prompt === value) prompt = null; + }; +} + +export async function confirmProjectTrust(path: string): Promise { + if (prompt && !(await prompt(path))) { + throw new IpcError({ kind: "cancelled", message: "Project opening cancelled" }); + } +} diff --git a/src/platform/remote.ts b/src/platform/remote.ts index 7d62446f..5ebe1975 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -1,3 +1,4 @@ +import { confirmProjectTrust } from "./projectTrust"; /** * Remote development: in a window whose project lives on another machine, the * commands that machine's `openidea-server` answers are routed to it (through @@ -138,6 +139,19 @@ export async function connectRemote(sshConfig: string, path: string) { try { const r = await call(commands.remoteConnect(sshConfig, path, onProgress)); enableRemote({ sshConfig: r.sshConfig, root: r.root, host: r.host, commands: r.commands }); + if (takeLegacyRemoteTrust(r.sshConfig, path, r.host)) { + const status = await call(commands.projectTrustStatus(r.root)); + if (!status.decided) await call(commands.projectTrustSet(r.root, true, false)); + } + try { + await confirmProjectTrust(r.root); + } catch (error) { + await commands.closeProject(); + disableRemote(); + throw error; + } + const current = await commands.projectCurrent(); + if (current) r.project = current; r.project.settings = await clientSettings(r.project.settings); useProject.setState({ project: r.project, settings: r.project.settings }); rememberRemote({ @@ -197,6 +211,32 @@ export interface RecentRemote { const RECENT_KEY = "openidea.remote.recent"; const RECENT_MAX = 20; +const TRUST_MIGRATION_KEY = "openidea.remote.trustMigration"; + +/** Snapshot existing user-owned recents before any new connection is remembered. */ +export function initializeRemoteTrustMigration() { + try { + if (localStorage.getItem(TRUST_MIGRATION_KEY) === null) { + localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(recentRemotes())); + } + } catch { + /* Unavailable storage never grants trust. */ + } +} + +export function takeLegacyRemoteTrust(sshConfig: string, path: string, host: string): boolean { + try { + const value: unknown = JSON.parse(localStorage.getItem(TRUST_MIGRATION_KEY) ?? "[]"); + if (!Array.isArray(value)) return false; + const matches = (entry: RecentRemote) => + entry?.sshConfig === sshConfig && entry.path === path && entry.host === host; + const found = value.some(matches); + localStorage.setItem(TRUST_MIGRATION_KEY, JSON.stringify(value.filter((entry) => !matches(entry)))); + return found; + } catch { + return false; + } +} export function recentRemotes(): RecentRemote[] { try { diff --git a/src/platform/remoteSettings.test.ts b/src/platform/remoteSettings.test.ts index 1de70988..4c21fde1 100644 --- a/src/platform/remoteSettings.test.ts +++ b/src/platform/remoteSettings.test.ts @@ -80,7 +80,9 @@ describe("remote client settings", () => { commands: [...served], project: { root: "/remote", name: "remote", settings: remote }, } - : local, + : cmd === "remote_invoke" + ? "null" + : local, ); await connectRemote("test", "/remote"); expect(useProject.getState().settings).toMatchObject({ keymap: "my-map", editor: { tabSize: 2, fontSize: 19 } }); diff --git a/src/styles/tokens.css b/src/styles/tokens.css index 66ffc23e..ac12c4d8 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -15,6 +15,9 @@ html { --fw-code-bold: 700; } :root { + --space-2: 8px; + --space-3: 12px; + --space-4: 16px; /* surfaces */ --bg-app: #191a1c; --bg-elev: #212326; diff --git a/src/ui/Dialog.module.css b/src/ui/Dialog.module.css index 690c2dda..f4fdeea0 100644 --- a/src/ui/Dialog.module.css +++ b/src/ui/Dialog.module.css @@ -231,6 +231,11 @@ .messageIcon { flex: none; } +.messageAppIcon { + flex: none; + width: 64px; + height: 64px; +} .messageShape { fill: var(--message-icon-color); } diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx index 8f876ec4..4d0423d9 100644 --- a/src/ui/Dialog.tsx +++ b/src/ui/Dialog.tsx @@ -7,6 +7,7 @@ import { Icon } from "./icons"; import { FOCUSABLE, onWindowDeactivate, openLayer, trapTab } from "./popupState"; import { useTx } from "@/platform/i18n"; import { selectableText } from "./selectableText"; +import appIcon from "../../src-tauri/icons/128x128.png"; import styles from "./Dialog.module.css"; import { isImeKey } from "@/platform/ime"; @@ -135,7 +136,7 @@ export interface DialogProps { * A Messages alert (IDEA `MessageDialogBuilder`): no title bar, this icon on the * left, the title in bold above the content, `footerStart` under the text. */ - icon?: MessageIconKind; + icon?: MessageIconKind | "app"; } export type MessageIconKind = "question" | "info" | "warning" | "error"; @@ -398,7 +399,11 @@ function DialogInner({ )} {icon ? (
- + {icon === "app" ? ( + + ) : ( + + )}
{title != null &&
{title}
}
diff --git a/src/workbench/Workbench.test.tsx b/src/workbench/Workbench.test.tsx index 2178895e..67ff7478 100644 --- a/src/workbench/Workbench.test.tsx +++ b/src/workbench/Workbench.test.tsx @@ -328,7 +328,11 @@ describe("workbench shell", () => { useProject.setState({ project: null }); const { unmount } = render(); expect(screen.getByRole("button", { name: /Open/ })).toBeInTheDocument(); - act(() => useProject.setState({ project: { root: "/shell", name: "shell-demo", isGit: false, settings: {} } })); + act(() => + useProject.setState({ + project: { root: "/shell", name: "shell-demo", trusted: true, isGit: false, settings: {} }, + }), + ); loadToolWindowLayout("/shell"); expect(await screen.findByText("shell-demo")).toBeInTheDocument(); expect(screen.getByText("project view")).toBeInTheDocument(); diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index f771087d..56c55c83 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -17,6 +17,7 @@ import { useApplyTheme } from "./theme"; import { ToolWindowLayout } from "./toolwindows/ToolWindows"; import { Welcome } from "./Welcome"; import { WindowResizeHandles } from "./WindowResizeHandles"; +import { useContributions } from "./contributions"; import styles from "./Workbench.module.css"; /** LightEdit: editor tabs only; no tool windows, toolbar widgets or project status items. */ @@ -38,6 +39,7 @@ function LightEditFrame() { export function Workbench() { useApplyTheme(); + const banners = useContributions((s) => s.workbenchBanners); const project = useProject((s) => s.project); const lightEdit = useProject((s) => s.lightEdit); const root = project?.root ?? null; @@ -54,6 +56,9 @@ export function Workbench() { {/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */} {!distractionFree && } + {[...banners.values()].map(({ id, component: Banner }) => ( + + ))} } /> {!distractionFree && } diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 9d54b12d..9348884f 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -264,7 +264,13 @@ export interface EditorInput { data?: unknown; } +export interface WorkbenchBannerContribution { + id: string; + component: ComponentType; +} + interface Contributions { + workbenchBanners: Map; toolWindows: Map; statusItems: Map; overlays: Map; @@ -274,6 +280,7 @@ interface Contributions { } export const useContributions = create(() => ({ + workbenchBanners: new Map(), toolWindows: new Map(), statusItems: new Map(), overlays: new Map(), @@ -301,6 +308,7 @@ function register( }); } +export const registerWorkbenchBanner = (c: WorkbenchBannerContribution) => register("workbenchBanners", c.id, c); export const registerToolWindow = (c: ToolWindowContribution) => register("toolWindows", c.id, c); export const registerStatusItem = (c: StatusItemContribution) => register("statusItems", c.id, c); export const registerOverlay = (c: OverlayContribution) => register("overlays", c.id, c); diff --git a/src/workbench/devSamples.tsx b/src/workbench/devSamples.tsx index 87978743..cdf9132c 100644 --- a/src/workbench/devSamples.tsx +++ b/src/workbench/devSamples.tsx @@ -9,7 +9,7 @@ import { useProject } from "@/platform/project"; export function registerDevSamples(): () => void { if (!useProject.getState().project) { useProject.setState({ - project: { root: "/dev/acme-api", name: "acme-api", isGit: true, settings: {} as never }, + project: { root: "/dev/acme-api", name: "acme-api", trusted: true, isGit: true, settings: {} as never }, }); } return () => {}; diff --git a/src/workbench/distractionFree.test.tsx b/src/workbench/distractionFree.test.tsx index 8d85c547..295dcfac 100644 --- a/src/workbench/distractionFree.test.tsx +++ b/src/workbench/distractionFree.test.tsx @@ -201,7 +201,11 @@ describe("distraction free shell", () => { disposers.push( registerStatusItem({ id: "t.status", align: "left", priority: 0, component: () => status-x }), ); - act(() => useProject.setState({ project: { root: "/work/df", name: "df-demo", isGit: false, settings: {} } })); + act(() => + useProject.setState({ + project: { root: "/work/df", name: "df-demo", trusted: true, isGit: false, settings: {} }, + }), + ); const { unmount } = render(); expect(await screen.findByText("df-demo")).toBeInTheDocument(); expect(screen.getByText("status-x")).toBeInTheDocument(); diff --git a/src/workbench/projectWindow.test.ts b/src/workbench/projectWindow.test.ts index efbb2ecc..32053d9e 100644 --- a/src/workbench/projectWindow.test.ts +++ b/src/workbench/projectWindow.test.ts @@ -18,7 +18,7 @@ vi.mock("@/platform/ipc", () => ({ beforeEach(() => { vi.clearAllMocks(); useProject.setState({ - project: { root: "/a", name: "A", settings: {}, isGit: false }, + project: { root: "/a", name: "A", settings: {}, trusted: true, isGit: false }, settings: {}, lightEdit: false, });