diff --git a/Cargo.lock b/Cargo.lock index b18613c3a8..b742f00b96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3763,6 +3763,7 @@ dependencies = [ "shellexpand", "smallvec", "tempfile", + "test-case", "thiserror 2.0.20", "toml 1.1.4+spec-1.1.0", "tracing", diff --git a/agents/references/language-server-threat-model.md b/agents/references/language-server-threat-model.md index 65a65e9430..3d1bfb5b63 100644 --- a/agents/references/language-server-threat-model.md +++ b/agents/references/language-server-threat-model.md @@ -14,6 +14,7 @@ when deciding which inputs are trusted. Browser integrations have a separate - **Trusted local input:** the editor, its extensions and their bundled files, the local machine and its file system, and the user's configuration and trust decisions. +Ruff treats workspaces as trusted unless the server starts with `--untrusted-workspace`. ty treats workspaces as trusted unless `untrustedWorkspace` is true at initialization. Trust extends to everything in the workspace, including source code, configuration, notebooks, and the targets of symlinks that point outside it. diff --git a/crates/ruff/src/args.rs b/crates/ruff/src/args.rs index 4b3cfa131d..de5d497a68 100644 --- a/crates/ruff/src/args.rs +++ b/crates/ruff/src/args.rs @@ -627,6 +627,10 @@ pub struct FormatCommand { #[derive(Copy, Clone, Debug, clap::Parser)] pub struct ServerCommand { + /// Treat all workspaces as untrusted, disabling the uv formatter backend. + #[arg(long)] + untrusted_workspace: bool, + /// Enable preview mode. Use `--no-preview` to disable. /// /// This enables unstable server features and turns on the preview mode for the linter @@ -638,6 +642,14 @@ pub struct ServerCommand { } impl ServerCommand { + pub(crate) fn resolve_workspace_trust(self) -> ruff_server::WorkspaceTrust { + if self.untrusted_workspace { + ruff_server::WorkspaceTrust::Untrusted + } else { + ruff_server::WorkspaceTrust::Trusted + } + } + pub(crate) fn resolve_preview(self) -> Option { resolve_bool_arg(self.preview, self.no_preview) } diff --git a/crates/ruff/src/commands/server.rs b/crates/ruff/src/commands/server.rs index 837662e7d3..eba983ccb8 100644 --- a/crates/ruff/src/commands/server.rs +++ b/crates/ruff/src/commands/server.rs @@ -1,7 +1,11 @@ use crate::ExitStatus; use anyhow::Result; +use ruff_server::WorkspaceTrust; -pub(crate) fn run_server(preview: Option) -> Result { - ruff_server::run(preview)?; +pub(crate) fn run_server( + preview: Option, + workspace_trust: WorkspaceTrust, +) -> Result { + ruff_server::run(preview, workspace_trust)?; Ok(ExitStatus::Success) } diff --git a/crates/ruff/src/lib.rs b/crates/ruff/src/lib.rs index f64359e567..b0cbd385b2 100644 --- a/crates/ruff/src/lib.rs +++ b/crates/ruff/src/lib.rs @@ -231,7 +231,7 @@ fn analyze_graph( } fn server(args: ServerCommand) -> Result { - commands::server::run_server(args.resolve_preview()) + commands::server::run_server(args.resolve_preview(), args.resolve_workspace_trust()) } pub fn check(args: CheckCommand, global_options: GlobalConfigArgs) -> Result { diff --git a/crates/ruff_server/Cargo.toml b/crates/ruff_server/Cargo.toml index 7140d72de8..82e1d093d3 100644 --- a/crates/ruff_server/Cargo.toml +++ b/crates/ruff_server/Cargo.toml @@ -54,6 +54,7 @@ dunce = { workspace = true } regex = { workspace = true } smallvec = { workspace = true } tempfile = { workspace = true } +test-case = { workspace = true } [features] test-uv = [] diff --git a/crates/ruff_server/src/lib.rs b/crates/ruff_server/src/lib.rs index 2df7a8bdfd..366d5efd24 100644 --- a/crates/ruff_server/src/lib.rs +++ b/crates/ruff_server/src/lib.rs @@ -36,11 +36,18 @@ const NOTEBOOK_SOURCE_ORGANIZE_IMPORTS_RUFF: CodeActionKind = /// result type is needed. pub(crate) type Result = anyhow::Result; +/// Whether the server may execute code from the workspace. +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum WorkspaceTrust { + Trusted, + Untrusted, +} + fn version() -> &'static str { ruff_linter::VERSION } -pub fn run(preview: Option) -> Result<()> { +pub fn run(preview: Option, workspace_trust: WorkspaceTrust) -> Result<()> { let four = NonZeroUsize::new(4).unwrap(); // by default, we set the number of worker threads to `num_cpus`, with a maximum of 4. @@ -50,7 +57,7 @@ pub fn run(preview: Option) -> Result<()> { let (connection, io_threads) = ConnectionInitializer::stdio(); - let server_result = Server::new(worker_threads, connection, preview, false) + let server_result = Server::new(worker_threads, connection, preview, workspace_trust, false) .context("Failed to start server")? .run(); diff --git a/crates/ruff_server/src/server.rs b/crates/ruff_server/src/server.rs index c046266d44..2cbb599438 100644 --- a/crates/ruff_server/src/server.rs +++ b/crates/ruff_server/src/server.rs @@ -24,11 +24,11 @@ use types::WorkspaceFoldersServerCapabilities; pub use self::connection::ConnectionInitializer; pub(crate) use self::connection::ConnectionSender; use self::schedule::spawn_main_loop; -use crate::PositionEncoding; pub(crate) use crate::server::main_loop::MainLoopSender; pub(crate) use crate::server::main_loop::{Event, MainLoopReceiver}; use crate::session::{AllOptions, Client, Session}; use crate::workspace::Workspaces; +use crate::{PositionEncoding, WorkspaceTrust}; pub(crate) use api::Error; mod api; @@ -52,6 +52,7 @@ impl Server { worker_threads: NonZeroUsize, connection: ConnectionInitializer, preview: Option, + workspace_trust: WorkspaceTrust, is_test: bool, ) -> crate::Result { let (id, init_params) = connection.initialize_start()?; @@ -121,6 +122,7 @@ impl Server { global, &workspaces, &client, + workspace_trust, )?, client_capabilities, }) diff --git a/crates/ruff_server/src/server/api/requests/format.rs b/crates/ruff_server/src/server/api/requests/format.rs index 36948e5266..4b201613b1 100644 --- a/crates/ruff_server/src/server/api/requests/format.rs +++ b/crates/ruff_server/src/server/api/requests/format.rs @@ -45,10 +45,7 @@ impl super::BackgroundDocumentRequestHandler for Format { pub(super) fn format_full_document(snapshot: &DocumentSnapshot) -> Result { let mut fixes = Fixes::default(); let query = snapshot.query(); - let backend = snapshot - .client_settings() - .editor_settings() - .format_backend(); + let backend = snapshot.format_backend(); match snapshot.query() { DocumentQuery::Notebook { notebook, .. } => { @@ -84,10 +81,7 @@ fn format_document(snapshot: &DocumentSnapshot) -> Result .context("Failed to get text document for the format request") .unwrap(); let query = snapshot.query(); - let backend = snapshot - .client_settings() - .editor_settings() - .format_backend(); + let backend = snapshot.format_backend(); format_text_document( text_document, query, diff --git a/crates/ruff_server/src/server/api/requests/format_range.rs b/crates/ruff_server/src/server/api/requests/format_range.rs index 3329a3358d..1e3d6f0dc7 100644 --- a/crates/ruff_server/src/server/api/requests/format_range.rs +++ b/crates/ruff_server/src/server/api/requests/format_range.rs @@ -47,10 +47,7 @@ fn format_document_range( .context("Failed to get text document for the format range request") .unwrap(); let query = snapshot.query(); - let backend = snapshot - .client_settings() - .editor_settings() - .format_backend(); + let backend = snapshot.format_backend(); format_text_document_range(text_document, range, query, snapshot.encoding(), backend) } diff --git a/crates/ruff_server/src/session.rs b/crates/ruff_server/src/session.rs index a6dda25a22..0b3a1d35a1 100644 --- a/crates/ruff_server/src/session.rs +++ b/crates/ruff_server/src/session.rs @@ -7,10 +7,11 @@ use lsp_types::{ClientCapabilities, FileEvent, NotebookDocumentCellChanges, Uri} use settings::ClientSettings; use crate::edit::{DocumentKey, DocumentVersion, NotebookDocument}; +use crate::format::FormatBackend; use crate::session::request_queue::RequestQueue; use crate::session::settings::GlobalClientSettings; use crate::workspace::Workspaces; -use crate::{PositionEncoding, TextDocument}; +use crate::{PositionEncoding, TextDocument, WorkspaceTrust}; pub(crate) use self::capabilities::ResolvedClientCapabilities; pub(crate) use self::index::DocumentQuery; @@ -34,6 +35,9 @@ pub(crate) struct Session { /// Global settings provided by the client. global_settings: GlobalClientSettings, + /// Set at startup so client settings cannot enable external formatting in an untrusted workspace. + workspace_trust: WorkspaceTrust, + /// Tracks what LSP features the client supports and doesn't support. resolved_client_capabilities: Arc, @@ -51,6 +55,7 @@ pub(crate) struct DocumentSnapshot { client_settings: Arc, document_ref: index::DocumentQuery, position_encoding: PositionEncoding, + workspace_trust: WorkspaceTrust, } impl Session { @@ -60,11 +65,13 @@ impl Session { global: GlobalClientSettings, workspaces: &Workspaces, client: &Client, + workspace_trust: WorkspaceTrust, ) -> crate::Result { Ok(Self { position_encoding, index: index::Index::new(workspaces, &global, client)?, global_settings: global, + workspace_trust, resolved_client_capabilities: Arc::new(ResolvedClientCapabilities::new( client_capabilities, )), @@ -104,6 +111,7 @@ impl Session { .unwrap_or_else(|| self.global_settings.to_settings_arc()), document_ref: self.index.make_document_ref(key, &self.global_settings)?, position_encoding: self.position_encoding, + workspace_trust: self.workspace_trust, }) } @@ -215,6 +223,21 @@ impl Session { } impl DocumentSnapshot { + pub(crate) fn format_backend(&self) -> FormatBackend { + let backend = self.client_settings.editor_settings().format_backend(); + match self.workspace_trust { + WorkspaceTrust::Trusted => backend, + WorkspaceTrust::Untrusted => { + if backend == FormatBackend::Uv { + tracing::info!( + "Using the internal formatter because the workspace is untrusted; the uv backend is disabled" + ); + } + FormatBackend::Internal + } + } + } + pub(crate) fn resolved_client_capabilities(&self) -> &ResolvedClientCapabilities { &self.resolved_client_capabilities } @@ -242,3 +265,49 @@ impl DocumentSnapshot { ) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use anyhow::{Context, Result}; + use lsp_types::Uri; + use serde_json::json; + use test_case::test_case; + + use super::index::Index; + use super::options::GlobalOptions; + use super::{Client, Session}; + use crate::format::FormatBackend; + use crate::session::request_queue::RequestQueue; + use crate::{PositionEncoding, TextDocument, WorkspaceTrust}; + + #[test_case(WorkspaceTrust::Untrusted, FormatBackend::Internal)] + #[test_case(WorkspaceTrust::Trusted, FormatBackend::Uv)] + fn format_backend(workspace_trust: WorkspaceTrust, expected: FormatBackend) -> Result<()> { + let (main_loop_sender, _) = crossbeam::channel::unbounded(); + let (client_sender, _) = crossbeam::channel::unbounded(); + let client = Client::new(main_loop_sender, client_sender); + let options: GlobalOptions = serde_json::from_value(json!({ + "format": {"backend": "uv"}, + }))?; + let mut session = Session { + index: Index::default(), + position_encoding: PositionEncoding::default(), + global_settings: options.into_settings(client), + workspace_trust, + resolved_client_capabilities: Arc::default(), + request_queue: RequestQueue::new(), + shutdown_requested: false, + }; + let uri: Uri = "untitled:test.py".parse()?; + session.open_text_document(uri.clone(), TextDocument::new(String::new(), 1)); + let snapshot = session + .take_snapshot(uri) + .context("missing document snapshot")?; + + assert_eq!(snapshot.format_backend(), expected); + + Ok(()) + } +} diff --git a/crates/ruff_server/tests/e2e/main.rs b/crates/ruff_server/tests/e2e/main.rs index d8e53ee40d..1cd945105f 100644 --- a/crates/ruff_server/tests/e2e/main.rs +++ b/crates/ruff_server/tests/e2e/main.rs @@ -66,7 +66,7 @@ use lsp_types::{ DidChangeWorkspaceFoldersNotification, DidCloseTextDocumentNotification, DidOpenTextDocumentNotification, ExitNotification, InitializedNotification, Notification, }; -use ruff_server::{ConnectionInitializer, LogLevel, Server, init_logging}; +use ruff_server::{ConnectionInitializer, LogLevel, Server, WorkspaceTrust, init_logging}; use rustc_hash::FxHashMap; use tempfile::TempDir; @@ -207,7 +207,13 @@ impl TestServer { // TODO: This should probably be configurable to test concurrency issues let worker_threads = NonZeroUsize::new(1).unwrap(); - match Server::new(worker_threads, server_connection, None, true) { + match Server::new( + worker_threads, + server_connection, + None, + WorkspaceTrust::Trusted, + true, + ) { Ok(server) => { if let Err(err) = server.run() { panic!("Server stopped with error: {err:?}"); diff --git a/docs/editors/settings.md b/docs/editors/settings.md index 5d5949a7fd..ab9a9c5b83 100644 --- a/docs/editors/settings.md +++ b/docs/editors/settings.md @@ -981,6 +981,10 @@ The backend to use for formatting files. Following options are available: For `internal`, the formatter version will match the selected Ruff version while for `uv`, the formatter version may differ. +Starting the server with `ruff server --untrusted-workspace` disables the uv backend. With this flag, +the server always uses the internal formatter, regardless of this setting. This prevents workspace +configuration from causing uv to execute untrusted code. + **Default value**: `"internal"` **Type**: `"internal" | "uv"`