Файли
openidea/crates/ide-host/src/plugins/api.rs
T
Arnaud Ritti 34c8f87ba7 fix(test): isolate plugin process environment rejection fixture (#2059)
The plugin process-environment test looked for Git only on a Windows user PATH, so it failed before reaching its assertions when Git was on the system PATH. It now pins a temporary, deliberately unspawnable file, so both process APIs must reject environment overrides before anything runs, and it uses the platform's key comparison when picking a second inherited variable.

Co-authored-by: arnaud-ritti <[email protected]>
2026-11-08 05:30:12 +08:00

2427 рядки
98 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
//! Capabilities were checked by the runtime before any of this runs.
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
use ide_plugin::{
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
ProcessChunk, ProcessCommand, ProcessOutput, Prompt, RepositoryStatus, StatusItem,
};
use parking_lot::{Mutex, RwLock};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command as StdCommand, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
/// Largest storage of one plugin (keys + values).
const MAX_STORAGE: usize = 64 << 20;
const MAX_KEY: usize = 256;
/// Most status bar items per plugin.
const MAX_STATUS_ITEMS: usize = 8;
const MAX_CONTEXT_KEYS: usize = 100;
/// Most diff text of a pending commit, all files together.
const MAX_COMMIT_DIFF: usize = 1 << 20;
/// How long a window may take to describe its pending commit (it saves editors first).
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
/// (window or `None` for every window, plugin, key).
type ContextKey = (Option<String>, String, String);
/// What plugins see of the open projects; provided by the app
/// ([`super::PluginHost::set_workspace`]), over [`crate::Host`] on the desktop.
pub trait PluginWorkspace: Send + Sync {
/// Roots of the open projects.
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Whether the editor holds changes for `path` that are not on disk yet;
/// writing such a file would lose them.
fn buffer_dirty(&self, _path: &Path) -> bool {
false
}
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Current index of the project rooted at `root`; never reads disk or calls LSP.
fn symbols(
&self,
_root: &Path,
_query: &str,
_limit: usize,
_cancel: &CancellationToken,
_deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
Err(HostError::NotFound("no project index is available".into()))
}
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
/// `version`, as one change (one undo step in the editor).
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError>;
/// Code from the project may run (git hooks and config on fetch).
fn trusted(&self, root: &Path) -> bool;
/// Root of the project open in `window`.
fn project_of(&self, window: &str) -> Option<PathBuf>;
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
-> Result<(), HostError>;
/// The encoding git content of `path` is read in (the project's file encoding).
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
encoding_rs::UTF_8.into()
}
/// The user's values of `plugin`'s settings: the project's at `root`
/// (over global), or global.
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
serde_json::Map::new()
}
}
/// Where plugin secrets live (the OS keychain on the desktop). Plugins can
/// only ask whether one is set; values go out only inside `net` requests.
pub trait SecretStore: Send + Sync {
fn get(&self, plugin: &str, name: &str) -> Option<String>;
/// Sets, or (`None`) deletes.
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String>;
}
/// Fallback [`SecretStore`]: one file per plugin, readable only by the user.
pub struct FileSecretStore {
pub dir: PathBuf,
}
impl FileSecretStore {
fn file(&self, plugin: &str) -> PathBuf {
self.dir.join(plugin).join("secrets.json")
}
fn load(&self, plugin: &str) -> BTreeMap<String, String> {
std::fs::read(self.file(plugin)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
}
}
impl SecretStore for FileSecretStore {
fn get(&self, plugin: &str, name: &str) -> Option<String> {
self.load(plugin).remove(name)
}
fn set(&self, plugin: &str, name: &str, value: Option<&str>) -> Result<(), String> {
let mut map = self.load(plugin);
match value {
Some(v) => map.insert(name.to_owned(), v.to_owned()),
None => map.remove(name),
};
let file = self.file(plugin);
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec(&map).map_err(|e| e.to_string())?;
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600));
}
std::fs::rename(&tmp, &file).map_err(|e| e.to_string())
}
}
/// Largest response body a plugin receives.
const MAX_RESPONSE: usize = 8 << 20;
/// Largest stdout / stderr kept from a process.
const MAX_OUTPUT: usize = 4 << 20;
/// Most processes one plugin keeps running at once.
const MAX_PROCESSES: usize = 8;
/// Most output one stream of a handle holds until the plugin reads it.
const MAX_PENDING: usize = 1 << 20;
/// Most files `find-files` walks before giving up.
const MAX_WALK: usize = 200_000;
/// Runtime for plugin HTTP requests (actor threads block on it).
pub(super) fn net_runtime() -> &'static tokio::runtime::Runtime {
static RT: std::sync::OnceLock<tokio::runtime::Runtime> = std::sync::OnceLock::new();
RT.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("plugin-net")
.enable_all()
.build()
.expect("plugin network runtime")
})
}
/// Replaces `{{secret:<name>}}` in `value` with the plugin's secrets.
fn fill_secrets(value: &str, lookup: impl Fn(&str) -> Option<String>) -> Result<String, HostError> {
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(i) = rest.find("{{secret:") {
out.push_str(&rest[..i]);
let after = &rest[i + "{{secret:".len()..];
let end = after.find("}}").ok_or_else(|| HostError::Invalid("unterminated {{secret:…}}".into()))?;
let name = &after[..end];
let secret = lookup(name).ok_or_else(|| HostError::NotFound(format!("secret {name:?} is not set")))?;
out.push_str(&secret);
rest = &after[end + 2..];
}
out.push_str(rest);
Ok(out)
}
/// A plugin task shown in the UI.
struct TaskEntry {
plugin: String,
cancel: CancellationToken,
cancelled: bool,
}
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
struct PromptEntry {
plugin: String,
client: String,
answer: Waiter,
}
enum Waiter {
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
}
/// A process a plugin started and has not released (API 1.3 `process.start`).
/// Two reader threads copy its output into bounded buffers; the handle lives
/// as long as the plugin instance that started it.
struct Proc {
client: Option<String>,
state: Mutex<ProcState>,
}
struct ProcState {
child: Child,
stdin: Option<ChildStdin>,
/// What it printed and the plugin hasn't read yet.
stdout: Vec<u8>,
stderr: Vec<u8>,
/// Its exit status once it ended (a code, or -1 when a signal ended it).
status: Option<i32>,
/// Reader threads still copying its pipes into the buffers above.
readers: usize,
}
/// Which pipe a reader thread drains.
#[derive(Clone, Copy)]
enum Stream {
Out,
Err,
}
impl Proc {
/// Copies `pipe` into the matching buffer until it closes, keeping the last
/// [`MAX_PENDING`] bytes, then notes that the reader finished.
fn read(self: &Arc<Self>, mut pipe: impl Read + Send + 'static, stream: Stream) {
self.state.lock().readers += 1;
let this = Arc::clone(self);
std::thread::spawn(move || {
let mut buf = [0u8; 8 << 10];
loop {
match pipe.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let mut state = this.state.lock();
let out = match stream {
Stream::Out => &mut state.stdout,
Stream::Err => &mut state.stderr,
};
out.extend_from_slice(&buf[..n]);
let over = out.len().saturating_sub(MAX_PENDING);
if over > 0 {
out.drain(..over);
let continuation = out.iter().take_while(|b| (**b & 0xC0) == 0x80).count();
out.drain(..continuation);
}
}
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
Err(_) => break,
}
}
this.state.lock().readers -= 1;
});
}
/// Notes its exit status if it ended (dropping its stdin then).
fn reap(&self) -> Result<(), HostError> {
let mut state = self.state.lock();
if state.status.is_none() {
match try_wait_process(&mut state.child) {
Ok(Some(status)) => {
state.status = Some(status.code().unwrap_or(-1));
state.stdin = None;
}
Ok(None) => {}
Err(e) => return Err(failed(e)),
}
}
Ok(())
}
/// Ends it and reaps it, so its status is known right away; killing twice
/// does nothing.
fn kill(&self) {
let mut state = self.state.lock();
state.stdin = None;
if state.status.is_none() {
ide_core::process::kill_tree(&mut state.child);
if let Ok(status) = state.child.wait() {
state.status = Some(status.code().unwrap_or(-1));
}
}
}
/// What it printed since the last call, each stream at most `max` bytes.
fn chunk(&self, max: usize) -> ProcessChunk {
let mut state = self.state.lock();
let eof = state.readers == 0;
ProcessChunk {
stdout: take_text(&mut state.stdout, max, eof),
stderr: take_text(&mut state.stderr, max, eof),
status: state.status,
}
}
}
/// Decodes complete characters within the byte budget, retaining partial UTF-8
/// until the reader gets the rest (or EOF). Even a tiny budget makes progress.
fn take_text(buf: &mut Vec<u8>, max: usize, eof: bool) -> String {
let limit = max.clamp(1, MAX_PENDING);
let mut out = String::new();
let mut consumed = 0;
while consumed < buf.len() && out.len() < limit {
let bytes = &buf[consumed..];
let (valid, invalid) = match std::str::from_utf8(bytes) {
Ok(text) => (text.len(), None),
Err(e) => (e.valid_up_to(), Some(e.error_len())),
};
let text = std::str::from_utf8(&bytes[..valid]).expect("valid prefix");
let mut end = text.len().min(limit - out.len());
while !text.is_char_boundary(end) {
end -= 1;
}
if end == 0 && out.is_empty() && !text.is_empty() {
end = text.chars().next().unwrap().len_utf8();
}
out.push_str(&text[..end]);
consumed += end;
if end < valid {
break;
}
match invalid {
Some(len) if len.is_some() || eof => {
if !out.is_empty() && out.len() + '\u{fffd}'.len_utf8() > limit {
break;
}
out.push('\u{fffd}');
consumed += len.unwrap_or(bytes.len() - valid);
}
_ => break,
}
}
buf.drain(..consumed);
out
}
/// Ends descendants before reaping an exited leader, while its process id is
/// still reserved. Otherwise inherited pipes can keep a completed call waiting.
fn try_wait_process(child: &mut Child) -> std::io::Result<Option<std::process::ExitStatus>> {
#[cfg(unix)]
{
use rustix::process::{Pid, WaitId, WaitIdOptions, waitid};
let status = waitid(
WaitId::Pid(Pid::from_child(child)),
WaitIdOptions::EXITED | WaitIdOptions::NOHANG | WaitIdOptions::NOWAIT,
)?;
if status.is_none() {
return Ok(None);
}
ide_core::process::kill_tree(child);
child.try_wait()
}
#[cfg(not(unix))]
{
let status = child.try_wait()?;
if status.is_some() {
ide_core::process::kill_tree(child);
}
Ok(status)
}
}
fn env_name_eq(a: &std::ffi::OsStr, b: &std::ffi::OsStr) -> bool {
#[cfg(windows)]
{
// Use the platform's Command key comparison, including non-ASCII names.
let mut probe = StdCommand::new("unused");
probe.env(a, "").env(b, "");
probe.get_envs().count() == 1
}
#[cfg(not(windows))]
{
a == b
}
}
/// Validates against the environment after `prepare`, then freezes that snapshot
/// so only new keys are added, including when the host environment changes later.
fn append_process_env(command: &mut StdCommand, env: &[(String, String)]) -> Result<(), HostError> {
if env.len() > 64 {
return Err(HostError::Invalid("at most 64 environment variables".into()));
}
let mut inherited: Vec<_> = std::env::vars_os().collect();
for (key, value) in command.get_envs() {
inherited.retain(|(k, _)| !env_name_eq(k, key));
if let Some(value) = value {
inherited.push((key.to_owned(), value.to_owned()));
}
}
for (key, value) in env {
if key.is_empty() || key.len() > 256 || key.contains(['=', '\0']) {
return Err(HostError::Invalid(format!("{key:?} is not an environment variable name")));
}
if value.len() > 4096 || value.contains('\0') {
return Err(HostError::Invalid(format!("the value of {key:?} is too long or holds a NUL")));
}
if inherited.iter().any(|(k, _)| env_name_eq(k, std::ffi::OsStr::new(key))) {
return Err(HostError::Invalid(format!(
"environment variable {key:?} already exists; only new variables may be added"
)));
}
inherited.push((key.into(), value.into()));
}
command.env_clear().envs(inherited);
Ok(())
}
/// State shared by the host and every plugin's [`PluginApi`].
pub(super) struct Shared {
pub sink: Arc<dyn PluginEventSink>,
pub workspace: RwLock<Option<Arc<dyn PluginWorkspace>>>,
/// `<plugins>/.data`: one directory per plugin id.
pub data_dir: PathBuf,
tasks: Mutex<HashMap<u64, TaskEntry>>,
next_task: AtomicU64,
/// (plugin, handle id) → the processes it started and has not released.
processes: Mutex<HashMap<(String, u64), Arc<Proc>>>,
next_process: AtomicU64,
process_shutdowns: Mutex<Vec<std::thread::JoinHandle<()>>>,
prompts: Mutex<HashMap<u64, PromptEntry>>,
/// Plugins being stopped: their calls may not open new prompts (taken before `prompts`).
prompts_blocked: Mutex<BTreeSet<String>>,
next_prompt: AtomicU64,
/// (plugin, item id) → item, for windows opened later.
status: Mutex<BTreeMap<(String, String), StatusItem>>,
/// (scope, plugin, key) → JSON value.
contexts: Mutex<BTreeMap<ContextKey, String>>,
closed_clients: Mutex<BTreeSet<String>>,
/// plugin → kinds subscribed at run time (kept across idle reclaim).
pub subscriptions: Mutex<super::event_paths::Subscriptions>,
/// plugin → files it shows problems for, withdrawn when it stops.
diagnostics: Mutex<HashMap<String, BTreeSet<PathBuf>>>,
pub secrets: RwLock<Arc<dyn SecretStore>>,
/// (plugin, `process:` capability) → the approved program.
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
/// Validated UI declarations (settings schemas, declared views).
pub ui: Arc<ide_extension::UiStore>,
/// The UI language the windows report (BCP 47).
pub locale: RwLock<String>,
}
impl Shared {
pub fn new(
sink: Arc<dyn PluginEventSink>,
data_dir: PathBuf,
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
ui: Arc<ide_extension::UiStore>,
) -> Arc<Self> {
Arc::new(Self {
ui,
locale: RwLock::new("en".into()),
sink,
workspace: RwLock::new(None),
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
pins,
data_dir,
tasks: Mutex::default(),
next_task: AtomicU64::new(1),
processes: Mutex::default(),
next_process: AtomicU64::new(1),
process_shutdowns: Mutex::default(),
prompts: Mutex::default(),
prompts_blocked: Mutex::default(),
next_prompt: AtomicU64::new(1),
status: Mutex::default(),
contexts: Mutex::default(),
closed_clients: Mutex::default(),
subscriptions: Mutex::default(),
diagnostics: Mutex::default(),
})
}
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
self.workspace.read().clone()
}
pub fn project_of(&self, window: &str) -> Option<PathBuf> {
self.workspace()?.project_of(window)
}
/// Shows `plugin`'s problems of `path`, remembering the file until they are
/// cleared (by the plugin, or when it stops).
fn publish_diagnostics(
&self,
plugin: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
let workspace = self.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
let empty = items.is_empty();
workspace.publish_diagnostics(plugin, path, items)?;
let mut published = self.diagnostics.lock();
if empty {
if let Some(paths) = published.get_mut(plugin) {
paths.remove(path);
if paths.is_empty() {
published.remove(plugin);
}
}
} else {
published.entry(plugin.to_owned()).or_default().insert(path.to_path_buf());
}
Ok(())
}
pub fn status_items(&self) -> Vec<(String, String, StatusItem)> {
self.status.lock().iter().map(|((p, id), item)| (p.clone(), id.clone(), item.clone())).collect()
}
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
let contexts = self.contexts.lock();
let mut keys: BTreeMap<(&str, &str), &str> = BTreeMap::new();
for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.is_none()) {
keys.insert((plugin, key), value);
}
if let Some(client) = client {
for ((_, plugin, key), value) in contexts.iter().filter(|((s, ..), _)| s.as_deref() == Some(client)) {
keys.insert((plugin, key), value);
}
}
keys.into_iter()
.map(|((p, k), v)| PluginContextKey { plugin_id: p.to_owned(), key: k.to_owned(), value: v.to_owned() })
.collect()
}
/// Cancels prompts and drops context keys scoped to window `client` (it closed).
/// Refuses further writes for it (late commands) until [`Self::reopen_client`].
pub fn forget_client(&self, client: &str) {
let mut closed = self.closed_clients.lock();
closed.insert(client.to_owned());
self.prompts.lock().retain(|_, entry| entry.client != client);
drop(closed);
self.contexts.lock().retain(|(scope, ..), _| scope.as_deref() != Some(client));
let mine: Vec<_> = {
let mut processes = self.processes.lock();
let keys: Vec<_> = processes
.iter()
.filter(|(_, p)| p.client.as_deref() == Some(client))
.map(|(key, _)| key.clone())
.collect();
keys.iter().filter_map(|key| processes.remove(key)).collect()
};
if !mine.is_empty() {
// Window destruction runs on the UI thread; tree shutdown may wait.
let worker = std::thread::spawn(move || {
for proc in mine {
proc.kill();
}
});
let mut workers = self.process_shutdowns.lock();
workers.retain(|worker| !worker.is_finished());
workers.push(worker);
}
}
pub fn finish_process_shutdowns(&self) {
let workers = std::mem::take(&mut *self.process_shutdowns.lock());
for worker in workers {
let _ = worker.join();
}
}
/// A window labelled `client` is being created (labels are reused after a close).
pub fn reopen_client(&self, client: &str) {
self.closed_clients.lock().remove(client);
}
pub fn client_closed(&self, client: &str) -> bool {
self.closed_clients.lock().contains(client)
}
/// Withdraws a plugin's run-time state (it stopped or failed). Its
/// subscriptions go only with `subscriptions`, as they survive restarts.
pub fn clear_plugin(&self, plugin: &str, subscriptions: bool) {
self.kill_processes(plugin);
let removed: Vec<String> = {
let mut status = self.status.lock();
let ids: Vec<String> = status.keys().filter(|(p, _)| p == plugin).map(|(_, id)| id.clone()).collect();
for id in &ids {
status.remove(&(plugin.to_owned(), id.clone()));
}
ids
};
for id in removed {
self.sink.emit(None, PluginEvent::StatusItemChanged { plugin_id: plugin.to_owned(), id, item: None });
}
self.withdraw_prompts(plugin);
let removed: Vec<(Option<String>, String)> = {
let mut contexts = self.contexts.lock();
let keys: Vec<_> = contexts.keys().filter(|(_, p, _)| p == plugin).cloned().collect();
for key in &keys {
contexts.remove(key);
}
keys.into_iter().map(|(scope, _, key)| (scope, key)).collect()
};
for (scope, key) in removed {
self.sink
.emit(scope.as_deref(), PluginEvent::ContextChanged { plugin_id: plugin.to_owned(), key, value: None });
}
let tasks: Vec<u64> = self.tasks.lock().iter().filter(|(_, t)| t.plugin == plugin).map(|(id, _)| *id).collect();
for id in tasks {
self.end_task(id);
}
// Its problems go with it (an idle reclaim keeps them: it isn't cleared then).
let paths = self.diagnostics.lock().remove(plugin).unwrap_or_default();
if let Some(workspace) = self.workspace() {
for path in paths {
let _ = workspace.publish_diagnostics(plugin, &path, Vec::new());
}
}
if subscriptions && self.subscriptions.lock().remove(plugin) {
self.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
/// Ends every process `plugin` started: its handles go with its instance
/// (it stopped, was disabled, or was reclaimed while idle).
pub fn kill_processes(&self, plugin: &str) {
let mine: Vec<Arc<Proc>> = {
let mut processes = self.processes.lock();
let keys: Vec<(String, u64)> = processes.keys().filter(|(p, _)| p == plugin).cloned().collect();
keys.iter().filter_map(|k| processes.remove(k)).collect()
};
for proc in mine {
proc.kill();
}
}
/// The user cancelled a task in the UI.
pub fn cancel_task(&self, id: u64) -> bool {
let mut tasks = self.tasks.lock();
let Some(t) = tasks.get_mut(&id) else { return false };
t.cancelled = true;
t.cancel.cancel();
true
}
/// Closes the prompts `plugin` waits on: the calls waiting for the user
/// wake up cancelled (dropping the senders).
fn withdraw_prompts(&self, plugin: &str) {
let ids: Vec<u64> = {
let mut prompts = self.prompts.lock();
let ids: Vec<u64> = prompts.iter().filter(|(_, p)| p.plugin == plugin).map(|(id, _)| *id).collect();
for id in &ids {
prompts.remove(id);
}
ids
};
for id in ids {
self.sink.emit(None, PluginEvent::PromptClosed { prompt_id: id.to_string() });
}
}
/// `plugin` is about to be stopped: withdraws its prompts, and its queued
/// calls can't open new ones (they'd keep the stop waiting) until
/// [`allow_prompts`](Self::allow_prompts).
pub fn block_prompts(&self, plugin: &str) {
self.prompts_blocked.lock().insert(plugin.to_owned());
self.withdraw_prompts(plugin);
}
pub fn allow_prompts(&self, plugin: &str) {
self.prompts_blocked.lock().remove(plugin);
}
/// The user answered (`Some`) or dismissed (`None`) a prompt.
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
/// A window answered a [`CommitRequest`].
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
let mut prompts = self.prompts.lock();
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
return false;
}
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
tx.send(answer).is_ok()
}
fn end_task(&self, id: u64) {
if let Some(t) = self.tasks.lock().remove(&id) {
self.sink.emit(None, PluginEvent::TaskEnded { plugin_id: t.plugin, task_id: id.to_string() });
}
}
/// The plugin's stored values: one file per key, so a write only
/// rewrites its own value.
pub fn storage_dir(&self, plugin: &str) -> PathBuf {
self.data_dir.join(plugin).join("storage")
}
}
fn storage_io(e: std::io::Error) -> HostError {
HostError::Failed(format!("storage: {e}"))
}
/// A key's file name: its hash, since keys may hold any character.
fn entry_name(key: &str) -> String {
blake3::hash(key.as_bytes()).to_hex().to_string()
}
/// A stored entry: the key's length (2 bytes, little-endian), the key, then the value.
fn decode_entry(bytes: &[u8]) -> Option<(&str, &str)> {
let len = usize::from(u16::from_le_bytes(bytes.get(..2)?.try_into().ok()?));
let key = std::str::from_utf8(bytes.get(2..2 + len)?).ok()?;
let value = std::str::from_utf8(&bytes[2 + len..]).ok()?;
Some((key, value))
}
fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
std::fs::create_dir_all(dir).map_err(storage_io)?;
let file = dir.join(entry_name(key));
let len = u16::try_from(key.len()).expect("keys are at most MAX_KEY bytes");
let mut bytes = Vec::with_capacity(2 + key.len() + value.len());
bytes.extend_from_slice(&len.to_le_bytes());
bytes.extend_from_slice(key.as_bytes());
bytes.extend_from_slice(value.as_bytes());
let tmp = file.with_extension("tmp");
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
}
/// The stored entries' files and the bytes of their keys and values.
fn entries(dir: &Path) -> Result<impl Iterator<Item = (PathBuf, usize)>, HostError> {
let list = match std::fs::read_dir(dir) {
Ok(list) => list,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new().into_iter()),
Err(e) => return Err(storage_io(e)),
};
let mut out = Vec::new();
for entry in list {
let entry = entry.map_err(storage_io)?;
let path = entry.path();
if path.extension().is_some() {
continue; // a write that didn't finish
}
let size = entry.metadata().map_err(storage_io)?.len();
out.push((path, usize::try_from(size).unwrap_or(usize::MAX).saturating_sub(2)));
}
Ok(out.into_iter())
}
pub(super) struct PluginApi {
pub pkg: Package,
pub shared: Arc<Shared>,
}
impl PluginApi {
fn id(&self) -> &str {
self.pkg.id()
}
/// The storage directory, after moving a `storage.json` written by an
/// older version into it.
fn storage(&self) -> Result<PathBuf, HostError> {
let dir = self.shared.storage_dir(self.id());
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
match std::fs::read(&legacy) {
Ok(bytes) => {
let map: BTreeMap<String, String> =
serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}")))?;
for (key, value) in &map {
write_entry(&dir, key, value)?;
}
std::fs::remove_file(&legacy).map_err(storage_io)?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => return Err(storage_io(e)),
}
Ok(dir)
}
/// The program approved for the (already checked) `process:` grant,
/// verified right before it starts.
fn approved_program(&self, program: &str) -> Result<PathBuf, HostError> {
let cap = format!("process:{program}");
// Granted without a pinned file: the program wasn't found when it was
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
clear {cap} in Settings › Plugins and select it again"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
}
}
impl PluginApi {
/// Runs an approved program until it exits or `cancel` fires, passing
/// each output line to `on_line`; returns its exit code.
pub(super) fn stream_process(
&self,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
cancel: &CancellationToken,
on_line: &dyn Fn(super::PluginOutputLine),
) -> Result<Option<i32>, HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let mut cmd = std::process::Command::new(&binary);
ide_core::process::prepare(ide_core::process::own_group(&mut cmd));
let mut child = cmd
.args(&args)
.current_dir(&cwd)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
// Bounded: a program printing faster than the lines are sent waits on its pipe.
let (tx, rx) = std::sync::mpsc::sync_channel(1024);
let read = |pipe: Box<dyn std::io::Read + Send>, stderr: bool| {
let tx = tx.clone();
std::thread::spawn(move || {
read_lines(pipe, MAX_OUTPUT_LINE, &mut |text| {
tx.send(super::PluginOutputLine { stderr, text }).is_ok()
});
});
};
if let Some(out) = child.stdout.take() {
read(Box::new(out), false);
}
if let Some(err) = child.stderr.take() {
read(Box::new(err), true);
}
drop(tx);
// Revoking the grant removes the pin: stop then.
let cap = format!("process:{program}");
let pin = (self.id().to_owned(), cap.clone());
let stop = |child: &mut std::process::Child| -> Option<HostError> {
if cancel.is_cancelled() {
kill_group(child);
return Some(HostError::Cancelled);
}
if !self.shared.pins.read().contains_key(&pin) {
kill_group(child);
return Some(HostError::Denied(cap.clone()));
}
None
};
loop {
if let Some(e) = stop(&mut child) {
return Err(e);
}
match rx.recv_timeout(Duration::from_millis(50)) {
Ok(line) => on_line(line),
// Both pipes closed: the program is done (or detached them).
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => break,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
}
}
loop {
if let Some(status) = child.try_wait().map_err(failed)? {
return Ok(status.code());
}
if let Some(e) = stop(&mut child) {
return Err(e);
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
/// Longest output line a followed program sends; the rest of the line is dropped.
const MAX_OUTPUT_LINE: usize = 16 << 10;
/// Passes `pipe`'s lines (without `\r\n`, at most `max` bytes each) to `line`
/// until it returns false or the pipe closes, holding at most one line.
fn read_lines(pipe: Box<dyn std::io::Read + Send>, max: usize, line: &mut dyn FnMut(String) -> bool) {
use std::io::{BufRead, Read};
let mut reader = std::io::BufReader::new(pipe);
let mut buf = Vec::new();
loop {
buf.clear();
match (&mut reader).take(max as u64).read_until(b'\n', &mut buf) {
Ok(0) | Err(_) => return,
Ok(_) => {}
}
if buf.last() != Some(&b'\n') && buf.len() == max {
// Too long: skip to the end of the line.
let mut rest = Vec::new();
loop {
rest.clear();
match (&mut reader).take(max as u64).read_until(b'\n', &mut rest) {
Ok(0) | Err(_) => break,
Ok(_) if rest.last() == Some(&b'\n') => break,
Ok(_) => {}
}
}
}
let end = buf.strip_suffix(b"\n").unwrap_or(&buf);
let end = end.strip_suffix(b"\r").unwrap_or(end);
if !line(String::from_utf8_lossy(end).into_owned()) {
return;
}
}
}
/// Kills a program spawned in its own group and what it started. A kill that
/// lands while the program forks can miss the new process, so the group is
/// signalled again before the program is reaped (until then its group id
/// can't be reused).
fn kill_group(child: &mut std::process::Child) {
ide_core::process::kill_tree_of(child.id());
std::thread::sleep(Duration::from_millis(50));
ide_core::process::kill_tree(child);
}
impl PluginApi {
/// Sends `request` to `client`'s window and waits for its answer, until
/// `cancel` fires, the plugin stops or `timeout` passes.
fn ask_commit(
&self,
client: &str,
request: CommitRequest,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<CommitAnswer, HostError> {
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Err(HostError::Cancelled);
}
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Commit(answer) },
);
}
let request_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
);
let deadline = Instant::now() + timeout;
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
if !cancel.is_cancelled() && Instant::now() < deadline => {}
Err(e) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
return Err(match e {
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
HostError::Failed("the window did not answer".into())
}
_ => HostError::Cancelled,
});
}
}
}
}
/// Where `cmd` runs: the project root it names, else the plugin's data
/// directory (created as needed).
fn spawn_dir(&self, cwd: Option<&Path>) -> PathBuf {
let dir = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&dir);
dir
}
/// The process handle `id`, if this plugin started it and it is still open.
fn process(&self, id: u64) -> Result<Arc<Proc>, HostError> {
self.shared
.processes
.lock()
.get(&(self.id().to_owned(), id))
.cloned()
.ok_or_else(|| HostError::NotFound(format!("no process {id}: it was closed, or belongs to another plugin")))
}
}
/// The changes `selection` commits, diffed repository by repository.
pub(super) fn commit_draft(
workspace: &dyn PluginWorkspace,
selection: CommitSelection,
cancel: &CancellationToken,
) -> Result<CommitDraft, HostError> {
let mut changes = Vec::new();
let mut budget = MAX_COMMIT_DIFF;
for repo in &selection.repositories {
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
};
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|e| match e.kind {
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
_ => HostError::Failed(e.message),
},
)?;
let root = to_ipc(r.root());
for c in pending {
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
changes.push(CommitChange {
repository: root.clone(),
path: to_ipc(&c.path),
old_path: c.old_path.as_deref().map(to_ipc),
status: c.status.to_owned(),
diff: c.diff,
truncated: c.truncated,
});
}
}
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
}
fn failed(e: impl std::fmt::Display) -> HostError {
HostError::Failed(e.to_string())
}
impl HostApi for PluginApi {
fn notify(
&self,
client: Option<&str>,
level: NotificationLevel,
title: String,
body: String,
actions: Vec<ide_plugin::NotificationAction>,
) -> Result<(), HostError> {
if !actions.is_empty() {
let commands = self.shared.ui.items("commands", Some(self.id()));
if let Some(a) = actions.iter().find(|a| !commands.iter().any(|c| c.key == a.command)) {
return Err(HostError::NotFound(format!("the plugin declares no command {:?}", a.command)));
}
}
let plugin_id = self.id().to_owned();
self.shared.sink.emit(client, PluginEvent::Notification { plugin_id, level, title, body, actions });
Ok(())
}
fn copy_to_clipboard(&self, client: Option<&str>, text: String) -> Result<(), HostError> {
let client = from_window(client, "copy to the clipboard")?;
self.shared.sink.emit(Some(client), PluginEvent::CopyToClipboard { plugin_id: self.id().to_owned(), text });
Ok(())
}
fn open_url(&self, client: Option<&str>, url: String) -> Result<(), HostError> {
let client = from_window(client, "open a URL")?;
self.shared.sink.emit(Some(client), PluginEvent::OpenUrl { plugin_id: self.id().to_owned(), url });
Ok(())
}
fn open_project(&self, client: Option<&str>, path: String, new_window: bool) -> Result<(), HostError> {
let client = from_window(client, "open a project")?;
self.shared
.sink
.emit(Some(client), PluginEvent::OpenProject { plugin_id: self.id().to_owned(), path, new_window });
Ok(())
}
fn ask(
&self,
client: Option<&str>,
prompt: Prompt,
cancel: &CancellationToken,
) -> Result<Option<Vec<String>>, HostError> {
let client = from_window(client, "ask the user")?;
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
let (answer, answers) = std::sync::mpsc::sync_channel(1);
{
let closed = self.shared.closed_clients.lock();
if closed.contains(client) {
return Err(HostError::Cancelled);
}
let blocked = self.shared.prompts_blocked.lock();
if blocked.contains(self.id()) {
return Err(HostError::Cancelled);
}
self.shared.prompts.lock().insert(
id,
PromptEntry { plugin: self.id().to_owned(), client: client.to_owned(), answer: Waiter::Prompt(answer) },
);
}
let prompt_id = id.to_string();
self.shared.sink.emit(
Some(client),
PluginEvent::PromptRequested { plugin_id: self.id().to_owned(), prompt_id: prompt_id.clone(), prompt },
);
loop {
match answers.recv_timeout(Duration::from_millis(100)) {
Ok(answer) => return Ok(answer),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) if !cancel.is_cancelled() => {}
Err(_) => {
self.shared.prompts.lock().remove(&id);
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id });
return Err(HostError::Cancelled);
}
}
}
}
fn pending_commit(
&self,
client: Option<&str>,
cancel: &CancellationToken,
) -> Result<Option<CommitDraft>, HostError> {
let client = from_window(client, "read the pending commit")?;
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
let root =
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
if !workspace.trusted(&root) {
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
}
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
match answer {
CommitAnswer::NoRepository => Ok(None),
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
}
}
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
let client = from_window(client, "set the commit message")?;
let request = CommitRequest::SetMessage { revision, message };
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
CommitAnswer::Done => Ok(()),
CommitAnswer::Stale => Err(HostError::Stale),
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
}
}
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
let client = from_window(client, "reveal a view")?;
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
if !declared {
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
}
self.shared
.sink
.emit(Some(client), PluginEvent::RevealView { plugin_id: self.id().to_owned(), view: view.to_owned() });
Ok(())
}
fn status_item(&self, id: &str, item: Option<StatusItem>) {
let key = (self.id().to_owned(), id.to_owned());
{
let mut status = self.shared.status.lock();
match &item {
Some(item) => {
let count = status.keys().filter(|(p, _)| p == self.id()).count();
if !status.contains_key(&key) && count >= MAX_STATUS_ITEMS {
tracing::warn!(plugin = %self.id(), "more than {MAX_STATUS_ITEMS} status items; {id:?} ignored");
return;
}
if status.get(&key) == Some(item) {
return;
}
status.insert(key, item.clone());
}
None => {
if status.remove(&key).is_none() {
return;
}
}
}
}
self.shared
.sink
.emit(None, PluginEvent::StatusItemChanged { plugin_id: self.id().to_owned(), id: id.to_owned(), item });
}
fn set_context(&self, client: Option<&str>, key: &str, value: Option<String>) -> Result<(), HostError> {
let map_key = (client.map(str::to_owned), self.id().to_owned(), key.to_owned());
{
let mut contexts = self.shared.contexts.lock();
if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) {
return Ok(());
}
match &value {
Some(value) => {
if contexts.get(&map_key) == Some(value) {
return Ok(());
}
let count = contexts.keys().filter(|(_, p, _)| p == self.id()).count();
if !contexts.contains_key(&map_key) && count >= MAX_CONTEXT_KEYS {
return Err(HostError::Invalid(format!("more than {MAX_CONTEXT_KEYS} context keys")));
}
contexts.insert(map_key, value.clone());
}
None => {
if contexts.remove(&map_key).is_none() {
return Ok(());
}
}
}
}
self.shared
.sink
.emit(client, PluginEvent::ContextChanged { plugin_id: self.id().to_owned(), key: key.to_owned(), value });
Ok(())
}
fn projects(&self) -> Vec<PathBuf> {
self.shared.workspace().map(|w| w.projects()).unwrap_or_default()
}
fn buffer_text(&self, path: &Path) -> Option<String> {
self.shared.workspace()?.buffer_text(path)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
self.shared.workspace()?.document(path)
}
fn apply_edits(
&self,
_client: Option<&str>,
path: &Path,
version: u64,
edits: Vec<TextEdit>,
) -> Result<u64, HostError> {
let workspace = self.shared.workspace().ok_or_else(|| HostError::NotFound("no project is open".into()))?;
workspace.apply_edits(path, version, &edits)
}
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
let file = self.storage()?.join(entry_name(key));
match std::fs::read(&file) {
Ok(bytes) => Ok(decode_entry(&bytes).filter(|(k, _)| *k == key).map(|(_, v)| v.to_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(storage_io(e)),
}
}
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
if key.is_empty() || key.len() > MAX_KEY {
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
}
let dir = self.storage()?;
let file = dir.join(entry_name(key));
let Some(value) = value else {
return match std::fs::remove_file(&file) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(storage_io(e)),
_ => Ok(()),
};
};
let others: usize = entries(&dir)?.filter(|(name, _)| *name != file).map(|(_, size)| size).sum();
if others + key.len() + value.len() > MAX_STORAGE {
return Err(HostError::Invalid(format!(
"storage is limited to {} MiB (UTF-8 keys and values)",
MAX_STORAGE >> 20
)));
}
write_entry(&dir, key, &value)
}
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
let dir = self.storage()?;
let mut keys = Vec::new();
for (file, _) in entries(&dir)? {
let bytes = std::fs::read(&file).map_err(storage_io)?;
if let Some((key, _)) = decode_entry(&bytes).filter(|(k, _)| k.starts_with(prefix)) {
keys.push(key.to_owned());
}
}
keys.sort();
Ok(keys)
}
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(None) };
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
root: to_ipc(repo.root()),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
ahead: info.ahead,
behind: info.behind,
operation: info.operation,
changed_files: u32::try_from(changed).unwrap_or(u32::MAX),
}))
}
fn vcs_remotes(&self, root: &Path) -> Result<Vec<(String, String)>, HostError> {
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else { return Ok(Vec::new()) };
let remotes = repo.remotes().map_err(failed)?;
Ok(remotes.into_iter().map(|r| (r.name, without_credentials(&r.url))).collect())
}
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError> {
let workspace = self.shared.workspace();
if !workspace.is_some_and(|w| w.trusted(root)) {
return Err(HostError::Failed("the project is in Restricted Mode: git does not run there".into()));
}
let Some(repo) = ide_vcs::Repo::discover(root).map_err(failed)? else {
return Err(HostError::NotFound("the project is not in a git repository".into()));
};
match repo.fetch(cancel, &|_| {}) {
Ok(()) => Ok(()),
Err(_) if cancel.is_cancelled() => Err(HostError::Cancelled),
Err(e) => Err(failed(e.message)),
}
}
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
if query.len() > 256 {
return Err(HostError::Invalid("symbol query exceeds 256 UTF-8 bytes".into()));
}
self.shared.workspace().ok_or_else(|| HostError::NotFound("no project index is available".into()))?.symbols(
root,
query,
limit.min(1000),
cancel,
deadline,
)
}
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError> {
let matcher = globset::GlobBuilder::new(glob)
.literal_separator(true)
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
// Project scope visits hidden files and folders (`.github`, `.config`):
// only `.git` internals stay out, as in the walk below.
let visible = |rel: &Path| !rel.iter().any(|c| c == ".git");
// The project index already lists the files: walking a large tree on every call can take
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
if let Some(files) = self
.shared
.workspace()
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
{
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
out.sort();
return Ok(out);
}
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root)
.hidden(false)
.filter_entry(|e| e.file_name() != ".git")
.build()
.flatten()
.enumerate()
{
if n >= MAX_WALK || out.len() >= limit {
break;
}
if !entry.file_type().is_some_and(|t| t.is_file()) {
continue;
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(to_ipc(entry.path()));
}
}
out.sort();
Ok(out)
}
fn publish_diagnostics(&self, path: &Path, items: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
self.shared.publish_diagnostics(self.id(), path, items)
}
fn fetch(&self, request: HttpRequest, cancel: &CancellationToken) -> Result<HttpResponse, HostError> {
let secrets = self.shared.secrets.read().clone();
let id = self.id().to_owned();
let mut headers = reqwest::header::HeaderMap::new();
for (name, value) in &request.headers {
let value = fill_secrets(value, |n| secrets.get(&id, n))?;
let name = reqwest::header::HeaderName::from_bytes(name.as_bytes())
.map_err(|e| HostError::Invalid(format!("header {name:?}: {e}")))?;
let mut value = reqwest::header::HeaderValue::from_str(&value)
.map_err(|e| HostError::Invalid(format!("header {name}: {e}")))?;
value.set_sensitive(true);
headers.append(name, value);
}
let method = reqwest::Method::from_bytes(request.method.to_ascii_uppercase().as_bytes())
.map_err(|_| HostError::Invalid(format!("method {:?}", request.method)))?;
let client = ide_http::client::shared().map_err(|e| HostError::Failed(e.message))?;
// A redirect to another host is refused after the fact (the shared
// client follows redirects).
let builder = client.request(method, &request.url).headers(headers).timeout(request.timeout);
let builder = match request.body {
Some(body) => builder.body(body),
None => builder,
};
let cancel = cancel.clone();
let host = ide_plugin::net_host(&request.url).map_err(HostError::Invalid)?;
net_runtime().block_on(async move {
let send = async {
let response = builder.send().await.map_err(|e| HostError::Failed(e.to_string()))?;
if ide_plugin::net_host(response.url().as_str()).ok().as_deref() != Some(host.as_str()) {
return Err(HostError::Denied(format!(
"net:{} (redirected)",
response.url().host_str().unwrap_or("?")
)));
}
let status = response.status().as_u16();
let headers = response
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_owned(), String::from_utf8_lossy(v.as_bytes()).into_owned()))
.collect();
let mut body = Vec::new();
let mut response = response;
while let Some(chunk) = response.chunk().await.map_err(|e| HostError::Failed(e.to_string()))? {
if body.len() + chunk.len() > MAX_RESPONSE {
return Err(HostError::Failed("response larger than 8 MiB".into()));
}
body.extend_from_slice(&chunk);
}
Ok(HttpResponse { status, headers, body })
};
tokio::select! {
r = send => r,
() = cancel.cancelled() => Err(HostError::Cancelled),
}
})
}
fn secret_is_set(&self, name: &str) -> bool {
self.shared.secrets.read().get(self.id(), name).is_some()
}
fn run_process(
&self,
cmd: ProcessCommand,
cwd: Option<&Path>,
stdin: Option<String>,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<ProcessOutput, HostError> {
let binary = self.approved_program(&cmd.program)?;
let cwd = self.spawn_dir(cwd);
let mut command = StdCommand::new(&binary);
ide_core::process::prepare(&mut command);
append_process_env(&mut command, &cmd.env)?;
ide_core::process::own_group(&mut command);
let mut child = command
.args(&cmd.args)
.current_dir(&cwd)
.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
if let (Some(input), Some(mut pipe)) = (stdin, child.stdin.take()) {
std::thread::spawn(move || {
use std::io::Write;
let _ = pipe.write_all(input.as_bytes());
});
}
let read = |pipe: Option<Box<dyn Read + Send>>| {
std::thread::spawn(move || {
let mut out = Vec::new();
if let Some(p) = pipe {
let mut p = p;
let _ = p.by_ref().take(MAX_OUTPUT as u64).read_to_end(&mut out);
let _ = std::io::copy(&mut p, &mut std::io::sink());
}
let mut text = String::from_utf8_lossy(&out).into_owned();
let mut end = text.len().min(MAX_OUTPUT);
while !text.is_char_boundary(end) {
end -= 1;
}
text.truncate(end);
text
})
};
let stdout = read(child.stdout.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let stderr = read(child.stderr.take().map(|p| Box::new(p) as Box<dyn Read + Send>));
let deadline = Instant::now() + timeout;
let status = loop {
if let Some(status) = try_wait_process(&mut child).map_err(|e| HostError::Failed(e.to_string()))? {
break status;
}
if cancel.is_cancelled() || Instant::now() >= deadline {
ide_core::process::kill_tree(&mut child);
return Err(if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed(format!("{} timed out after {timeout:?}", cmd.program))
});
}
std::thread::sleep(Duration::from_millis(10));
};
Ok(ProcessOutput {
status: status.code(),
stdout: stdout.join().unwrap_or_default(),
stderr: stderr.join().unwrap_or_default(),
})
}
fn subscribe(&self, kind: EventKind) {
if self.shared.subscriptions.lock().set(self.id(), kind) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn subscribe_paths(&self, kind: &str, globs: Vec<String>) -> Result<(), HostError> {
let filter = super::event_paths::PathFilter::new(kind, globs)?;
if self.shared.subscriptions.lock().set_paths(self.id(), kind, filter) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
Ok(())
}
fn unsubscribe(&self, kind: &str) {
if self.shared.subscriptions.lock().unsubscribe(self.id(), kind) {
self.shared.sink.emit(None, PluginEvent::SubscriptionsChanged);
}
}
fn start_process(&self, cmd: ProcessCommand, cwd: Option<&Path>, client: Option<&str>) -> Result<u64, HostError> {
let binary = self.approved_program(&cmd.program)?;
let cwd = self.spawn_dir(cwd);
if client.is_some_and(|c| self.shared.closed_clients.lock().contains(c)) {
return Err(HostError::Cancelled);
}
{
let processes = self.shared.processes.lock();
let running = processes.keys().filter(|(plugin, _)| plugin == self.id()).count();
if running >= MAX_PROCESSES {
return Err(HostError::Invalid(format!(
"at most {MAX_PROCESSES} processes may run at once: release one first"
)));
}
}
let mut command = StdCommand::new(&binary);
ide_core::process::prepare(&mut command);
append_process_env(&mut command, &cmd.env)?;
ide_core::process::own_group(&mut command);
let mut child = command
.args(&cmd.args)
.current_dir(&cwd)
// Always piped: `write` may come later, and a plugin that never
// writes closes it (or loses it when it releases the handle).
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| HostError::Failed(format!("{}: {e}", binary.display())))?;
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
let proc = Arc::new(Proc {
client: client.map(str::to_owned),
state: Mutex::new(ProcState {
stdin: child.stdin.take(),
child,
stdout: Vec::new(),
stderr: Vec::new(),
status: None,
readers: 0,
}),
});
if let Some(pipe) = stdout {
proc.read(pipe, Stream::Out);
}
if let Some(pipe) = stderr {
proc.read(pipe, Stream::Err);
}
let closed = self.shared.closed_clients.lock();
if client.is_some_and(|c| closed.contains(c)) {
drop(closed);
proc.kill();
return Err(HostError::Cancelled);
}
let id = self.shared.next_process.fetch_add(1, Ordering::Relaxed);
self.shared.processes.lock().insert((self.id().to_owned(), id), proc);
drop(closed);
Ok(id)
}
fn poll_process(&self, id: u64, max: usize) -> Result<ProcessChunk, HostError> {
let proc = self.process(id)?;
proc.reap()?;
Ok(proc.chunk(max))
}
fn write_process(
&self,
id: u64,
text: &str,
cancel: &CancellationToken,
timeout: Duration,
) -> Result<(), HostError> {
let proc = self.process(id)?;
proc.reap()?;
let mut pipe = {
let mut state = proc.state.lock();
if state.status.is_some() {
return Err(HostError::Failed("the process already ended".into()));
}
state.stdin.take().ok_or_else(|| HostError::Failed("its stdin is closed".into()))?
};
let input = text.to_owned();
let writer = proc.clone();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
std::thread::spawn(move || {
use std::io::Write;
let result = pipe.write_all(input.as_bytes()).and_then(|()| pipe.flush());
let mut state = writer.state.lock();
if state.status.is_none() {
state.stdin = Some(pipe);
}
let _ = tx.send(result);
});
let deadline = Instant::now() + timeout;
loop {
if cancel.is_cancelled() || Instant::now() >= deadline {
proc.kill();
return Err(if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed("stdin write timed out".into())
});
}
match rx.recv_timeout(Duration::from_millis(10)) {
Ok(result) => return result.map_err(|e| HostError::Failed(format!("stdin: {e}"))),
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {}
Err(_) => return Err(HostError::Failed("stdin writer stopped".into())),
}
}
}
fn close_process_stdin(&self, id: u64) -> Result<(), HostError> {
let proc = self.process(id)?;
proc.reap()?;
let mut state = proc.state.lock();
if state.status.is_some() {
return Err(HostError::Failed("the process already ended".into()));
}
state.stdin = None;
Ok(())
}
fn release_process(&self, id: u64) {
let proc = self.shared.processes.lock().remove(&(self.id().to_owned(), id));
if let Some(proc) = proc {
proc.kill();
}
}
fn kill_process(&self, id: u64) -> Result<(), HostError> {
self.process(id)?.kill();
Ok(())
}
fn wait_process(
&self,
id: u64,
max: usize,
timeout: Duration,
cancel: &CancellationToken,
) -> Result<ProcessChunk, HostError> {
let proc = self.process(id)?;
let deadline = Instant::now() + timeout;
loop {
proc.reap()?;
let (ended, readers) = {
let state = proc.state.lock();
(state.status.is_some(), state.readers)
};
// Its output is only complete once its pipes reached the end too.
if ended && readers == 0 {
break;
}
if cancel.is_cancelled() {
return Err(HostError::Cancelled);
}
if Instant::now() >= deadline {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
Ok(proc.chunk(max))
}
fn task_begin(&self, client: Option<&str>, title: String, cancellable: bool, cancel: CancellationToken) -> u64 {
let id = self.shared.next_task.fetch_add(1, Ordering::Relaxed);
self.shared.tasks.lock().insert(id, TaskEntry { plugin: self.id().to_owned(), cancel, cancelled: false });
self.shared.sink.emit(
client,
PluginEvent::TaskStarted { plugin_id: self.id().to_owned(), task_id: id.to_string(), title, cancellable },
);
id
}
fn task_report(&self, id: u64, done: u32, total: Option<u32>, message: Option<String>) {
if !self.shared.tasks.lock().contains_key(&id) {
return;
}
self.shared.sink.emit(
None,
PluginEvent::TaskProgress {
plugin_id: self.id().to_owned(),
task_id: id.to_string(),
done,
total,
message,
},
);
}
fn task_cancelled(&self, id: u64) -> bool {
self.shared.tasks.lock().get(&id).is_some_and(|t| t.cancelled)
}
fn task_end(&self, id: u64) {
self.shared.end_task(id);
}
fn refresh_view(&self, view: &str) {
self.shared
.sink
.emit(None, PluginEvent::ViewChanged { plugin_id: self.id().to_owned(), view: view.to_owned() });
}
fn post_view_message(&self, view: &str, message: String) -> Result<(), HostError> {
let declared = [super::ui_points::VIEWS, super::ui_points::CUSTOM_EDITORS]
.iter()
.any(|point| self.shared.ui.items(point, Some(self.id())).iter().any(|i| i.key == view));
if !declared {
return Err(HostError::NotFound(format!("the plugin declares no view {view:?}")));
}
let event = PluginEvent::ViewMessage { plugin_id: self.id().to_owned(), view: view.to_owned(), message };
self.shared.sink.emit(None, event);
Ok(())
}
fn open_terminal(
&self,
client: Option<&str>,
title: String,
program: &str,
args: Vec<String>,
cwd: Option<&Path>,
) -> Result<(), HostError> {
let binary = self.approved_program(program)?;
let cwd = cwd.map(Path::to_path_buf).unwrap_or_else(|| self.shared.data_dir.join(self.id()));
let _ = std::fs::create_dir_all(&cwd);
let event = PluginEvent::OpenTerminal {
plugin_id: self.id().to_owned(),
title,
program: binary.into(),
args,
cwd: cwd.into(),
};
self.shared.sink.emit(client, event);
Ok(())
}
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
let event = PluginEvent::OpenLocation {
plugin_id: self.id().to_owned(),
path: path.to_path_buf().into(),
line,
column,
};
self.shared.sink.emit(client, event);
Ok(())
}
fn locale(&self) -> String {
self.shared.locale.read().clone()
}
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
let schema: Vec<super::ui_points::SettingSchema> = self
.shared
.ui
.items(super::ui_points::SETTINGS, Some(self.id()))
.into_iter()
.find_map(|i| serde_json::from_value(i.decl.get("properties")?.clone()).ok())?;
let setting = schema.into_iter().find(|s| s.key == key)?;
let user = self.shared.workspace().map(|w| w.plugin_settings(self.id(), root)).unwrap_or_default();
let value = user.get(key).filter(|v| setting.accepts(v)).cloned().unwrap_or(setting.default);
serde_json::to_string(&value).ok()
}
fn read_text_file(&self, real: &Path, max: usize) -> Result<String, HostError> {
// Unsaved editor contents win, as they do for project files.
if let Some(text) = self.shared.workspace().and_then(|w| w.buffer_text(real)) {
return Ok(text);
}
let meta = std::fs::metadata(real).map_err(|e| not_a_file(real, e))?;
if !meta.is_file() {
return Err(HostError::Invalid(format!("{}: not a file", real.display())));
}
if meta.len() > max as u64 {
return Err(too_large(real, max));
}
let loaded = ide_vfs::load_text(real).map_err(failed)?;
if loaded.binary {
return Err(HostError::Invalid(format!("{}: not text; read it as bytes", real.display())));
}
Ok(loaded.text)
}
fn write_text_file(&self, real: &Path, text: &str) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
// Write back in the file's own encoding, so a GBK properties file stays
// one; a file that is not there yet becomes UTF-8.
let (encoding, has_bom) = match ide_vfs::load_text(real) {
Ok(loaded) if loaded.binary => {
return Err(HostError::Invalid(format!("{}: not text; write it as bytes", real.display())));
}
Ok(loaded) => (loaded.encoding, loaded.has_bom),
Err(e) if e.kind == ide_core::error::ErrorKind::NotFound => ("utf-8".to_string(), false),
Err(e) => return Err(not_writable(real, e)),
};
ide_vfs::save_text(real, text, &encoding, has_bom).map(drop).map_err(|e| not_writable(real, e))
}
fn write_bytes_file(&self, real: &Path, bytes: &[u8]) -> Result<(), HostError> {
check_unsaved(self.shared.workspace(), real)?;
ide_vfs::write_atomic(real, bytes).map(drop).map_err(|e| not_writable(real, e))
}
}
/// A file that is not there, or cannot be read, as the plugin sees it.
fn not_a_file(path: &Path, e: std::io::Error) -> HostError {
match e.kind() {
std::io::ErrorKind::NotFound => HostError::NotFound(format!("{}: no such file", path.display())),
std::io::ErrorKind::PermissionDenied => HostError::Denied(format!("{}: {}", path.display(), "not readable")),
_ => failed(e),
}
}
fn too_large(path: &Path, max: usize) -> HostError {
HostError::Invalid(format!("{}: file is larger than {} MiB", path.display(), max >> 20))
}
/// Refuses to replace a file the editor holds unsaved changes for: the write
/// would lose them.
fn check_unsaved(workspace: Option<Arc<dyn PluginWorkspace>>, path: &Path) -> Result<(), HostError> {
match workspace {
Some(workspace) if workspace.buffer_dirty(path) => Err(HostError::Invalid(format!(
"{}: the editor has unsaved changes for it; save them first",
path.display()
))),
_ => Ok(()),
}
}
/// A write that did not land, as the plugin sees it.
fn not_writable(path: &Path, e: ide_core::error::IdeError) -> HostError {
let message = format!("{}: {}", path.display(), e.message);
match e.kind {
ide_core::error::ErrorKind::NotFound => HostError::NotFound(message),
ide_core::error::ErrorKind::PermissionDenied => HostError::Denied(message),
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(message),
_ => HostError::Failed(message),
}
}
/// [`PluginWorkspace`] over the desktop host's open projects.
pub struct HostWorkspace(pub std::sync::Weak<crate::Host>);
impl HostWorkspace {
fn projects(&self) -> Vec<Arc<crate::project::Project>> {
self.0.upgrade().map(|h| h.projects()).unwrap_or_default()
}
/// The project holding an open document at `path`, and its id.
fn open_document(&self, path: &Path) -> Option<(Arc<crate::project::Project>, ide_core::DocId)> {
self.projects().into_iter().find_map(|p| p.documents.by_path(path).map(|doc| (p, doc)))
}
}
impl PluginWorkspace for HostWorkspace {
fn projects(&self) -> Vec<PathBuf> {
let mut roots: Vec<PathBuf> = HostWorkspace::projects(self).iter().map(|p| p.root.clone()).collect();
roots.sort();
roots.dedup();
roots
}
fn buffer_text(&self, path: &Path) -> Option<String> {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn buffer_dirty(&self, path: &Path) -> bool {
let Some((project, doc)) = self.open_document(path) else { return false };
project.documents.is_dirty(doc).unwrap_or(false)
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
// The index of the project rooted at `root` (a workspace member's files are in its own).
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
project.index.files_matching(root, matches, limit)
}
fn symbols(
&self,
root: &Path,
query: &str,
limit: usize,
cancel: &CancellationToken,
deadline: Instant,
) -> Result<Vec<ide_plugin::SymbolInfo>, HostError> {
let project = HostWorkspace::projects(self)
.into_iter()
.find(|p| p.root == root)
.ok_or_else(|| HostError::NotFound("the project is no longer open".into()))?;
let stopped = || cancel.is_cancelled() || Instant::now() >= deadline;
let interrupted = || {
if cancel.is_cancelled() {
HostError::Cancelled
} else {
HostError::Failed("symbol search timed out".into())
}
};
let symbols = project
.index
.find_symbols_until(
query,
ide_index::SymbolFilter::All,
limit.min(1000),
&|path| path.starts_with(root),
&stopped,
)
.ok_or_else(interrupted)?;
let mut out = Vec::new();
for s in symbols {
if stopped() {
return Err(interrupted());
}
out.push(ide_plugin::SymbolInfo {
name: s.name,
kind: serde_json::to_value(s.kind)
.ok()
.and_then(|k| k.as_str().map(str::to_owned))
.unwrap_or_else(|| "unknown".into()),
container: s.container,
location: ide_plugin::Location { path: s.path.0, range: s.range },
});
}
if stopped() {
return Err(interrupted());
}
Ok(out)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
Some((u64::from(snap.version), snap.text))
}
fn apply_edits(&self, path: &Path, version: u64, edits: &[TextEdit]) -> Result<u64, HostError> {
let (project, doc) =
self.open_document(path).ok_or_else(|| HostError::NotFound(format!("{} is not open", path.display())))?;
let version = u32::try_from(version).map_err(|_| HostError::Stale)?;
match project.documents.apply_edits_at(doc, version, edits) {
Ok(Some(v)) => Ok(u64::from(v)),
Ok(None) => Err(HostError::Stale),
Err(e) if e.kind == ide_core::error::ErrorKind::InvalidInput => Err(HostError::Invalid(e.message)),
Err(e) => Err(HostError::Failed(e.message)),
}
}
fn trusted(&self, root: &Path) -> bool {
HostWorkspace::projects(self).iter().any(|p| p.root == root && p.is_trusted())
}
fn project_of(&self, window: &str) -> Option<PathBuf> {
self.0.upgrade()?.project(window).ok().map(|p| p.root.clone())
}
fn publish_diagnostics(
&self,
source: &str,
path: &Path,
items: Vec<ide_core::Diagnostic>,
) -> Result<(), HostError> {
let projects = HostWorkspace::projects(self);
let project = projects
.iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.ok_or_else(|| HostError::Invalid(format!("{} is not in an open project", path.display())))?;
project.sink.emit(ide_core::IdeEvent::Diagnostics {
path: path.to_path_buf().into(),
source: format!("plugin:{source}"),
items,
});
Ok(())
}
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
HostWorkspace::projects(self)
.into_iter()
.filter(|p| path.starts_with(&p.root))
.max_by_key(|p| p.root.as_os_str().len())
.and_then(|p| p.vcs_encoding(path, None).ok())
.unwrap_or_else(|| encoding_rs::UTF_8.into())
}
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
Some(p) => p.settings.effective(),
None => host.global_settings.effective(),
};
settings.plugins.get(plugin).cloned().unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api(dir: &Path, pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>) -> PluginApi {
let manifest = ide_extension::Manifest::parse(
"id = \"acme.lint\"\nname = \"Lint\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n",
)
.unwrap();
let sink: Arc<dyn PluginEventSink> = Arc::new(|_: Option<&str>, _: PluginEvent| {});
let shared = Shared::new(sink, dir.join(".data"), pins, ide_extension::UiStore::new());
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn storage_moves_an_older_storage_json_to_one_file_per_key() {
let tmp = tempfile::tempdir().unwrap();
let legacy = tmp.path().join(".data/acme.lint/storage.json");
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
std::fs::write(&legacy, r#"{"a":"1","b/c":"two"}"#).unwrap();
let api = api(tmp.path(), Arc::default());
assert_eq!(api.storage_keys("").unwrap(), ["a", "b/c"]);
assert_eq!(api.storage_get("b/c").unwrap().as_deref(), Some("two"));
assert!(!legacy.exists());
api.storage_set("a", Some("3".into())).unwrap();
api.storage_set("b/c", None).unwrap();
assert_eq!(api.storage_keys("").unwrap(), ["a"]);
assert_eq!(api.storage_get("a").unwrap().as_deref(), Some("3"));
assert_eq!(api.storage_get("b/c").unwrap(), None);
}
#[test]
fn storage_keeps_multimegabyte_indexes_after_host_recreation() {
let tmp = tempfile::tempdir().unwrap();
let value = "é".repeat(2 << 20);
{
let api = api(tmp.path(), Arc::default());
api.storage_set("index", Some(value.clone())).unwrap();
}
let api = api(tmp.path(), Arc::default());
assert_eq!(api.storage_get("index").unwrap().as_deref(), Some(value.as_str()));
api.storage_set("index", None).unwrap();
assert_eq!(api.storage_get("index").unwrap(), None);
}
#[test]
fn storage_quota_counts_utf8_keys_and_values_and_preserves_data_on_failure() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let key = "index";
let mut value = "é".repeat(((64 << 20) - key.len()) / 2);
value.push('x');
api.storage_set(key, Some(value.clone())).unwrap();
for (key, value) in [("x", String::new()), (key, format!("{value}x"))] {
let err = api.storage_set(key, Some(value)).unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains("64 MiB")), "{err}");
}
assert_eq!(api.storage_keys("").unwrap(), [key]);
assert_eq!(api.storage_get(key).unwrap().as_deref(), Some(value.as_str()));
api.storage_set(key, Some("small".into())).unwrap();
api.storage_set("other", Some("value".into())).unwrap();
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
}
#[test]
fn notification_actions_run_the_plugins_own_commands() {
let tmp = tempfile::tempdir().unwrap();
let api = api(tmp.path(), Arc::default());
let _command = api.shared.ui.insert(ide_extension::UiContribution {
plugin_id: api.id().into(),
point: "commands".into(),
key: "run".into(),
decl: serde_json::json!({}),
});
let action = |command: &str| ide_plugin::NotificationAction {
label: "Run".into(),
command: command.into(),
argument: Some("a\nb".into()),
};
let notify = |actions| api.notify(Some("main"), NotificationLevel::Info, "Done".into(), "".into(), actions);
notify(vec![]).unwrap();
notify(vec![action("run")]).unwrap();
assert!(matches!(notify(vec![action("run"), action("other.command")]), Err(HostError::NotFound(_))));
}
#[test]
fn repository_and_search_paths_match_ipc_events() {
let tmp = tempfile::tempdir().unwrap();
assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
let root = tmp.path().canonicalize().unwrap();
std::fs::create_dir(root.join("src")).unwrap();
let file = root.join("src/Example.java");
std::fs::write(&file, "class Example {}\n").unwrap();
let api = api(&root, Arc::default());
assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
}
/// A project index that lists `files` (root-relative), whatever is on disk.
struct Indexed(PathBuf, Vec<&'static str>);
impl PluginWorkspace for Indexed {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
(root == self.0).then(|| {
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
})
}
}
#[test]
fn find_files_uses_the_project_index_when_there_is_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let api = api(&root, Arc::default());
*api.shared.workspace.write() = Some(Arc::new(Indexed(
root.clone(),
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", ".git/index.xml", "pom.xml"],
)));
// Listed by the index, not walked: none of these exist on disk.
assert_eq!(
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
);
// Hidden folders are visited like any project scope; `.git` internals are not.
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 4);
assert_eq!(api.find_files(&root, ".idea/*.xml", 10).unwrap(), [to_ipc(&root.join(".idea/workspace.xml"))]);
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
// Another root: no index covers it, so the disk is walked.
let other = root.join("other");
std::fs::create_dir_all(other.join(".config/tool")).unwrap();
std::fs::write(other.join("a.xml"), "").unwrap();
std::fs::write(other.join(".config/tool/index.xml"), "").unwrap();
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
assert_eq!(
api.find_files(&other, ".config/tool/*.xml", 10).unwrap(),
[to_ipc(&other.join(".config/tool/index.xml"))]
);
}
#[test]
fn process_environment_only_adds_variables() {
let tmp = tempfile::tempdir().unwrap();
// Environment overrides must be rejected before spawn, so no executable is needed.
let binary = tmp.path().join("lint.exe");
std::fs::write(&binary, "not an executable").unwrap();
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
pins.write().insert(("acme.lint".into(), "process:lint".into()), ProgramPin::approve(&binary).unwrap());
let api = api(tmp.path(), pins);
let inherited = std::env::vars_os()
.find(|(key, _)| {
!env_name_eq(key, "PATH".as_ref())
&& key.to_str().is_some_and(|key| !key.is_empty() && !key.contains('='))
})
.unwrap()
.0
.into_string()
.unwrap();
for key in ["PATH".to_owned(), inherited] {
let cmd = ProcessCommand {
program: "lint".into(),
env: vec![(key.clone(), "replacement".into())],
..Default::default()
};
let err = api
.run_process(cmd.clone(), None, None, &CancellationToken::new(), Duration::from_secs(5))
.unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}");
let err = api.start_process(cmd, None, None).unwrap_err();
assert!(matches!(err, HostError::Invalid(ref message) if message.contains(&key)), "{err}");
}
}
#[test]
fn process_environment_uses_prepared_values_and_windows_key_rules() {
let mut cmd = StdCommand::new("unused");
ide_core::process::prepare(&mut cmd);
cmd.env("OPENIDEA_PREPARED_ENV", "login-shell");
let err = append_process_env(&mut cmd, &[("OPENIDEA_PREPARED_ENV".into(), "override".into())]).unwrap_err();
assert!(err.to_string().contains("OPENIDEA_PREPARED_ENV"));
for name in ["", "BAD=KEY", "BAD\0KEY"] {
assert!(append_process_env(&mut cmd, &[(name.into(), "value".into())]).is_err());
}
for value in ["bad\0value".to_owned(), "x".repeat(4097)] {
assert!(append_process_env(&mut cmd, &[("OPENIDEA_NEW_ENV".into(), value)]).is_err());
}
assert!(
append_process_env(
&mut cmd,
&[("OPENIDEA_NEW_ENV".into(), "a".into()), ("OPENIDEA_NEW_ENV".into(), "b".into())]
)
.is_err()
);
assert_eq!(env_name_eq("PATH".as_ref(), "Path".as_ref()), cfg!(windows));
#[cfg(windows)]
{
assert!(append_process_env(&mut cmd, &[("pAtH".into(), "override".into())]).is_err());
assert!(env_name_eq("Ä_KEY".as_ref(), "ä_key".as_ref()));
}
cmd.env_remove("OPENIDEA_REMOVED_ENV");
append_process_env(&mut cmd, &[("OPENIDEA_REMOVED_ENV".into(), "added".into())]).unwrap();
assert_eq!(cmd.get_envs().find(|(k, _)| *k == "OPENIDEA_REMOVED_ENV").unwrap().1, Some("added".as_ref()));
}
#[test]
fn process_output_preserves_partial_utf8_and_bounds_replacement_text() {
let mut bytes = vec![0xe4, 0xb8];
assert_eq!(take_text(&mut bytes, 1, false), "");
bytes.extend_from_slice(&[0xad, b'x']);
assert_eq!(take_text(&mut bytes, 1, false), "中");
assert_eq!(take_text(&mut bytes, 0, false), "x");
let mut bytes = vec![0xff; MAX_PENDING];
let text = take_text(&mut bytes, MAX_PENDING, true);
assert!(text.len() <= MAX_PENDING);
assert!(!bytes.is_empty());
assert_eq!(take_text(&mut vec![0xe4, 0xb8], 8, true), "�");
}
#[cfg(unix)]
fn shell_api(dir: &Path) -> PluginApi {
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
pins.write()
.insert(("acme.lint".into(), "process:sh".into()), ProgramPin::approve(Path::new("/bin/sh")).unwrap());
api(dir, pins)
}
#[cfg(unix)]
fn shell(script: &str) -> ProcessCommand {
ProcessCommand { program: "sh".into(), args: vec!["-c".into(), script.into()], env: Vec::new() }
}
#[cfg(unix)]
#[test]
fn process_handles_are_bounded_scoped_and_released() {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let mut ids = Vec::new();
for _ in 0..MAX_PROCESSES {
ids.push(api.start_process(shell("exec cat"), None, Some("main")).unwrap());
}
assert!(matches!(api.start_process(shell("exec cat"), None, None), Err(HostError::Invalid(_))));
let proc = api.process(ids[0]).unwrap();
api.release_process(ids[0]);
assert!(proc.state.lock().child.try_wait().unwrap().is_some());
assert!(api.process(ids[0]).is_err());
let other = api.start_process(shell("exec cat"), None, Some("other")).unwrap();
api.shared.forget_client("main");
assert!(api.process(ids[1]).is_err());
assert!(api.process(other).is_ok());
assert!(matches!(api.start_process(shell("exec cat"), None, Some("main")), Err(HostError::Cancelled)));
api.shared.kill_processes(api.id());
assert!(api.shared.processes.lock().is_empty());
}
#[cfg(unix)]
#[test]
fn process_stdin_timeout_and_stop_do_not_deadlock() {
let tmp = tempfile::tempdir().unwrap();
let api = Arc::new(shell_api(tmp.path()));
for stop in [false, true] {
let id = api.start_process(shell("exec sleep 60"), None, Some("main")).unwrap();
let proc = api.process(id).unwrap();
let writer = api.clone();
let thread = std::thread::spawn(move || {
writer.write_process(id, &"x".repeat(1 << 20), &CancellationToken::new(), Duration::from_millis(150))
});
if stop {
std::thread::sleep(Duration::from_millis(30));
api.shared.kill_processes(api.id());
}
assert!(thread.join().unwrap().is_err());
assert!(proc.state.lock().child.try_wait().unwrap().is_some());
api.release_process(id);
}
}
#[cfg(unix)]
#[test]
fn process_parent_exit_closes_descendant_pipes() {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
let before = Instant::now();
let out =
api.run_process(shell("sleep 60 & echo done"), None, None, &token, Duration::from_millis(300)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!(out.stdout.trim(), "done");
assert!(before.elapsed() < Duration::from_secs(5));
let id = api.start_process(shell("sleep 60 & echo done"), None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_millis(300), &token).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!(out.stdout.trim(), "done");
assert_eq!(api.process(id).unwrap().state.lock().readers, 0);
api.release_process(id);
}
#[cfg(unix)]
#[test]
fn process_output_is_drained_and_retention_is_bounded() {
let tmp = tempfile::tempdir().unwrap();
let api = shell_api(tmp.path());
let token = CancellationToken::new();
let cmd = shell("head -c 5242880 /dev/zero; head -c 5242880 /dev/zero >&2");
let out = api.run_process(cmd.clone(), None, None, &token, Duration::from_secs(5)).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_OUTPUT, MAX_OUTPUT));
let id = api.start_process(cmd, None, None).unwrap();
let out = api.wait_process(id, MAX_PENDING, Duration::from_secs(5), &token).unwrap();
assert_eq!(out.status, Some(0));
assert_eq!((out.stdout.len(), out.stderr.len()), (MAX_PENDING, MAX_PENDING));
let state = api.process(id).unwrap();
let state = state.state.lock();
assert!(state.stdout.is_empty() && state.stderr.is_empty());
drop(state);
api.release_process(id);
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
let bin = tmp.path().join("lint");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
let pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>> = Arc::default();
let api = api(tmp.path(), pins.clone());
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found on PATH when this permission was allowed")
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
"{err}"
);
pins.write().insert(("acme.lint".into(), "process:lint".into()), ProgramPin::approve(&bin).unwrap());
assert_eq!(api.approved_program("lint").unwrap(), bin);
std::fs::write(&bin, "#!/bin/sh\necho replaced\n").unwrap();
assert!(
matches!(api.approved_program("lint"), Err(HostError::Denied(e)) if e.contains("changed since it was approved"))
);
}
}
/// The window a user action invoked the plugin from: browser tabs and clipboard writes
/// follow what the user did, never background work (which would reach every window).
fn from_window<'a>(client: Option<&'a str>, what: &str) -> Result<&'a str, HostError> {
client.ok_or_else(|| HostError::Invalid(format!("can only {what} from a command the user invoked")))
}
/// `url` without the `user:password@` a remote may carry (`https://user:token@host/…`):
/// `vcs.read` must not hand out credentials. scp-style `git@host:path` has no password.
fn without_credentials(url: &str) -> String {
let Some((scheme, rest)) = url.split_once("://") else { return url.to_owned() };
let authority_end = rest.find('/').unwrap_or(rest.len());
match rest[..authority_end].rfind('@') {
Some(at) => format!("{scheme}://{}", &rest[at + 1..]),
None => url.to_owned(),
}
}
#[cfg(test)]
mod credential_tests {
use super::*;
#[test]
fn remote_urls_lose_their_credentials() {
for (url, expected) in [
("https://user:[email protected]/o/r.git", "https://github.com/o/r.git"),
("https://[email protected]/o/r", "https://gitlab.com/o/r"),
("ssh://git@host:2222/o/r.git", "ssh://host:2222/o/r.git"),
("https://github.com/o/r.git", "https://github.com/o/r.git"),
("https://github.com/o/r@v1", "https://github.com/o/r@v1"),
("[email protected]:o/r.git", "[email protected]:o/r.git"),
("/srv/git/r.git", "/srv/git/r.git"),
] {
assert_eq!(without_credentials(url), expected, "{url}");
}
assert!(matches!(from_window(None, "open a URL"), Err(HostError::Invalid(_))));
assert_eq!(from_window(Some("main"), "open a URL").unwrap(), "main");
}
}