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
This commit is contained in:
l0ng-ai
2026-10-31 20:58:45 +08:00
committed by GitHub
parent cf51b57924
commit 2c9a6470ff
103 changed files with 1996 additions and 168 deletions
+4
View File
@@ -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<crate::IpcPath>,
+11 -1
View File
@@ -44,6 +44,7 @@ struct State {
project: Changes,
/// Project roots whose `postStartCommand` ran in this process.
post_started: HashSet<PathBuf>,
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
}
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)
+21
View File
@@ -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<LanguageRegistry>,
@@ -67,6 +69,7 @@ pub struct Host {
/// One project per window, keyed by window label.
projects: RwLock<HashMap<String, Arc<Project>>>,
requests: requests::Requests,
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
}
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);
}
+18
View File
@@ -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;
+12
View File
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
}
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
host.require_trust(window)?;
let mut providers = host.project(window)?.agents.providers();
ide_agent::registry::mark_updates(&registry_paths(host), &mut providers);
Ok(providers)
@@ -88,6 +89,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
}
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
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<u32>) -> IdeResult<AgentTask> {
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<Option<AgentPrompt>> {
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<AgentProbe> {
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<String>)
/// Answers a permission request; `option: None` cancels it.
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> 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<Vec<AgentChange>> {
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<Option<String>> {
host.require_trust(window)?;
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
}
+5 -2
View File
@@ -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<BuildModel> {
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<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
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<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
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 {
+3 -1
View File
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + 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<CoveragePrepared> {
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<CoverageSuite> {
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
host.require_trust(window)?;
blocking(move || ide_coverage::collect(&report)).await
}
+4 -1
View File
@@ -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<DebugSessionId> {
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<u16> {
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);
+1 -1
View File
@@ -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<Host>, window: &str) -> IdeResult<EnvReloaded> {
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
+4 -1
View File
@@ -20,7 +20,8 @@ pub async fn java_resolve_launch(
build: bool,
request: RequestId,
) -> IdeResult<JavaLaunchInfo> {
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<u16> {
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<ide_lsp::jdtls::JavaBuildStatus> {
host.require_trust(window)?;
let project = host.project(window)?;
let module = project.root.join(module);
project.lsp.java_build(&module, &CancellationToken::new()).await
+3 -1
View File
@@ -54,6 +54,7 @@ pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<Notebook
}
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
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<NotebookOutputEvent>,
) -> IdeResult<NotebookSnapshot> {
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())?;
+2
View File
@@ -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 {
+3
View File
@@ -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<Vec<RunConfig>> {
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<OutputChunk>) -> IdeResult<RunInfo> {
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;
+4
View File
@@ -50,6 +50,7 @@ pub async fn run_test_reports(
}
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
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<Vec<ide_build::web::WebArtifact>> {
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<Vec<id
}
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
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"));
+15 -8
View File
@@ -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<Vec<Sdk>> {
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<Option<ide_core::IpcPath>> {
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<Option<ide_c
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
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<Vec<i
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
pub async fn sdk_locate_tools(
_host: &Host,
_window: &str,
host: &Host,
window: &str,
names: Vec<String>,
near: Option<PathBuf>,
) -> IdeResult<LocatedTools> {
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<String>,
) -> IdeResult<Sdk> {
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<Vec<PythonPackage>> {
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<String>,
) -> 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);
+17 -5
View File
@@ -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<String>) -> IdeResult<TerminalInfo> {
pub fn spawn(
host: &Host,
window: &str,
mut options: TerminalOptions,
on_output: Emit<String>,
) -> IdeResult<TerminalInfo> {
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"));
+1
View File
@@ -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<Tools> {
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()),
+3 -1
View File
@@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
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"));
+31 -1
View File
@@ -259,6 +259,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
}
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> 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<PathBuf>,
destination: IgnoreDestination,
) -> IdeResult<Vec<String>> {
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<PathBuf>) -> 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<PathBuf>) -> 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<String> {
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<ide_vcs::CheckoutOptions>,
) -> IdeResult<ide_vcs::CheckoutOutcome> {
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<String>,
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<PathBuf>, commit: String) -> IdeResult<String> {
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<PathBuf>,
branch: String,
) -> IdeResult<ide_vcs::DeletedBranch> {
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<PathBuf>,
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<PathBuf>, 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<PathBuf>, 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<String>,
) -> 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<PathBuf>, 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<String>,
) -> 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<u32>) -> 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<GitExecutableInfo> {
pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult<GitExecutableInfo> {
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<String>,
) -> 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<String>,
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<String>,
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<PathBuf>) -> Id
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, 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<String>,
) -> 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<PathBuf>,
patch: String,
) -> IdeResult<ApplyOutcome> {
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<PathBuf>) -> IdeResult<ShelvedChangeList> {
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<String>,
) -> IdeResult<ApplyOutcome> {
host.require_trust(window)?;
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(None, Some("Unshelve"), |r| {
+38 -4
View File
@@ -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<AtomicBool>,
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<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
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<AtomicBool>,
remote: Arc<ide_remote::RemoteService>,
settings: Arc<SettingsStore>,
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 {
+11 -1
View File
@@ -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::<PathBuf>("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::<PathBuf>("path")?, c.arg("trusted")?, c.arg("folder")?).await
})
.add("project_trust_remove_location", |c| async move {
c.host.trust.remove_location(&c.arg::<PathBuf>("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)
+310
View File
@@ -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<PathBuf>,
pub locations: Vec<PathBuf>,
pub restricted: Vec<PathBuf>,
}
pub struct TrustStore {
file: PathBuf,
state: Mutex<IdeResult<TrustDecisions>>,
}
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<TrustDecisions> {
self.state.lock().clone()
}
pub fn trusted(&self, path: &Path) -> IdeResult<bool> {
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<TrustStatus> {
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 })
}
}
+1
View File
@@ -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() {
+4
View File
@@ -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")));
+15 -1
View File
@@ -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()}))
+4
View File
@@ -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();
+244
View File
@@ -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<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: 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;
}
+36
View File
@@ -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");
}
+1
View File
@@ -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() {
+1
View File
@@ -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 {
+1
View File
@@ -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());
+39 -1
View File
@@ -174,6 +174,7 @@ pub struct LspManager {
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
shutting_down: AtomicBool,
execution_allowed: AtomicBool,
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
shown_messages: Mutex<HashSet<(String, String)>>,
/// 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<LanguageServerConfig> {
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<Server>) {
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<Arc<Server>> = self.servers.lock().clone();
+33 -1
View File
@@ -92,7 +92,10 @@ impl LoadedPlugin {
}
}
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
pub struct PluginHost {
project_policy: RwLock<ProjectPolicy>,
engine: PluginEngine,
plugins_dir: PathBuf,
sink: Arc<dyn PluginEventSink>,
@@ -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 `<root>/.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<String> = self
@@ -448,7 +475,12 @@ impl PluginHost {
// ---- internals ----
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
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 {
+36
View File
@@ -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());
}
+15 -1
View File
@@ -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);
+20
View File
@@ -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() {
+19 -1
View File
@@ -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<Command> {
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")
+99 -1
View File
@@ -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<LineHunk> {
/// 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<repo::Cache>,
@@ -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<Vec<FileStatus>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
status::status(self)
}
pub fn branch_info(&self) -> IdeResult<BranchInfo> {
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<String>, Option<String>)> {
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<Option<String>> {
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<TextEncoding>,
) -> IdeResult<Option<String>> {
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<Option<Vec<u8>>> {
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<TextEncoding>,
) -> 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<TextEncoding>,
) -> IdeResult<Vec<LineHunk>> {
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<TextEncoding>,
) -> IdeResult<Vec<BlameLine>> {
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: <name>`.
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
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<Vec<ChangedFile>> {
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<Vec<RevisionChange>> {
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<Vec<Branch>> {
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<Vec<Stash>> {
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<TextEncoding>) -> IdeResult<String> {
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 <key>` (all scopes); `None` when unset.
pub fn config_get(&self, key: &str) -> IdeResult<Option<String>> {
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<E>,
) -> 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<String> {
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<E>,
) -> IdeResult<String> {
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<CheckoutOutcome> {
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<String> {
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<DeletedBranch> {
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 <remote> --delete <branch>`); `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 --<mode>`).
@@ -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<Vec<String>> {
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<Vec<ChangedFile>> {
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<Vec<ChangedFile>> {
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<Vec<(String, u32, u32)>> {
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<String> {
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<String> {
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<Vec<PatchFile>> {
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<ApplyOutcome> {
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<ShelvedChangeList> {
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<ApplyOutcome> {
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<MergeSides> {
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<TextEncoding>) -> IdeResult<MergeVersions> {
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<TextEncoding>,
) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
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<Option<String>> {
@@ -965,3 +1041,25 @@ impl Repo {
Ok((stage(1)?, stage(2)?, stage(3)?))
}
}
thread_local! { static EXECUTION_ALLOWED: std::cell::Cell<bool> = 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);
}
}
+7
View File
@@ -210,6 +210,7 @@ pub(crate) fn parse_worktrees(text: &str) -> Vec<Worktree> {
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<Vec<Worktree>> {
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<String> = 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 {
+36 -10
View File
@@ -70,16 +70,9 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
/// 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<String> {
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<String> {
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<Vec<String>> {
/// Tracked files that differ between `rev` and the working tree (Compare with Local).
pub(crate) fn diff_with_worktree(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
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<String> =
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<Vec<ChangedFile>> {
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()))
}
+10 -1
View File
@@ -40,12 +40,14 @@ pub(crate) fn parse_remotes(text: &str) -> Vec<Remote> {
impl Repo {
/// Configured remotes with their fetch URLs.
pub fn remotes(&self) -> IdeResult<Vec<Remote>> {
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<Option<String>> {
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<Vec<ChangedFile>> {
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()))
}
}
+14 -1
View File
@@ -47,7 +47,13 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
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<PathBuf> {
@@ -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<gix::Repository> {
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<String> {
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);
+36 -6
View File
@@ -16,6 +16,7 @@ const UNCOMMITTED_AUTHOR: &str = "Not Committed Yet";
impl Repo {
pub(crate) fn rel_paths(&self, paths: &[PathBuf]) -> IdeResult<Vec<String>> {
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<bool> {
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<BlameLine> {
pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String> {
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<Vec<String>> {
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())
}
+51
View File
@@ -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");
}
+38
View File
@@ -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<ide_host::trust::TrustStatus> {
state.trust.status(&path)
}
#[tauri::command]
#[specta::specta]
pub fn project_trust_list(state: State<'_, AppState>) -> IdeResult<ide_host::trust::TrustDecisions> {
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(())
}
+6 -2
View File
@@ -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<CoverageSuite> {
ide_host::ops::coverage::coverage_collect(&state.host, "", report).await
pub async fn coverage_collect(
window: Window,
state: State<'_, AppState>,
report: CoverageReportSpec,
) -> IdeResult<CoverageSuite> {
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,
+4
View File
@@ -64,6 +64,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
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,
+9 -1
View File
@@ -36,8 +36,16 @@ fn deliver(client: Option<&str>, event: PluginEvent) {
}
/// Creates the host over `<app-data>/plugins` and loads installed plugins in the background.
pub fn create_host(_app: &AppHandle, data_dir: &Path) -> IdeResult<Arc<PluginHost>> {
pub fn create_host(
_app: &AppHandle,
data_dir: &Path,
project_host: &Arc<ide_host::Host>,
) -> IdeResult<Arc<PluginHost>> {
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)
+88 -64
View File
@@ -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<Mutex<Option<Vec<PathBuf>>>> = 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::<Vec<_>>(), &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::<AppState>() {
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::<WindowsState>() {
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::<Vec<_>>(), &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::<WindowsState>() {
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::<AppState>() {
state.close_project(window.label());
}
if let Some(w) = window.try_state::<WindowsState>() {
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::<WindowsState>().is_some() {
windows::open_paths(app, paths);
} else {
early_opens.lock().get_or_insert_default().extend(paths);
}
if let Some(state) = window.try_state::<AppState>() {
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::<AppState>() {
state.host.shutdown_notebooks_on_exit();
}
// Cleanup must finish before update/restart replaces this process.
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
tauri::process::restart(&app.env());
}
if let Some(w) = window.try_state::<WindowsState>() {
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::<WindowsState>().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::<AppState>() {
state.host.shutdown_notebooks_on_exit();
}
// Cleanup must finish before update/restart replaces this process.
if app.try_state::<WindowsState>().is_some_and(|w| w.restart_requested()) {
tauri::process::restart(&app.env());
}
}
});
}
fn window_state_flags() -> tauri_plugin_window_state::StateFlags {
+22
View File
@@ -53,6 +53,28 @@ type Channels = Arc<Mutex<HashMap<u64, (Channel<ChannelFrame>, u64)>>>;
#[derive(Default)]
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
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<ide_host::ops::project::ProjectInfo> = 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<Arc<Connection>> {
self.0.lock().get(window).cloned()
+1 -1
View File
@@ -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,
+20 -1
View File
@@ -11,6 +11,10 @@ export const commands = {
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
projectTrustStatus: (path: string) => typedError<TrustStatus, IdeError>(__TAURI_INVOKE("project_trust_status", { path })),
projectTrustList: () => typedError<TrustDecisions, IdeError>(__TAURI_INVOKE("project_trust_list")),
projectTrustSet: (path: string, trusted: boolean, folder: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_set", { path, trusted, folder })),
projectTrustRemoveLocation: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("project_trust_remove_location", { path })),
workspaceCreate: (path: string, name: string, projects: string[]) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_create", { path, name, projects })),
workspaceAttach: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_attach", { path })),
workspaceUpdate: (name: string, projects: string[]) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("workspace_update", { name, projects })),
@@ -28,6 +32,7 @@ export const commands = {
lightEditOpen: (path: string) => typedError<Settings_Serialize, IdeError>(__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`),
+3 -2
View File
@@ -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 = [
@@ -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"] },
},
+1 -1
View File
@@ -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,
});
});
@@ -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;
+1 -1
View File
@@ -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,
+6 -1
View File
@@ -165,7 +165,12 @@ export async function connect(): Promise<void> {
};
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;
+4 -2
View File
@@ -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"] },
+2 -1
View File
@@ -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) }),
+1 -1
View File
@@ -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();
}),
+1 -1
View File
@@ -158,7 +158,7 @@ export async function loadModel(): Promise<void> {
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([
+2 -2
View File
@@ -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,
@@ -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) {
+2 -1
View File
@@ -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 {
+2 -1
View File
@@ -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<void> {
}
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(
+1 -1
View File
@@ -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);
+3
View File
@@ -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],
+3 -1
View File
@@ -90,6 +90,7 @@ const sdkKey = (s: ReturnType<typeof useProject.getState>) => {
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;
+1 -1
View File
@@ -70,7 +70,7 @@ export function activate(): () => void {
startPluginBridge(),
// Dev plugins (`<project>/.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();
@@ -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"] },
},
@@ -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 = [
+1 -1
View File
@@ -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 });
+218
View File
@@ -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 (
<span className={styles.pathLine} title={path}>
<span className={styles.pathHead}>{path.slice(0, Math.max(cut, 0))}</span>
<span className={styles.pathTail}>{path.slice(Math.max(cut, 0))}</span>
</span>
);
}
function TrustDialog() {
const { path, folder } = usePrompt();
return (
<Dialog
open={!!path}
icon="app"
width={560}
title="Trust and Open Project?"
onClose={() => 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={
<Checkbox
checked={folder}
onChange={(folder) => usePrompt.setState({ folder })}
className={styles.folderOption}
label={
<span className={styles.folderLabel}>
<span className={styles.folderPrefix}>
<T>Trust projects in</T>
</span>{" "}
<PathLine path={path ? dirname(path) : ""} />
</span>
}
/>
}
>
<div className={styles.content}>
<PathLine path={path ?? ""} />
<p>
<T>
If you don't trust the source, preview the project in Restricted Mode, where project commands and tools
can't run.
</T>
</p>
<p>
<T>Loading, running or building a project may execute potentially malicious code from its build scripts.</T>
</p>
</div>
</Dialog>
);
}
function RestrictedBanner() {
const restricted = useProject((s) => s.project?.trusted === false);
return restricted ? (
<div className={styles.banner} role="status">
<span>
<T>Restricted Mode: project commands and tools are disabled.</T>
</span>
<Button onClick={() => void executeAction("project.trust")}>
<T>Trust Project</T>
</Button>
</div>
) : null;
}
function TrustSettings() {
const [decisions, setDecisions] = useState<TrustDecisions | null>(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 (
<div className={styles.content}>
<p>
<T>Trust decisions are saved in your user data and take effect immediately.</T>
</p>
{error && <p role="alert">{error}</p>}
<h3>
<T>Trusted projects</T>
</h3>
{decisions?.projects.map((path) => (
<div className={styles.row} key={path}>
<span className={styles.path}>{path}</span>
<Button onClick={() => void revoke("project.revokeTrust", path)}>
<T>Revoke Trust</T>
</Button>
</div>
))}
<h3>
<T>Trusted locations</T>
</h3>
{decisions?.locations.map((path) => (
<div className={styles.row} key={path}>
<span className={styles.path}>{path}</span>
<Button onClick={() => void revoke("project.removeTrustedLocation", path)}>
<T>Remove</T>
</Button>
</div>
))}
</div>
);
}
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<boolean>((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());
};
}
@@ -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;
}
+83
View File
@@ -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<typeof import("@/platform/ipc")>();
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(<OverlayHost />);
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(<OverlayHost />);
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(<Banner />);
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(<OverlayHost />);
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);
});
+2 -2
View File
@@ -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);
}
};
+21 -1
View File
@@ -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);
});
+3 -3
View File
@@ -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(() => {
+1 -1
View File
@@ -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();
+1
View File
@@ -164,6 +164,7 @@ function writeSelected(root: string, id: string | null) {
}
export async function loadConfigs(): Promise<void> {
if (useProject.getState().project?.trusted === false) return;
const root = useProject.getState().project?.root;
if (!root) {
useRun.setState({ configs: [], loaded: false, selectedId: null });
@@ -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 });
@@ -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" }];
@@ -67,13 +67,14 @@ async function hasPythonFiles(): Promise<boolean> {
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<HTMLButtonElement>(null);
useEffect(() => {
reset();
if (!root || !isTauri()) return;
if (!root || !trusted || !isTauri()) return;
let live = true;
let sequence = 0;
let detected: Promise<Sdk[]> | 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;
+2 -1
View File
@@ -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}
+2 -2
View File
@@ -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();
+3 -1
View File
@@ -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<RecentProject, "path" | "name">):
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}`,
+2 -1
View File
@@ -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}`,
+18
View File
@@ -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": "从工作区移除",
+2
View File
@@ -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<void> {
}
export async function openProject(path: string): Promise<ProjectInfo> {
await confirmProjectTrust(path);
useProject.setState({ opening: true });
try {
const project = await call(commands.openProject(path));
+2 -1
View File
@@ -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"] },
},
+16
View File
@@ -0,0 +1,16 @@
import { IpcError } from "./ipc";
let prompt: ((path: string) => Promise<boolean>) | null = null;
export function provideProjectTrustPrompt(value: (path: string) => Promise<boolean>): () => void {
prompt = value;
return () => {
if (prompt === value) prompt = null;
};
}
export async function confirmProjectTrust(path: string): Promise<void> {
if (prompt && !(await prompt(path))) {
throw new IpcError({ kind: "cancelled", message: "Project opening cancelled" });
}
}
+40
View File
@@ -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 {
+3 -1
View File
@@ -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 } });
+3
View File
@@ -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;
+5
View File
@@ -231,6 +231,11 @@
.messageIcon {
flex: none;
}
.messageAppIcon {
flex: none;
width: 64px;
height: 64px;
}
.messageShape {
fill: var(--message-icon-color);
}
+7 -2
View File
@@ -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 ? (
<div className={styles.message} data-dialog-body="" onPointerDown={drag}>
<MessageIcon kind={icon} />
{icon === "app" ? (
<img className={styles.messageAppIcon} src={appIcon} alt="" draggable={false} />
) : (
<MessageIcon kind={icon} />
)}
<div className={styles.messageText}>
{title != null && <div className={clsx(styles.messageTitle, selectableText)}>{title}</div>}
<div className={clsx(styles.messageBody, selectableText)}>
+5 -1
View File
@@ -328,7 +328,11 @@ describe("workbench shell", () => {
useProject.setState({ project: null });
const { unmount } = render(<Workbench />);
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();
+5
View File
@@ -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. */}
<TitleBar bare={distractionFree} />
{!distractionFree && <WindowGroupTabs />}
{[...banners.values()].map(({ id, component: Banner }) => (
<Banner key={id} />
))}
<ToolWindowLayout key={root} editor={<EditorArea />} />
{!distractionFree && <StatusBar />}
</>
+8
View File
@@ -264,7 +264,13 @@ export interface EditorInput {
data?: unknown;
}
export interface WorkbenchBannerContribution {
id: string;
component: ComponentType;
}
interface Contributions {
workbenchBanners: Map<string, WorkbenchBannerContribution>;
toolWindows: Map<string, ToolWindowContribution>;
statusItems: Map<string, StatusItemContribution>;
overlays: Map<string, OverlayContribution>;
@@ -274,6 +280,7 @@ interface Contributions {
}
export const useContributions = create<Contributions>(() => ({
workbenchBanners: new Map(),
toolWindows: new Map(),
statusItems: new Map(),
overlays: new Map(),
@@ -301,6 +308,7 @@ function register<K extends keyof Contributions>(
});
}
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);

Some files were not shown because too many files have changed in this diff Show More