diff --git a/Cargo.lock b/Cargo.lock index ec7ae6e..d4becca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -368,6 +368,12 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "downcast" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -465,6 +471,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fragile" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" + [[package]] name = "fslock" version = "0.2.1" @@ -1158,6 +1170,32 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "mockall" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +dependencies = [ + "cfg-if", + "downcast", + "fragile", + "mockall_derive", + "predicates", + "predicates-tree", +] + +[[package]] +name = "mockall_derive" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +dependencies = [ + "cfg-if", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -1361,6 +1399,32 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "predicates-core", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -1554,20 +1618,21 @@ dependencies = [ [[package]] name = "rstest" -version = "0.26.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" +checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682" dependencies = [ + "futures", "futures-timer", - "futures-util", "rstest_macros", + "rustc_version", ] [[package]] name = "rstest_macros" -version = "0.26.1" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" +checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d" dependencies = [ "cfg-if", "glob", @@ -1955,8 +2020,8 @@ dependencies = [ "dirs", "futures", "itertools", - "lazy_static", "marked-yaml", + "mockall", "rand", "regex", "reqwest", @@ -2019,6 +2084,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.16" diff --git a/Cargo.toml b/Cargo.toml index d3ad9cc..6a8698f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,8 +34,10 @@ tracing = "0.1.41" tracing-subscriber = "0.3.19" [dev-dependencies] -itertools = "0.14.0" -lazy_static = "1.5.0" -rstest = "0.26.1" -tracing-subscriber = { version = "0.3.19", features = ["fmt", "env-filter"] } +rstest = "0.21.0" +serial_test = "3.1.1" tracing-test = "0.2.5" +mockall = "0.13.0" + +[features] +default = [] diff --git a/src/app/commands.rs b/src/app/commands.rs deleted file mode 100644 index 38dae06..0000000 --- a/src/app/commands.rs +++ /dev/null @@ -1,328 +0,0 @@ -use std::{ - path::{Path, PathBuf}, - str::FromStr, - sync::Arc, -}; - -use itertools::Itertools; -use tower_lsp::{ - jsonrpc::{Error, Result}, - lsp_types::{Diagnostic, DiagnosticSeverity, MessageType, Position, Range}, -}; - -use crate::{ - domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}, - infra::parse_dockerfile, -}; - -use super::{ - ImageBuilder, ImageScanner, InMemoryDocumentDatabase, LSPClient, lsp_server::WithContext, -}; - -pub struct CommandExecutor { - client: C, - document_database: InMemoryDocumentDatabase, -} - -impl CommandExecutor { - pub fn new(client: C, document_database: InMemoryDocumentDatabase) -> Self { - Self { - client, - document_database, - } - } - - fn image_from_line<'a>(&self, line: u32, contents: &'a str) -> Option<&'a str> { - let line_number: usize = line.try_into().ok()?; - let line_that_contains_from = contents.lines().nth(line_number)?; - line_that_contains_from - .strip_prefix("FROM ")? - .split_whitespace() - .next() - } -} - -impl CommandExecutor -where - C: LSPClient, -{ - pub async fn update_document_with_text(&self, uri: &str, text: &str) { - self.document_database.write_document_text(uri, text).await; - self.document_database.remove_diagnostics(uri).await; - let _ = self.publish_all_diagnostics().await; - } - - pub async fn show_message(&self, message_type: MessageType, message: &str) { - self.client.show_message(message_type, message).await; - } - - async fn publish_all_diagnostics(&self) -> Result<()> { - let all_diagnostics = self.document_database.all_diagnostics().await; - for (url, diagnostics) in all_diagnostics { - self.client - .publish_diagnostics(&url, diagnostics, None) - .await; - } - Ok(()) - } -} - -impl CommandExecutor -where - C: LSPClient, -{ - pub async fn scan_image( - &self, - uri: &str, - range: Range, - image_name: &str, - image_scanner: &impl ImageScanner, - ) -> Result<()> { - self.show_message( - MessageType::INFO, - format!("Starting scan of {image_name}...").as_str(), - ) - .await; - - let scan_result = image_scanner - .scan_image(image_name) - .await - .map_err(|e| Error::internal_error().with_message(e.to_string()))?; - - self.show_message( - MessageType::INFO, - format!("Finished scan of {image_name}.").as_str(), - ) - .await; - - let diagnostic = { - let mut diagnostic = Diagnostic { - range, - severity: Some(DiagnosticSeverity::HINT), - message: "No vulnerabilities found.".to_owned(), - ..Default::default() - }; - - if !scan_result.vulnerabilities().is_empty() { - let vulns = scan_result - .vulnerabilities() - .iter() - .counts_by(|v| v.severity()); - diagnostic.message = format!( - "Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - image_name, - vulns.get(&Severity::Critical).unwrap_or(&0_usize), - vulns.get(&Severity::High).unwrap_or(&0_usize), - vulns.get(&Severity::Medium).unwrap_or(&0_usize), - vulns.get(&Severity::Low).unwrap_or(&0_usize), - vulns.get(&Severity::Negligible).unwrap_or(&0_usize), - ); - - diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() { - DiagnosticSeverity::INFORMATION - } else { - DiagnosticSeverity::ERROR - }); - } - - diagnostic - }; - - self.document_database.remove_diagnostics(uri).await; - self.document_database - .append_document_diagnostics(uri, &[diagnostic]) - .await; - self.publish_all_diagnostics().await - } - - pub async fn build_and_scan_from_file( - &self, - uri: &Path, - line: u32, - image_builder: &impl ImageBuilder, - image_scanner: &impl ImageScanner, - ) -> Result<()> { - let document_text = self - .document_database - .read_document_text(uri.to_str().unwrap_or_default()) - .await - .ok_or_else(|| { - Error::internal_error().with_message("unable to obtain document to scan") - })?; - - let uri_without_file_path = uri - .to_str() - .and_then(|s| s.strip_prefix("file://")) - .ok_or_else(|| { - Error::internal_error().with_message("unable to strip prefix file:// from uri") - })?; - - self.show_message( - MessageType::INFO, - format!("Starting build of {uri_without_file_path}...").as_str(), - ) - .await; - - let build_result = image_builder - .build_image(&PathBuf::from_str(uri_without_file_path).unwrap()) - .await - .map_err(|e| Error::internal_error().with_message(e.to_string()))?; - - self.show_message( - MessageType::INFO, - format!( - "Temporal image built '{}', starting scan...", - &build_result.image_name - ) - .as_str(), - ) - .await; - - let scan_result = image_scanner - .scan_image(&build_result.image_name) - .await - .map_err(|e| Error::internal_error().with_message(e.to_string()))?; - - self.show_message( - MessageType::INFO, - format!("Finished scan of {}.", &build_result.image_name).as_str(), - ) - .await; - - let diagnostic = diagnostic_for_image(line, &document_text, &scan_result); - let diagnostics_per_layer = diagnostics_for_layers(&document_text, &scan_result)?; - - self.document_database - .remove_diagnostics(uri.to_str().unwrap()) - .await; - self.document_database - .append_document_diagnostics(uri.to_str().unwrap(), &[diagnostic]) - .await; - self.document_database - .append_document_diagnostics(uri.to_str().unwrap(), &diagnostics_per_layer) - .await; - self.publish_all_diagnostics().await - } -} - -pub fn diagnostics_for_layers( - document_text: &str, - scan_result: &ScanResult, -) -> Result> { - let instructions = parse_dockerfile(document_text); - let layers = &scan_result.layers(); - - let mut instr_idx = instructions.len().checked_sub(1); - let mut layer_idx = layers.len().checked_sub(1); - - let mut diagnostics = Vec::new(); - - while let (Some(i), Some(l)) = (instr_idx, layer_idx) { - let instr = &instructions[i]; - let layer = &layers[l]; - - if instr.keyword == "FROM" { - break; - } - - instr_idx = instr_idx.and_then(|x| x.checked_sub(1)); - layer_idx = layer_idx.and_then(|x| x.checked_sub(1)); - - if !layer.vulnerabilities().is_empty() { - let vulns = layer.vulnerabilities().iter().counts_by(|v| v.severity()); - let msg = format!( - "Vulnerabilities found in layer: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - vulns.get(&Severity::Critical).unwrap_or(&0_usize), - vulns.get(&Severity::High).unwrap_or(&0_usize), - vulns.get(&Severity::Medium).unwrap_or(&0_usize), - vulns.get(&Severity::Low).unwrap_or(&0_usize), - vulns.get(&Severity::Negligible).unwrap_or(&0_usize), - ); - let diagnostic = Diagnostic { - range: instr.range, - severity: Some(DiagnosticSeverity::WARNING), - message: msg, - ..Default::default() - }; - - diagnostics.push(diagnostic); - - fill_vulnerability_hints_for_layer(layer, instr.range, &mut diagnostics) - } - } - - Ok(diagnostics) -} - -fn fill_vulnerability_hints_for_layer( - layer: &Arc, - range: Range, - diagnostics: &mut Vec, -) { - let vulns_per_severity = layer - .vulnerabilities() - .iter() - .cloned() - .sorted_by_key(|v| v.severity()); - - // TODO(fede): eventually we would want to add here a .take() to truncate the number - // of vulnerabilities shown as hint per layer. - vulns_per_severity.for_each(|vuln| { - let url = format!("https://nvd.nist.gov/vuln/detail/{}", vuln.cve()); - diagnostics.push(Diagnostic { - range, - severity: Some(DiagnosticSeverity::HINT), - message: format!( - "Vulnerability: {} ({:?}) {}", - vuln.cve(), - vuln.severity(), - url - ), - ..Default::default() - }); - }); -} - -fn diagnostic_for_image(line: u32, document_text: &str, scan_result: &ScanResult) -> Diagnostic { - let range_for_selected_line = Range::new( - Position::new(line, 0), - Position::new( - line, - document_text - .lines() - .nth(line as usize) - .map(|x| x.len() as u32) - .unwrap_or(u32::MAX), - ), - ); - - let mut diagnostic = Diagnostic { - range: range_for_selected_line, - severity: Some(DiagnosticSeverity::HINT), - message: "No vulnerabilities found.".to_owned(), - ..Default::default() - }; - - if !scan_result.vulnerabilities().is_empty() { - let vulns = scan_result - .vulnerabilities() - .iter() - .counts_by(|v| v.severity()); - diagnostic.message = format!( - "Total vulnerabilities found: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - vulns.get(&Severity::Critical).unwrap_or(&0_usize), - vulns.get(&Severity::High).unwrap_or(&0_usize), - vulns.get(&Severity::Medium).unwrap_or(&0_usize), - vulns.get(&Severity::Low).unwrap_or(&0_usize), - vulns.get(&Severity::Negligible).unwrap_or(&0_usize), - ); - - diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() { - DiagnosticSeverity::INFORMATION - } else { - DiagnosticSeverity::ERROR - }); - } - - diagnostic -} diff --git a/src/app/component_factory.rs b/src/app/component_factory.rs index bdf72aa..4720a72 100644 --- a/src/app/component_factory.rs +++ b/src/app/component_factory.rs @@ -1,114 +1,58 @@ use std::env::VarError; -use bollard::Docker; use serde::Deserialize; use thiserror::Error; +use tower_lsp::jsonrpc::{Error as LspError, ErrorCode}; -use crate::infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner}; +use super::{ImageBuilder, ImageScanner}; #[derive(Clone, Debug, Default, Deserialize)] pub struct Config { - sysdig: SysdigConfig, + pub sysdig: SysdigConfig, } #[derive(Clone, Debug, Default, Deserialize)] pub struct SysdigConfig { - api_url: String, - api_token: Option, + #[serde(alias = "apiUrl")] + pub api_url: String, + #[serde(alias = "apiToken")] + pub api_token: Option, } -#[derive(Clone, Default)] -pub struct ComponentFactory { - config: Option, +pub struct Components { + pub scanner: Box, + pub builder: Box, +} - scanner: Option, - builder: Option, +pub trait ComponentFactory: Send + Sync { + fn create_components(&self, config: Config) -> Result; } #[derive(Error, Debug)] pub enum ComponentFactoryError { - #[error("the configuration has not been provided")] - ConfigurationNotProvided, - #[error("unable to retrieve sysdig api token from env var: {0}")] UnableToRetrieveAPITokenFromEnvVar(#[from] VarError), #[error("docker client error: {0:?}")] - DockerClientError(#[from] bollard::errors::Error), + DockerClientError(String), } -impl ComponentFactory { - pub fn initialize_with(&mut self, config: Config) { - self.config.replace(config); - self.scanner.take(); - } - - pub fn image_scanner(&mut self) -> Result { - if self.scanner.is_some() { - return Ok(self.scanner.clone().unwrap()); - } - - let Some(config) = &self.config else { - return Err(ComponentFactoryError::ConfigurationNotProvided); +impl From for LspError { + fn from(err: ComponentFactoryError) -> Self { + let (code, message) = match err { + ComponentFactoryError::UnableToRetrieveAPITokenFromEnvVar(e) => ( + ErrorCode::InternalError, + format!("Could not read SECURE_API_TOKEN from environment: {}", e), + ), + ComponentFactoryError::DockerClientError(e) => ( + ErrorCode::InternalError, + format!("Failed to connect to Docker: {}", e), + ), }; - - let token = config - .sysdig - .api_token - .clone() - .map(Ok) - .unwrap_or_else(|| std::env::var("SECURE_API_TOKEN").map(SysdigAPIToken))?; - - let image_scanner = SysdigImageScanner::new(config.sysdig.api_url.clone(), token); - - self.scanner.replace(image_scanner); - Ok(self.scanner.clone().unwrap()) - } - - pub fn image_builder(&mut self) -> Result { - if self.builder.is_some() { - return Ok(self.builder.clone().unwrap()); + LspError { + code, + message: message.into(), + data: None, } - - let docker_client = Docker::connect_with_local_defaults()?; - let image_builder = DockerImageBuilder::new(docker_client); - - self.builder.replace(image_builder); - Ok(self.builder.clone().unwrap()) - } -} - -#[cfg(test)] -mod test { - use super::{ComponentFactory, Config}; - - #[test] - fn it_loads_the_factory_uninit() { - let factory = ComponentFactory::default(); - - assert!(factory.config.is_none()); - } - - #[test] - fn it_fails_to_create_the_scanner_without_config() { - let mut factory = ComponentFactory::default(); - - assert!(factory.image_scanner().is_err()); - } - - #[test] - fn it_creates_a_scanner_after_initializing() { - let mut factory = ComponentFactory::default(); - - factory.initialize_with(Config::default()); - - assert!(factory.image_scanner().is_ok()); - } - - #[test] - fn it_creates_a_builder_without_config() { - let mut factory = ComponentFactory::default(); - - assert!(factory.image_builder().is_ok()); } } diff --git a/src/app/lsp_interactor.rs b/src/app/lsp_interactor.rs new file mode 100644 index 0000000..2123612 --- /dev/null +++ b/src/app/lsp_interactor.rs @@ -0,0 +1,59 @@ +use tower_lsp::{ + jsonrpc::Result, + lsp_types::{Diagnostic, MessageType}, +}; + +use super::{InMemoryDocumentDatabase, LSPClient}; + +pub struct LspInteractor { + client: C, + document_database: InMemoryDocumentDatabase, +} + +impl LspInteractor { + pub fn new(client: C, document_database: InMemoryDocumentDatabase) -> Self { + Self { + client, + document_database, + } + } +} + +impl LspInteractor +where + C: LSPClient, +{ + pub async fn update_document_with_text(&self, uri: &str, text: &str) { + self.document_database.write_document_text(uri, text).await; + self.document_database.remove_diagnostics(uri).await; + let _ = self.publish_all_diagnostics().await; + } + + pub async fn show_message(&self, message_type: MessageType, message: &str) { + self.client.show_message(message_type, message).await; + } + + pub async fn publish_all_diagnostics(&self) -> Result<()> { + let all_diagnostics = self.document_database.all_diagnostics().await; + for (url, diagnostics) in all_diagnostics { + self.client + .publish_diagnostics(&url, diagnostics, None) + .await; + } + Ok(()) + } + + pub async fn read_document_text(&self, uri: &str) -> Option { + self.document_database.read_document_text(uri).await + } + + pub async fn remove_diagnostics(&self, uri: &str) { + self.document_database.remove_diagnostics(uri).await + } + + pub async fn append_document_diagnostics(&self, uri: &str, diagnostics: &[Diagnostic]) { + self.document_database + .append_document_diagnostics(uri, diagnostics) + .await + } +} diff --git a/src/app/lsp_server.rs b/src/app/lsp_server.rs deleted file mode 100644 index 6e6615a..0000000 --- a/src/app/lsp_server.rs +++ /dev/null @@ -1,430 +0,0 @@ -use std::borrow::Cow; -use std::path::PathBuf; -use std::str::FromStr; - -use serde_json::{Value, json}; -use tokio::sync::RwLock; -use tower_lsp::LanguageServer; -use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; -use tower_lsp::lsp_types::{ - CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, - CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, - DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, - ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, -}; -use tracing::{debug, info}; - -use super::commands::CommandExecutor; -use super::component_factory::{ComponentFactory, Config}; -use super::queries::QueryExecutor; -use super::{InMemoryDocumentDatabase, LSPClient}; -use crate::infra::{parse_compose_file, parse_dockerfile}; - -pub struct LSPServer { - command_executor: CommandExecutor, - query_executor: QueryExecutor, - component_factory: RwLock, -} - -impl LSPServer { - pub fn new(client: C) -> LSPServer { - let document_database = InMemoryDocumentDatabase::default(); - - LSPServer { - command_executor: CommandExecutor::new(client, document_database.clone()), - query_executor: QueryExecutor::new(document_database.clone()), - component_factory: Default::default(), // to be initialized in the initialize method of the LSP - } - } -} - -impl LSPServer -where - C: LSPClient + Send + Sync + 'static, -{ - async fn initialize_component_factory_with(&self, config: &Value) -> Result<()> { - let Ok(config) = serde_json::from_value::(config.clone()) else { - return Err(Error::internal_error() - .with_message(format!("unable to transform json into config: {config}"))); - }; - - debug!("updating with configuration: {config:?}"); - - self.component_factory.write().await.initialize_with(config); - - debug!("updated configuration"); - Ok(()) - } -} - -pub enum SupportedCommands { - ExecuteBaseImageScan, - ExecuteBuildAndScan, -} - -impl std::fmt::Display for SupportedCommands { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::ExecuteBaseImageScan => "sysdig-lsp.execute-scan", - Self::ExecuteBuildAndScan => "sysdig-lsp.execute-build-and-scan", - }) - } -} - -impl TryFrom<&str> for SupportedCommands { - type Error = String; - - fn try_from(value: &str) -> std::result::Result { - match value { - "sysdig-lsp.execute-scan" => Ok(SupportedCommands::ExecuteBaseImageScan), - "sysdig-lsp.execute-build-and-scan" => Ok(SupportedCommands::ExecuteBuildAndScan), - _ => Err(format!("command not supported: {value}")), - } - } -} - -struct CommandInfo { - title: String, - command: String, - arguments: Option>, - range: Range, -} - -impl LSPServer -where - C: LSPClient + Send + Sync + 'static, -{ - fn generate_commands_for_uri( - &self, - uri: &tower_lsp::lsp_types::Url, - content: &str, - ) -> Vec { - let file_uri = uri.as_str(); - - if file_uri.contains("docker-compose.yml") - || file_uri.contains("compose.yml") - || file_uri.contains("docker-compose.yaml") - || file_uri.contains("compose.yaml") - { - self.generate_compose_commands(uri, content) - } else { - self.generate_dockerfile_commands(uri, content) - } - } - - fn generate_compose_commands( - &self, - uri: &tower_lsp::lsp_types::Url, - content: &str, - ) -> Vec { - let mut commands = vec![]; - if let Ok(instructions) = parse_compose_file(content) { - for instruction in instructions { - commands.push(CommandInfo { - title: "Scan base image".to_string(), - command: SupportedCommands::ExecuteBaseImageScan.to_string(), - arguments: Some(vec![ - json!(uri), - json!(instruction.range), - json!(instruction.image_name), - ]), - range: instruction.range, - }); - } - } - commands - } - - fn generate_dockerfile_commands( - &self, - uri: &tower_lsp::lsp_types::Url, - content: &str, - ) -> Vec { - let mut commands = vec![]; - let instructions = parse_dockerfile(content); - if let Some(last_from_instruction) = instructions - .iter() - .filter(|instruction| instruction.keyword == "FROM") - .next_back() - { - let range = last_from_instruction.range; - let line = last_from_instruction.range.start.line; - commands.push(CommandInfo { - title: "Build and scan".to_string(), - command: SupportedCommands::ExecuteBuildAndScan.to_string(), - arguments: Some(vec![json!(uri), json!(line)]), - range, - }); - - if let Some(image_name) = last_from_instruction.arguments.first() { - commands.push(CommandInfo { - title: "Scan base image".to_string(), - command: SupportedCommands::ExecuteBaseImageScan.to_string(), - arguments: Some(vec![json!(uri), json!(range), json!(image_name)]), - range, - }); - } - } - commands - } -} - -#[async_trait::async_trait] -impl LanguageServer for LSPServer -where - C: LSPClient + Send + Sync + 'static, -{ - async fn initialize(&self, initialize_params: InitializeParams) -> Result { - let Some(config) = initialize_params.initialization_options else { - return Err(Error { - code: ErrorCode::InvalidParams, - message: "expected parameters to configure the LSP, received nothing".into(), - data: None, - }); - }; - - self.initialize_component_factory_with(&config).await?; - - Ok(InitializeResult { - capabilities: ServerCapabilities { - text_document_sync: Some(TextDocumentSyncCapability::Kind( - TextDocumentSyncKind::FULL, - )), - code_action_provider: Some(CodeActionProviderCapability::Simple(true)), - code_lens_provider: Some(CodeLensOptions { - resolve_provider: Some(false), - }), - execute_command_provider: Some(ExecuteCommandOptions { - commands: vec![ - SupportedCommands::ExecuteBaseImageScan.to_string(), - SupportedCommands::ExecuteBuildAndScan.to_string(), - ], - ..Default::default() - }), - ..Default::default() - }, - ..Default::default() - }) - } - - async fn initialized(&self, _: InitializedParams) { - info!("Initialized"); - self.command_executor - .show_message(MessageType::INFO, "Sysdig LSP initialized") - .await; - } - - async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { - let _ = self - .initialize_component_factory_with(¶ms.settings) - .await; - } - - async fn did_open(&self, params: DidOpenTextDocumentParams) { - self.command_executor - .update_document_with_text( - params.text_document.uri.as_str(), - params.text_document.text.as_str(), - ) - .await; - } - - async fn did_change(&self, params: DidChangeTextDocumentParams) { - if let Some(change) = params.content_changes.into_iter().next_back() { - self.command_executor - .update_document_with_text(params.text_document.uri.as_str(), &change.text) - .await; - } - } - - async fn code_action(&self, params: CodeActionParams) -> Result> { - let Some(content) = self - .query_executor - .get_document_text(params.text_document.uri.as_str()) - .await - else { - return Err(Error::internal_error().with_message(format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ))); - }; - - let commands = self.generate_commands_for_uri(¶ms.text_document.uri, &content); - let code_actions: Vec = commands - .into_iter() - .filter(|cmd| cmd.range.start.line == params.range.start.line) - .map(|cmd| { - CodeActionOrCommand::Command(Command { - title: cmd.title, - command: cmd.command, - arguments: cmd.arguments, - }) - }) - .collect(); - - Ok(Some(code_actions)) - } - - async fn code_lens(&self, params: CodeLensParams) -> Result>> { - let Some(content) = self - .query_executor - .get_document_text(params.text_document.uri.as_str()) - .await - else { - return Err(Error::internal_error().with_message(format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ))); - }; - - let commands = self.generate_commands_for_uri(¶ms.text_document.uri, &content); - let code_lenses = commands - .into_iter() - .map(|cmd| CodeLens { - range: cmd.range, - command: Some(Command { - title: cmd.title, - command: cmd.command, - arguments: cmd.arguments, - }), - data: None, - }) - .collect(); - - Ok(Some(code_lenses)) - } - - async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { - let command: SupportedCommands = params.command.as_str().try_into().map_err(|e| { - Error::internal_error().with_message(format!("unable to parse command: {e}")) - })?; - - let result = match command { - SupportedCommands::ExecuteBaseImageScan => { - execute_command_scan_base_image(self, ¶ms) - .await - .map(|_| None) - } - - SupportedCommands::ExecuteBuildAndScan => execute_command_build_and_scan(self, ¶ms) - .await - .map(|_| None), - }; - - match result { - Ok(_) => result, - Err(mut e) => { - self.command_executor - .show_message(MessageType::ERROR, e.to_string().as_str()) - .await; - e.message = format!("error calling command: '{command}': {e}").into(); - Err(e) - } - } - } - - async fn shutdown(&self) -> Result<()> { - Ok(()) - } -} - -async fn execute_command_scan_base_image( - server: &LSPServer, - params: &ExecuteCommandParams, -) -> Result<()> { - let Some(uri) = params.arguments.first() else { - return Err(Error::internal_error().with_message("no uri was provided")); - }; - - let Some(uri) = uri.as_str() else { - return Err(Error::internal_error().with_message("uri is not a string")); - }; - - let Some(range) = params.arguments.get(1) else { - return Err(Error::internal_error().with_message("no range was provided")); - }; - - let Ok(range) = serde_json::from_value::(range.clone()) else { - return Err(Error::internal_error().with_message("range is not a Range object")); - }; - - let Some(image_name) = params.arguments.get(2) else { - return Err(Error::internal_error().with_message("no image name was provided")); - }; - - let Some(image_name) = image_name.as_str() else { - return Err(Error::internal_error().with_message("image name is not a string")); - }; - - let image_scanner = { - let mut lock = server.component_factory.write().await; - lock.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })? - }; - - server - .command_executor - .scan_image(uri, range, image_name, &image_scanner) - .await?; - - Ok(()) -} - -async fn execute_command_build_and_scan( - server: &LSPServer, - params: &ExecuteCommandParams, -) -> Result<()> { - let Some(uri) = params.arguments.first() else { - return Err(Error::internal_error().with_message("no uri was provided")); - }; - - let Some(uri) = uri.as_str() else { - return Err(Error::internal_error().with_message("uri is not a string")); - }; - - let Some(line) = params.arguments.get(1) else { - return Err(Error::internal_error().with_message("no line was provided")); - }; - - let Some(line) = line.as_u64().and_then(|x| u32::try_from(x).ok()) else { - return Err(Error::internal_error().with_message("line is not a u32")); - }; - - let (image_scanner, image_builder) = { - let mut factory = server.component_factory.write().await; - - let image_scanner = factory.image_scanner().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image scanner: {e}")) - })?; - let image_builder = factory.image_builder().map_err(|e| { - Error::internal_error().with_message(format!("unable to create image builder: {e}")) - })?; - - (image_scanner, image_builder) - }; - - server - .command_executor - .build_and_scan_from_file( - &PathBuf::from_str(uri).unwrap(), - line, - &image_builder, - &image_scanner, - ) - .await?; - - Ok(()) -} - -pub(super) trait WithContext { - fn with_message(self, message: impl Into>) -> Self; -} - -impl WithContext for Error { - fn with_message(mut self, message: impl Into>) -> Self { - self.message = message.into(); - self - } -} diff --git a/src/app/lsp_server/command_generator.rs b/src/app/lsp_server/command_generator.rs new file mode 100644 index 0000000..d6fc497 --- /dev/null +++ b/src/app/lsp_server/command_generator.rs @@ -0,0 +1,114 @@ +use serde_json::{Value, json}; +use tower_lsp::lsp_types::{CodeLens, Command, Location, Range, Url}; + +use crate::app::lsp_server::supported_commands::SupportedCommands; +use crate::infra::{parse_compose_file, parse_dockerfile}; + +pub struct CommandInfo { + pub title: String, + pub command: String, + pub arguments: Option>, + pub range: Range, +} + +impl From for CommandInfo { + fn from(value: SupportedCommands) -> Self { + match &value { + SupportedCommands::ExecuteBaseImageScan { location, image } => CommandInfo { + title: "Scan base image".to_owned(), + command: value.as_string_command(), + arguments: Some(vec![json!(location), json!(image)]), + range: location.range, + }, + + SupportedCommands::ExecuteBuildAndScan { location } => CommandInfo { + title: "Build and scan".to_owned(), + command: value.as_string_command(), + arguments: Some(vec![json!(location)]), + range: location.range, + }, + } + } +} + +impl From for Command { + fn from(value: CommandInfo) -> Self { + Command { + title: value.title, + command: value.command, + arguments: value.arguments, + } + } +} + +impl From for CodeLens { + fn from(value: CommandInfo) -> Self { + CodeLens { + range: value.range, + command: Some(Command { + title: value.title, + command: value.command, + arguments: value.arguments, + }), + data: None, + } + } +} + +pub fn generate_commands_for_uri(uri: &Url, content: &str) -> Vec { + let file_uri = uri.as_str(); + + if file_uri.contains("docker-compose.yml") + || file_uri.contains("compose.yml") + || file_uri.contains("docker-compose.yaml") + || file_uri.contains("compose.yaml") + { + generate_compose_commands(uri, content) + } else { + generate_dockerfile_commands(uri, content) + } +} + +fn generate_compose_commands(url: &Url, content: &str) -> Vec { + let mut commands = vec![]; + if let Ok(instructions) = parse_compose_file(content) { + for instruction in instructions { + commands.push( + SupportedCommands::ExecuteBaseImageScan { + location: Location::new(url.clone(), instruction.range), + image: instruction.image_name, + } + .into(), + ); + } + } + commands +} + +fn generate_dockerfile_commands(uri: &Url, content: &str) -> Vec { + let mut commands = vec![]; + let instructions = parse_dockerfile(content); + if let Some(last_from_instruction) = instructions + .iter() + .filter(|instruction| instruction.keyword == "FROM") + .next_back() + { + let range = last_from_instruction.range; + commands.push( + SupportedCommands::ExecuteBuildAndScan { + location: Location::new(uri.clone(), range), + } + .into(), + ); + if let Some(image) = last_from_instruction.arguments.first() { + commands.push( + SupportedCommands::ExecuteBaseImageScan { + location: Location::new(uri.clone(), range), + image: image.to_owned(), + } + .into(), + ); + } + } + commands +} diff --git a/src/app/lsp_server/commands/build_and_scan.rs b/src/app/lsp_server/commands/build_and_scan.rs new file mode 100644 index 0000000..fbc7eb1 --- /dev/null +++ b/src/app/lsp_server/commands/build_and_scan.rs @@ -0,0 +1,244 @@ +use std::{path::PathBuf, str::FromStr, sync::Arc}; + +use itertools::Itertools; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::{ + Diagnostic, DiagnosticSeverity, Location, MessageType, Position, Range, +}; + +use crate::{ + app::{ImageBuilder, ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, + domain::scanresult::{layer::Layer, scan_result::ScanResult, severity::Severity}, + infra::parse_dockerfile, +}; + +use super::LspCommand; + +pub struct BuildAndScanCommand<'a, C, B: ?Sized, S: ?Sized> +where + B: ImageBuilder, + S: ImageScanner, +{ + image_builder: &'a B, + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, +} + +impl<'a, C, B: ?Sized, S: ?Sized> BuildAndScanCommand<'a, C, B, S> +where + B: ImageBuilder, + S: ImageScanner, +{ + pub fn new( + image_builder: &'a B, + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + ) -> Self { + Self { + image_builder, + image_scanner, + interactor, + location, + } + } +} + +#[async_trait::async_trait] +impl<'a, C, B: ?Sized, S: ?Sized> LspCommand for BuildAndScanCommand<'a, C, B, S> +where + C: LSPClient + Sync, + B: ImageBuilder + Sync, + S: ImageScanner + Sync, +{ + async fn execute(&mut self) -> Result<()> { + let uri = self.location.uri.as_str(); + let line = self.location.range.start.line; + + let document_text = self + .interactor + .read_document_text(uri) + .await + .ok_or_else(|| { + tower_lsp::jsonrpc::Error::internal_error() + .with_message("unable to obtain document to scan") + })?; + + let uri_without_file_path = uri.strip_prefix("file://").ok_or_else(|| { + tower_lsp::jsonrpc::Error::internal_error() + .with_message("unable to strip prefix file:// from uri") + })?; + + self.interactor + .show_message( + MessageType::INFO, + format!("Starting build of {uri_without_file_path}...").as_str(), + ) + .await; + + let build_result = self + .image_builder + .build_image(&PathBuf::from_str(uri_without_file_path).unwrap()) + .await + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; + + self.interactor + .show_message( + MessageType::INFO, + format!( + "Temporal image built '{}', starting scan...", + &build_result.image_name + ) + .as_str(), + ) + .await; + + let scan_result = self + .image_scanner + .scan_image(&build_result.image_name) + .await + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; + + self.interactor + .show_message( + MessageType::INFO, + format!("Finished scan of {}.", &build_result.image_name).as_str(), + ) + .await; + + let diagnostic = diagnostic_for_image(line, &document_text, &scan_result); + let diagnostics_per_layer = diagnostics_for_layers(&document_text, &scan_result)?; + + self.interactor.remove_diagnostics(uri).await; + self.interactor + .append_document_diagnostics(uri, &[diagnostic]) + .await; + self.interactor + .append_document_diagnostics(uri, &diagnostics_per_layer) + .await; + self.interactor.publish_all_diagnostics().await + } +} + +pub fn diagnostics_for_layers( + document_text: &str, + scan_result: &ScanResult, +) -> Result> { + let instructions = parse_dockerfile(document_text); + let layers = &scan_result.layers(); + + let mut instr_idx = instructions.len().checked_sub(1); + let mut layer_idx = layers.len().checked_sub(1); + + let mut diagnostics = Vec::new(); + + while let (Some(i), Some(l)) = (instr_idx, layer_idx) { + let instr = &instructions[i]; + let layer = &layers[l]; + + if instr.keyword == "FROM" { + break; + } + + instr_idx = instr_idx.and_then(|x| x.checked_sub(1)); + layer_idx = layer_idx.and_then(|x| x.checked_sub(1)); + + if !layer.vulnerabilities().is_empty() { + let vulns = layer.vulnerabilities().iter().counts_by(|v| v.severity()); + let msg = format!( + "Vulnerabilities found in layer: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + vulns.get(&Severity::Critical).unwrap_or(&0_usize), + vulns.get(&Severity::High).unwrap_or(&0_usize), + vulns.get(&Severity::Medium).unwrap_or(&0_usize), + vulns.get(&Severity::Low).unwrap_or(&0_usize), + vulns.get(&Severity::Negligible).unwrap_or(&0_usize), + ); + let diagnostic = Diagnostic { + range: instr.range, + severity: Some(DiagnosticSeverity::WARNING), + message: msg, + ..Default::default() + }; + + diagnostics.push(diagnostic); + + fill_vulnerability_hints_for_layer(layer, instr.range, &mut diagnostics) + } + } + + Ok(diagnostics) +} + +fn fill_vulnerability_hints_for_layer( + layer: &Arc, + range: Range, + diagnostics: &mut Vec, +) { + let vulns_per_severity = layer + .vulnerabilities() + .iter() + .cloned() + .sorted_by_key(|v| v.severity()); + + // TODO(fede): eventually we would want to add here a .take() to truncate the number + // of vulnerabilities shown as hint per layer. + vulns_per_severity.for_each(|vuln| { + let url = format!("https://nvd.nist.gov/vuln/detail/{}", vuln.cve()); + diagnostics.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::HINT), + message: format!( + "Vulnerability: {} ({:?}) {}", + vuln.cve(), + vuln.severity(), + url + ), + ..Default::default() + }); + }); +} + +fn diagnostic_for_image(line: u32, document_text: &str, scan_result: &ScanResult) -> Diagnostic { + let range_for_selected_line = Range::new( + Position::new(line, 0), + Position::new( + line, + document_text + .lines() + .nth(line as usize) + .map(|x| x.len() as u32) + .unwrap_or(u32::MAX), + ), + ); + + let mut diagnostic = Diagnostic { + range: range_for_selected_line, + severity: Some(DiagnosticSeverity::HINT), + message: "No vulnerabilities found.".to_owned(), + ..Default::default() + }; + + if !scan_result.vulnerabilities().is_empty() { + let vulns = scan_result + .vulnerabilities() + .iter() + .counts_by(|v| v.severity()); + diagnostic.message = format!( + "Vulnerabilities found: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + vulns.get(&Severity::Critical).unwrap_or(&0_usize), + vulns.get(&Severity::High).unwrap_or(&0_usize), + vulns.get(&Severity::Medium).unwrap_or(&0_usize), + vulns.get(&Severity::Low).unwrap_or(&0_usize), + vulns.get(&Severity::Negligible).unwrap_or(&0_usize), + ); + + diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() { + DiagnosticSeverity::INFORMATION + } else { + DiagnosticSeverity::ERROR + }); + } + + diagnostic +} diff --git a/src/app/lsp_server/commands/mod.rs b/src/app/lsp_server/commands/mod.rs new file mode 100644 index 0000000..873c28d --- /dev/null +++ b/src/app/lsp_server/commands/mod.rs @@ -0,0 +1,9 @@ +pub mod build_and_scan; +pub mod scan_base_image; + +use tower_lsp::jsonrpc::Result; + +#[async_trait::async_trait] +pub trait LspCommand { + async fn execute(&mut self) -> Result<()>; +} diff --git a/src/app/lsp_server/commands/scan_base_image.rs b/src/app/lsp_server/commands/scan_base_image.rs new file mode 100644 index 0000000..436e5ca --- /dev/null +++ b/src/app/lsp_server/commands/scan_base_image.rs @@ -0,0 +1,108 @@ +use itertools::Itertools; +use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity, Location, MessageType}; + +use crate::{ + app::{ImageScanner, LSPClient, LspInteractor, lsp_server::WithContext}, + domain::scanresult::severity::Severity, +}; + +use super::LspCommand; + +pub struct ScanBaseImageCommand<'a, C, S: ?Sized> +where + S: ImageScanner, +{ + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + image: String, +} + +impl<'a, C, S: ?Sized> ScanBaseImageCommand<'a, C, S> +where + S: ImageScanner, +{ + pub fn new( + image_scanner: &'a S, + interactor: &'a LspInteractor, + location: Location, + image: String, + ) -> Self { + Self { + image_scanner, + interactor, + location, + image, + } + } +} + +#[async_trait::async_trait] +impl<'a, C, S: ?Sized> LspCommand for ScanBaseImageCommand<'a, C, S> +where + C: LSPClient + Sync, + S: ImageScanner + Sync, +{ + async fn execute(&mut self) -> tower_lsp::jsonrpc::Result<()> { + let image_name = &self.image; + self.interactor + .show_message( + MessageType::INFO, + format!("Starting scan of {image_name}...").as_str(), + ) + .await; + + let scan_result = self + .image_scanner + .scan_image(image_name) + .await + .map_err(|e| tower_lsp::jsonrpc::Error::internal_error().with_message(e.to_string()))?; + + self.interactor + .show_message( + MessageType::INFO, + format!("Finished scan of {image_name}.").as_str(), + ) + .await; + + let diagnostic = { + let mut diagnostic = Diagnostic { + range: self.location.range, + severity: Some(DiagnosticSeverity::HINT), + message: "No vulnerabilities found.".to_owned(), + ..Default::default() + }; + + if !scan_result.vulnerabilities().is_empty() { + let vulns = scan_result + .vulnerabilities() + .iter() + .counts_by(|v| v.severity()); + diagnostic.message = format!( + "Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + image_name, + vulns.get(&Severity::Critical).unwrap_or(&0_usize), + vulns.get(&Severity::High).unwrap_or(&0_usize), + vulns.get(&Severity::Medium).unwrap_or(&0_usize), + vulns.get(&Severity::Low).unwrap_or(&0_usize), + vulns.get(&Severity::Negligible).unwrap_or(&0_usize), + ); + + diagnostic.severity = Some(if scan_result.evaluation_result().is_passed() { + DiagnosticSeverity::INFORMATION + } else { + DiagnosticSeverity::ERROR + }); + } + + diagnostic + }; + + let uri = self.location.uri.as_str(); + self.interactor.remove_diagnostics(uri).await; + self.interactor + .append_document_diagnostics(uri, &[diagnostic]) + .await; + self.interactor.publish_all_diagnostics().await + } +} diff --git a/src/app/lsp_server/lsp_server_inner.rs b/src/app/lsp_server/lsp_server_inner.rs new file mode 100644 index 0000000..773e25a --- /dev/null +++ b/src/app/lsp_server/lsp_server_inner.rs @@ -0,0 +1,234 @@ +use serde_json::Value; +use tower_lsp::jsonrpc::{Error, ErrorCode, Result}; +use tower_lsp::lsp_types::{ + CodeActionOrCommand, CodeActionParams, CodeActionProviderCapability, CodeActionResponse, + CodeLens, CodeLensOptions, CodeLensParams, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, + ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, + ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, +}; +use tracing::{debug, info}; + +use super::super::component_factory::{ComponentFactory, Components, Config}; +use super::super::queries::QueryExecutor; +use super::command_generator; +use super::commands::{ + LspCommand, build_and_scan::BuildAndScanCommand, scan_base_image::ScanBaseImageCommand, +}; +use super::{InMemoryDocumentDatabase, LSPClient, WithContext}; +use crate::app::LspInteractor; + +use super::supported_commands::SupportedCommands; + +pub struct LSPServerInner { + interactor: LspInteractor, + query_executor: QueryExecutor, + component_factory: F, + components: Option, +} + +impl LSPServerInner { + pub fn new(client: C, component_factory: F) -> LSPServerInner { + let document_database = InMemoryDocumentDatabase::default(); + + LSPServerInner { + interactor: LspInteractor::new(client, document_database.clone()), + query_executor: QueryExecutor::new(document_database.clone()), + component_factory, + components: None, + } + } +} + +impl LSPServerInner +where + C: LSPClient + Send + Sync + 'static, +{ + fn update_components(&mut self, config: &Value) -> Result<()> { + let config = serde_json::from_value::(config.clone()).map_err(|e| { + Error::internal_error() + .with_message(format!("unable to transform json into config: {e}")) + })?; + + debug!("updating with configuration: {config:?}"); + + let components = self.component_factory.create_components(config)?; + self.components.replace(components); + + debug!("updated configuration"); + Ok(()) + } +} + +impl LSPServerInner +where + C: LSPClient + Send + Sync + 'static, +{ + async fn get_commands_for_document( + &self, + uri: &tower_lsp::lsp_types::Url, + ) -> Result> { + let Some(content) = self.query_executor.get_document_text(uri.as_str()).await else { + return Err(Error::internal_error().with_message(format!( + "unable to extract document content for document: {uri}" + ))); + }; + + let commands = command_generator::generate_commands_for_uri(uri, &content); + Ok(commands) + } + + pub async fn initialize( + &mut self, + initialize_params: InitializeParams, + ) -> Result { + let Some(config) = initialize_params.initialization_options else { + return Err(Error { + code: ErrorCode::InvalidParams, + message: "expected parameters to configure the LSP, received nothing".into(), + data: None, + }); + }; + + self.update_components(&config)?; + + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + code_action_provider: Some(CodeActionProviderCapability::Simple(true)), + code_lens_provider: Some(CodeLensOptions { + resolve_provider: Some(false), + }), + execute_command_provider: Some(ExecuteCommandOptions { + commands: SupportedCommands::all_supported_commands_as_string(), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }) + } + + pub async fn initialized(&self, _: InitializedParams) { + info!("Initialized"); + self.interactor + .show_message(MessageType::INFO, "Sysdig LSP initialized") + .await; + } + + pub async fn did_change_configuration(&mut self, params: DidChangeConfigurationParams) { + let _ = self.update_components(¶ms.settings); + } + + pub async fn did_open(&self, params: DidOpenTextDocumentParams) { + self.interactor + .update_document_with_text( + params.text_document.uri.as_str(), + params.text_document.text.as_str(), + ) + .await; + } + + pub async fn did_change(&self, params: DidChangeTextDocumentParams) { + if let Some(change) = params.content_changes.into_iter().next_back() { + self.interactor + .update_document_with_text(params.text_document.uri.as_str(), &change.text) + .await; + } + } + + pub async fn code_action( + &self, + params: CodeActionParams, + ) -> Result> { + let commands = self + .get_commands_for_document(¶ms.text_document.uri) + .await?; + let code_actions: Vec = commands + .into_iter() + .filter(|cmd| cmd.range.start.line == params.range.start.line) + .map(|cmd| CodeActionOrCommand::Command(cmd.into())) + .collect(); + + Ok(Some(code_actions)) + } + + pub async fn code_lens(&self, params: CodeLensParams) -> Result>> { + let commands = self + .get_commands_for_document(¶ms.text_document.uri) + .await?; + let code_lenses = commands.into_iter().map(|cmd| cmd.into()).collect(); + + Ok(Some(code_lenses)) + } + + fn components(&self) -> Result<&Components> { + self.components + .as_ref() + .ok_or_else(|| Error::internal_error().with_message("LSP not initialized")) + } + + async fn execute_base_image_scan( + &self, + location: tower_lsp::lsp_types::Location, + image: String, + ) -> Result<()> { + let components = self.components()?; + ScanBaseImageCommand::new( + components.scanner.as_ref(), + &self.interactor, + location, + image, + ) + .execute() + .await + } + + async fn execute_build_and_scan(&self, location: tower_lsp::lsp_types::Location) -> Result<()> { + let components = self.components()?; + BuildAndScanCommand::new( + components.builder.as_ref(), + components.scanner.as_ref(), + &self.interactor, + location, + ) + .execute() + .await + } + + async fn handle_command_error(&self, command_name: &str, e: Error) -> Error { + self.interactor + .show_message(MessageType::ERROR, e.to_string().as_str()) + .await; + Error { + code: e.code, + message: format!("error calling command: '{command_name}': {}", e.message).into(), + data: e.data, + } + } + + pub async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { + let command: SupportedCommands = params.try_into()?; + let command_name = command.to_string(); + + let result = match command { + SupportedCommands::ExecuteBaseImageScan { location, image } => { + self.execute_base_image_scan(location, image).await + } + SupportedCommands::ExecuteBuildAndScan { location } => { + self.execute_build_and_scan(location).await + } + }; + + match result { + Ok(_) => Ok(None), + Err(e) => Err(self.handle_command_error(&command_name, e).await), + } + } + + pub async fn shutdown(&self) -> Result<()> { + Ok(()) + } +} diff --git a/src/app/lsp_server/mod.rs b/src/app/lsp_server/mod.rs new file mode 100644 index 0000000..0d5a883 --- /dev/null +++ b/src/app/lsp_server/mod.rs @@ -0,0 +1,96 @@ +use serde_json::Value; +use std::borrow::Cow; +use tokio::sync::RwLock; +use tower_lsp::LanguageServer; +use tower_lsp::jsonrpc::{Error, Result}; +use tower_lsp::lsp_types::{ + CodeActionParams, CodeActionResponse, CodeLens, CodeLensParams, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, + InitializeResult, InitializedParams, Range, +}; + +use super::{InMemoryDocumentDatabase, LSPClient}; + +pub mod command_generator; +pub mod commands; +mod lsp_server_inner; +pub mod supported_commands; +use crate::app::component_factory::ComponentFactory; +use lsp_server_inner::LSPServerInner; + +pub trait WithContext { + fn with_message(self, message: impl Into>) -> Self; +} + +impl WithContext for Error { + fn with_message(mut self, message: impl Into>) -> Self { + self.message = message.into(); + self + } +} + +pub struct LSPServer { + inner: RwLock>, +} + +impl LSPServer { + pub fn new(client: C, component_factory: F) -> LSPServer { + LSPServer { + inner: RwLock::new(LSPServerInner::new(client, component_factory)), + } + } +} + +struct CommandInfo { + title: String, + command: String, + arguments: Option>, + range: Range, +} + +#[async_trait::async_trait] +impl LanguageServer for LSPServer +where + C: LSPClient + Send + Sync + 'static, + F: ComponentFactory + Send + Sync + 'static, +{ + async fn initialize(&self, params: InitializeParams) -> Result { + self.inner.write().await.initialize(params).await + } + + async fn initialized(&self, params: InitializedParams) { + self.inner.read().await.initialized(params).await + } + + async fn did_change_configuration(&self, params: DidChangeConfigurationParams) { + self.inner + .write() + .await + .did_change_configuration(params) + .await + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + self.inner.read().await.did_open(params).await + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + self.inner.read().await.did_change(params).await + } + + async fn code_action(&self, params: CodeActionParams) -> Result> { + self.inner.read().await.code_action(params).await + } + + async fn code_lens(&self, params: CodeLensParams) -> Result>> { + self.inner.read().await.code_lens(params).await + } + + async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { + self.inner.read().await.execute_command(params).await + } + + async fn shutdown(&self) -> Result<()> { + self.inner.read().await.shutdown().await + } +} diff --git a/src/app/lsp_server/supported_commands.rs b/src/app/lsp_server/supported_commands.rs new file mode 100644 index 0000000..807e656 --- /dev/null +++ b/src/app/lsp_server/supported_commands.rs @@ -0,0 +1,72 @@ +use std::fmt::Display; + +use tower_lsp::{ + jsonrpc::{self, Error}, + lsp_types::{ExecuteCommandParams, Location}, +}; + +const CMD_EXECUTE_SCAN: &str = "sysdig-lsp.execute-scan"; +const CMD_BUILD_AND_SCAN: &str = "sysdig-lsp.execute-build-and-scan"; + +#[derive(Debug, Clone)] +pub enum SupportedCommands { + ExecuteBaseImageScan { location: Location, image: String }, + ExecuteBuildAndScan { location: Location }, +} + +impl SupportedCommands { + pub fn as_string_command(&self) -> String { + match self { + SupportedCommands::ExecuteBaseImageScan { .. } => CMD_EXECUTE_SCAN, + SupportedCommands::ExecuteBuildAndScan { .. } => CMD_BUILD_AND_SCAN, + } + .to_string() + } + + pub fn all_supported_commands_as_string() -> Vec { + [CMD_EXECUTE_SCAN, CMD_BUILD_AND_SCAN] + .into_iter() + .map(|s| s.to_string()) + .collect() + } +} + +impl TryFrom for SupportedCommands { + type Error = jsonrpc::Error; + + fn try_from(value: ExecuteCommandParams) -> std::result::Result { + match (value.command.as_str(), value.arguments.as_slice()) { + (CMD_EXECUTE_SCAN, [location, image]) => Ok(SupportedCommands::ExecuteBaseImageScan { + location: serde_json::from_value(location.clone()) + .map_err(|_| Error::invalid_params("location must be a Location object"))?, + image: image + .as_str() + .ok_or_else(|| Error::invalid_params("image must be string"))? + .to_owned(), + }), + (CMD_BUILD_AND_SCAN, [location]) => Ok(SupportedCommands::ExecuteBuildAndScan { + location: serde_json::from_value(location.clone()) + .map_err(|_| Error::invalid_params("location must be a Location object"))?, + }), + (other, _) => Err(Error::invalid_params(format!( + "command not supported: {other}" + ))), + } + } +} + +impl Display for SupportedCommands { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SupportedCommands::ExecuteBaseImageScan { location, image } => { + write!( + f, + "ExecuteBaseImageScan(location: {location:?}, image: {image})", + ) + } + SupportedCommands::ExecuteBuildAndScan { location } => { + write!(f, "ExecuteBuildAndScan(location: {location:?})") + } + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index f2735fd..bd5a917 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,9 +1,9 @@ -mod commands; -mod component_factory; +pub mod component_factory; mod document_database; mod image_builder; mod image_scanner; mod lsp_client; +mod lsp_interactor; mod lsp_server; mod queries; @@ -11,4 +11,5 @@ pub use document_database::*; pub use image_builder::{ImageBuildError, ImageBuildResult, ImageBuilder}; pub use image_scanner::{ImageScanError, ImageScanner, Vulnerabilities}; pub use lsp_client::LSPClient; +pub use lsp_interactor::LspInteractor; pub use lsp_server::LSPServer; diff --git a/src/infra/component_factory_impl.rs b/src/infra/component_factory_impl.rs new file mode 100644 index 0000000..0611515 --- /dev/null +++ b/src/infra/component_factory_impl.rs @@ -0,0 +1,31 @@ +use bollard::Docker; + +use crate::{ + app::component_factory::{ComponentFactory, ComponentFactoryError, Components, Config}, + infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner}, +}; + +pub struct ConcreteComponentFactory; + +impl ComponentFactory for ConcreteComponentFactory { + fn create_components(&self, config: Config) -> Result { + let token = config + .sysdig + .api_token + .clone() + .map(Ok) + .unwrap_or_else(|| std::env::var("SECURE_API_TOKEN")) + .map(SysdigAPIToken)?; + + let scanner = SysdigImageScanner::new(config.sysdig.api_url.clone(), token); + + let docker_client = Docker::connect_with_local_defaults() + .map_err(|e| ComponentFactoryError::DockerClientError(e.to_string()))?; + let builder = DockerImageBuilder::new(docker_client); + + Ok(Components { + scanner: Box::new(scanner), + builder: Box::new(builder), + }) + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index c329eac..a1e0c31 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,3 +1,4 @@ +mod component_factory_impl; mod compose_ast_parser; mod docker_image_builder; mod dockerfile_ast_parser; @@ -7,6 +8,7 @@ mod sysdig_image_scanner_json_scan_result_v1; pub use sysdig_image_scanner::{SysdigAPIToken, SysdigImageScanner}; pub mod lsp_logger; +pub use component_factory_impl::ConcreteComponentFactory; pub use compose_ast_parser::{ImageInstruction, parse_compose_file}; pub use docker_image_builder::DockerImageBuilder; pub use dockerfile_ast_parser::{Instruction, parse_dockerfile}; diff --git a/src/main.rs b/src/main.rs index a992dbe..d30d08f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ use clap::Parser; -use sysdig_lsp::{app::LSPServer, infra::lsp_logger::LSPLogger}; +use sysdig_lsp::{ + app::LSPServer, + infra::{ConcreteComponentFactory, lsp_logger::LSPLogger}, +}; use tower_lsp::{LspService, Server}; use tracing_subscriber::layer::SubscriberExt; @@ -21,7 +24,7 @@ async fn main() { tracing::subscriber::set_global_default(subscriber) .expect("setting default subscriber failed"); - LSPServer::new(client) + LSPServer::new(client, ConcreteComponentFactory) }); Server::new(stdin, stdout, messages).serve(service).await; diff --git a/tests/common.rs b/tests/common.rs new file mode 100644 index 0000000..b743ea0 --- /dev/null +++ b/tests/common.rs @@ -0,0 +1,141 @@ +use std::sync::Arc; +use tokio::sync::Mutex; + +use mockall::mock; +use sysdig_lsp::{ + app::{ + ImageBuildError, ImageBuildResult, ImageBuilder, ImageScanError, ImageScanner, LSPServer, + component_factory::{ComponentFactory, ComponentFactoryError, Components, Config}, + }, + domain::scanresult::scan_result::ScanResult, +}; +use tower_lsp::lsp_types::{Diagnostic, MessageType}; + +// --- Contenido de recorder.rs --- +#[derive(Clone)] +pub struct TestClientRecorder { + pub messages: Arc>>, + pub diagnostics: Arc>>>, +} + +impl TestClientRecorder { + pub fn new() -> Self { + Self { + messages: Arc::new(Mutex::new(Vec::new())), + diagnostics: Arc::new(Mutex::new(Vec::new())), + } + } +} + +impl Default for TestClientRecorder { + fn default() -> Self { + Self::new() + } +} + +#[async_trait::async_trait] +impl sysdig_lsp::app::LSPClient for TestClientRecorder { + async fn show_message( + &self, + message_type: MessageType, + message: M, + ) { + self.messages + .lock() + .await + .push((message_type, message.to_string())); + } + + async fn publish_diagnostics( + &self, + _url: &str, + diagnostics: Vec, + _version: Option, + ) { + self.diagnostics.lock().await.push(diagnostics); + } +} + +// --- Contenido de mocks.rs --- +mock! { + pub ImageBuilder {} + #[async_trait::async_trait] + impl ImageBuilder for ImageBuilder { + async fn build_image(&self, containerfile: &std::path::Path) -> Result; + } +} + +mock! { + pub ImageScanner {} + #[async_trait::async_trait] + impl ImageScanner for ImageScanner { + async fn scan_image(&self, image_pull_string: &str) -> Result; + } +} + +// --- Implementaciones de traits para Arc> --- +#[derive(Clone)] +pub struct MockImageBuilderWrapper(pub Arc>); +#[derive(Clone)] +pub struct MockImageScannerWrapper(pub Arc>); + +#[async_trait::async_trait] +impl ImageBuilder for MockImageBuilderWrapper { + async fn build_image( + &self, + containerfile: &std::path::Path, + ) -> Result { + self.0.lock().await.build_image(containerfile).await + } +} + +#[async_trait::async_trait] +impl ImageScanner for MockImageScannerWrapper { + async fn scan_image(&self, image_pull_string: &str) -> Result { + self.0.lock().await.scan_image(image_pull_string).await + } +} + +// --- Estructuras de Setup --- +#[derive(Clone)] +pub struct MockComponentFactory { + pub image_builder: Arc>, + pub image_scanner: Arc>, +} + +impl ComponentFactory for MockComponentFactory { + fn create_components(&self, _config: Config) -> Result { + Ok(Components { + builder: Box::new(MockImageBuilderWrapper(self.image_builder.clone())), + scanner: Box::new(MockImageScannerWrapper(self.image_scanner.clone())), + }) + } +} + +pub struct TestSetup { + pub server: LSPServer, + pub client_recorder: TestClientRecorder, + pub component_factory: MockComponentFactory, +} + +impl TestSetup { + pub fn new() -> Self { + let client_recorder = TestClientRecorder::new(); + let component_factory = MockComponentFactory { + image_builder: Arc::new(Mutex::new(MockImageBuilder::new())), + image_scanner: Arc::new(Mutex::new(MockImageScanner::new())), + }; + let server = LSPServer::new(client_recorder.clone(), component_factory.clone()); + Self { + server, + client_recorder, + component_factory, + } + } +} + +impl Default for TestSetup { + fn default() -> Self { + Self::new() + } +} diff --git a/tests/general.rs b/tests/general.rs index d091dc4..68c6251 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -1,309 +1,352 @@ -use serde_json::json; -use tower_lsp::lsp_types::{CodeActionOrCommand, CodeLens, Command, MessageType, Position, Range}; - -mod test; +mod common; -#[tokio::test] -async fn when_the_lsp_is_loaded_initializes_correctly() { - let mut client = test::TestClient::new(); - let response = client.initialize_lsp().await; +use common::TestSetup; +use rstest::{fixture, rstest}; +use serde_json::json; +use std::collections::HashMap; +use sysdig_lsp::domain::scanresult::architecture::Architecture; +use sysdig_lsp::domain::scanresult::operating_system::{Family, OperatingSystem}; +use sysdig_lsp::domain::scanresult::scan_result::ScanResult; +use sysdig_lsp::domain::scanresult::scan_type::ScanType; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::{ + CodeActionContext, CodeActionParams, DiagnosticSeverity, DidChangeConfigurationParams, + DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, + PartialResultParams, Position, Range, TextDocumentIdentifier, TextDocumentItem, Url, + VersionedTextDocumentIdentifier, WorkDoneProgressParams, +}; - assert!(response.capabilities.code_action_provider.is_some()); - assert!( - client - .recorder() - .messages_shown() - .await - .contains(&(MessageType::INFO, "Sysdig LSP initialized".to_string())) - ) +#[fixture] +async fn initialized_server() -> TestSetup { + let setup = TestSetup::new(); + let params = InitializeParams { + initialization_options: Some(serde_json::json!({ + "sysdig": { + "apiUrl": "http://localhost:8080", + "api_token": "dummy-token" + } + })), + ..Default::default() + }; + let result = setup.server.initialize(params).await; + assert!(result.is_ok()); + setup } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_the_existing_code_actions_it_receives_the_available_code_actions() -{ - let mut client = test::TestClient::new_initialized().await; - - client - .open_file_with_contents("Dockerfile", "FROM alpine") - .await; - - let response = client - .request_available_actions_in_line("Dockerfile", 0) +async fn test_did_change_configuration(#[future] initialized_server: TestSetup) { + let params = DidChangeConfigurationParams { + settings: serde_json::json!({ + "sysdig": { + "apiUrl": "http://localhost:8080", + "api_token": "dummy-token" + } + }), + }; + initialized_server + .server + .did_change_configuration(params) .await; +} - assert_eq!( - response.unwrap(), - vec![ - CodeActionOrCommand::Command(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) - }), - CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 11}}), - json!("alpine"), - ]) - }) - ] - ); +#[rstest] +#[awt] +#[tokio::test] +async fn test_did_open(#[future] initialized_server: TestSetup) { + let params = DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + "file:///Dockerfile".parse().unwrap(), + "dockerfile".to_string(), + 1, + "FROM alpine".to_string(), + ), + }; + initialized_server.server.did_open(params).await; } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_the_existing_code_actions_but_the_dockerfile_contains_multiple_froms_it_only_returns_the_latest() - { - let mut client = test::TestClient::new_initialized().await; +async fn test_did_change(#[future] initialized_server: TestSetup) { + let params = DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier::new( + "file:///Dockerfile".parse().unwrap(), + 1, + ), + content_changes: vec![], + }; + initialized_server.server.did_change(params).await; +} - client - .open_file_with_contents("Dockerfile", "FROM alpine\nFROM ubuntu") - .await; +#[fixture] +fn open_file_url() -> Url { + "file:///Dockerfile".parse().unwrap() +} - let response_for_first_line = client - .request_available_actions_in_line("Dockerfile", 0) +#[fixture] +#[awt] +async fn server_with_open_file( + #[future] initialized_server: TestSetup, + open_file_url: Url, +) -> TestSetup { + initialized_server + .server + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem::new( + open_file_url.clone(), + "dockerfile".to_string(), + 1, + "FROM alpine".to_string(), + ), + }) .await; - assert!(response_for_first_line.unwrap().is_empty()); + initialized_server +} - let response_for_second_line = client - .request_available_actions_in_line("Dockerfile", 1) - .await; +use sysdig_lsp::domain::scanresult::{package_type::PackageType, severity::Severity}; - assert_eq!( - response_for_second_line.unwrap(), - vec![ - CodeActionOrCommand::Command(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) - }), - CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 11}}), - json!("ubuntu"), - ]) - }) - ] +#[fixture] +fn scan_result() -> ScanResult { + let mut result = ScanResult::new( + ScanType::Docker, + "alpine:latest".to_string(), + "sha256:12345".to_string(), + Some("sha256:67890".to_string()), + OperatingSystem::new(Family::Linux, "alpine:3.18".to_string()), + 123456, + Architecture::Amd64, + HashMap::new(), + chrono::Utc::now(), ); -} -#[tokio::test] -async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_available_code_lens() { - let mut client = test::TestClient::new_initialized().await; + let layer = result.add_layer( + "sha256:layer1".to_string(), + 0, + Some(1024), + "COPY . .".to_string(), + ); - // Open a Dockerfile containing a single "FROM" statement. - client - .open_file_with_contents("Dockerfile", "FROM alpine") - .await; + let package1 = result.add_package( + PackageType::Os, + "package1".to_string(), + "1.0.0".to_string(), + "/usr/lib/package1".to_string(), + layer.clone(), + ); - // Request code lens on the line with the FROM statement (line 0). - let response = client - .request_available_code_lens_in_file("Dockerfile") - .await; + result.add_package( + PackageType::Os, + "package2".to_string(), + "2.0.0".to_string(), + "/usr/lib/package2".to_string(), + layer, + ); - // Expect a CodeLens with the appropriate command. - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(0, 0), Position::new(0, 11)), - command: Some(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(0, 0), Position::new(0, 11)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 11}}), - json!("alpine"), - ]) - }), - data: None - } - ] + let vulnerability = result.add_vulnerability( + "CVE-2021-1234".to_string(), + Severity::High, + chrono::NaiveDate::from_ymd_opt(2021, 1, 1).unwrap(), + None, + false, + Some("1.0.1".to_string()), ); + + package1.add_vulnerability_found(vulnerability); + + result } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_contains_multiple_froms_it_only_returns_the_latest() - { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents("Dockerfile", "FROM alpine\nFROM ubuntu") - .await; +async fn test_code_action(#[future] server_with_open_file: TestSetup, open_file_url: Url) { + let params = CodeActionParams { + text_document: TextDocumentIdentifier::new(open_file_url), + range: Range::new(Position::new(0, 0), Position::new(0, 0)), + context: CodeActionContext::default(), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; + let result = server_with_open_file + .server + .code_action(params) + .await + .unwrap() + .unwrap(); - let response = client - .request_available_code_lens_in_file("Dockerfile") - .await; + let mut result_json = serde_json::to_value(result).unwrap(); + // Sort by command title to have a deterministic order for comparison + result_json.as_array_mut().unwrap().sort_by(|a, b| { + a["title"] + .as_str() + .unwrap() + .cmp(b["title"].as_str().unwrap()) + }); - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(1, 0), Position::new(1, 11)), - command: Some(Command { - title: "Build and scan".to_string(), - command: "sysdig-lsp.execute-build-and-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(1, 0), Position::new(1, 11)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://dockerfile/"), - json!({"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 11}}), - json!("ubuntu"), - ]) - }), - data: None - } - ] - ); + let expected_json = serde_json::json!([ + { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + } + ], + "command": "sysdig-lsp.execute-build-and-scan", + "title": "Build and scan" + }, + { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + }, + "alpine" + ], + "command": "sysdig-lsp.execute-scan", + "title": "Scan base image" + } + ]); + + assert_eq!(result_json, expected_json); } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_code_lens_in_a_compose_file_it_receives_them() { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents( - "docker-compose.yml", - include_str!("fixtures/docker-compose.yml"), - ) - .await; +async fn test_code_lens(#[future] server_with_open_file: TestSetup, open_file_url: Url) { + let params = tower_lsp::lsp_types::CodeLensParams { + text_document: TextDocumentIdentifier::new(open_file_url), + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }; - let response = client - .request_available_code_lens_in_file("docker-compose.yml") - .await; + let result = server_with_open_file + .server + .code_lens(params) + .await + .unwrap() + .unwrap(); - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(2, 11), Position::new(2, 23)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://docker-compose.yml/"), - json!({"start": {"line": 2, "character": 11}, "end": {"line": 2, "character": 23}}), - json!("nginx:latest") - ]) - }), - data: None + let mut result_json = serde_json::to_value(result).unwrap(); + // Sort by command title to have a deterministic order for comparison + result_json.as_array_mut().unwrap().sort_by(|a, b| { + a["command"]["title"] + .as_str() + .unwrap() + .cmp(b["command"]["title"].as_str().unwrap()) + }); + + let expected_json = serde_json::json!([ + { + "command": { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + } + ], + "command": "sysdig-lsp.execute-build-and-scan", + "title": "Build and scan" }, - CodeLens { - range: Range::new(Position::new(4, 11), Position::new(4, 22)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://docker-compose.yml/"), - json!({"start": {"line": 4, "character": 11}, "end": {"line": 4, "character": 22}}), - json!("postgres:13") - ]) - }), - data: None + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } } - ] - ); + }, + { + "command": { + "arguments": [ + { + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + }, + "uri": "file:///Dockerfile" + }, + "alpine" + ], + "command": "sysdig-lsp.execute-scan", + "title": "Scan base image" + }, + "range": { + "end": { "character": 11, "line": 0 }, + "start": { "character": 0, "line": 0 } + } + } + ]); + + assert_eq!(result_json, expected_json); } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_code_actions_in_a_compose_file_it_receives_them() { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents( - "docker-compose.yml", - include_str!("fixtures/docker-compose.yml"), - ) - .await; +async fn test_execute_command( + #[future] server_with_open_file: TestSetup, + open_file_url: Url, + scan_result: ScanResult, +) { + server_with_open_file + .component_factory + .image_scanner + .lock() + .await + .expect_scan_image() + .with(mockall::predicate::eq("alpine")) + .times(1) + .returning(move |_| Ok(scan_result.clone())); - let response = client - .request_available_actions_in_line("docker-compose.yml", 2) - .await; + server_with_open_file + .client_recorder + .diagnostics + .lock() + .await + .clear(); + let params = ExecuteCommandParams { + command: "sysdig-lsp.execute-scan".to_string(), + arguments: vec![ + json!({"range":{"end":{"character":11,"line":0},"start":{"character": 0,"line":0}},"uri":open_file_url}), + json!("alpine"), + ], + work_done_progress_params: WorkDoneProgressParams::default(), + }; + let result = server_with_open_file.server.execute_command(params).await; + assert!(result.is_ok()); + + let diagnostics = server_with_open_file + .client_recorder + .diagnostics + .lock() + .await; + assert_eq!(diagnostics.len(), 1); + let diagnostic = &diagnostics[0][0]; assert_eq!( - response.unwrap(), - vec![CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://docker-compose.yml/"), - json!({"start": {"line": 2, "character": 11}, "end": {"line": 2, "character": 23}}), - json!("nginx:latest"), - ]) - })] + diagnostic.message, + "Vulnerabilities found for alpine: 0 Critical, 1 High, 0 Medium, 0 Low, 0 Negligible" + ); + assert_eq!(diagnostic.severity, Some(DiagnosticSeverity::INFORMATION)); + assert_eq!( + diagnostic.range, + Range::new(Position::new(0, 0), Position::new(0, 11)) ); } +#[rstest] +#[awt] #[tokio::test] -async fn when_the_client_asks_for_code_lens_in_a_complex_compose_yaml_file_it_receives_them() { - let mut client = test::TestClient::new_initialized().await; - client - .open_file_with_contents("compose.yaml", include_str!("fixtures/compose.yaml")) - .await; - - let response = client - .request_available_code_lens_in_file("compose.yaml") - .await; - - assert_eq!( - response.unwrap(), - vec![ - CodeLens { - range: Range::new(Position::new(4, 13), Position::new(4, 25)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://compose.yaml/"), - json!({"start": {"line": 4, "character": 13}, "end": {"line": 4, "character": 25}}), - json!("nginx:latest") - ]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(9, 6), Position::new(9, 17)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://compose.yaml/"), - json!({"start": {"line": 9, "character": 6}, "end": {"line": 9, "character": 17}}), - json!("postgres:13") - ]) - }), - data: None - }, - CodeLens { - range: Range::new(Position::new(13, 11), Position::new(13, 21)), - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![ - json!("file://compose.yaml/"), - json!({"start": {"line": 13, "character": 11}, "end": {"line": 13, "character": 21}}), - json!("my-api:1.0") - ]) - }), - data: None - } - ] - ); +async fn test_shutdown(#[future] initialized_server: TestSetup) { + let result = initialized_server.server.shutdown().await; + assert!(result.is_ok()); } diff --git a/tests/test.rs b/tests/test.rs deleted file mode 100644 index d34bd12..0000000 --- a/tests/test.rs +++ /dev/null @@ -1,151 +0,0 @@ -use core::panic; -use std::collections::HashMap; -use std::fmt::Display; -use std::sync::Arc; - -use serde_json::json; -use sysdig_lsp::app::{LSPClient, LSPServer}; -use tokio::sync::Mutex; -use tower_lsp::LanguageServer; -use tower_lsp::lsp_types::{ - CodeActionOrCommand, CodeActionParams, CodeLens, CodeLensParams, Diagnostic, - DidOpenTextDocumentParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - Position, Range, TextDocumentIdentifier, TextDocumentItem, Url, -}; - -pub struct TestClient { - server: LSPServer, - recorder: TestClientRecorder, -} - -impl TestClient { - pub fn new() -> TestClient { - let recorder = TestClientRecorder::default(); - let server = LSPServer::new(recorder.clone()); - TestClient { server, recorder } - } - - pub async fn new_initialized() -> TestClient { - let mut client = Self::new(); - client.initialize_lsp().await; - client - } - - pub fn recorder(&self) -> &TestClientRecorder { - &self.recorder - } - - pub async fn initialize_lsp(&mut self) -> InitializeResult { - let result = self - .server - .initialize(InitializeParams { - initialization_options: Some(json!({"sysdig": - { - "api_url": "some_api_url" - } - })), - ..Default::default() - }) - .await - .expect("initialize failed"); - - self.server.initialized(InitializedParams {}).await; - - result - } - - pub async fn open_file_with_contents(&mut self, filename: &str, contents: &str) { - self.server - .did_open(DidOpenTextDocumentParams { - text_document: TextDocumentItem { - uri: url_from(filename), - text: contents.to_string(), - language_id: "".to_owned(), // unused - version: 0, // unused - }, - }) - .await; - } - - pub async fn request_available_actions_in_line( - &mut self, - filename: &str, - line_number: u32, - ) -> Option> { - self.server - .code_action(CodeActionParams { - text_document: TextDocumentIdentifier::new(url_from(filename)), - range: Range { - start: Position::new(line_number, 0), - end: Position::new(line_number, 0), - }, - context: Default::default(), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }) - .await - .unwrap_or_else(|_| { - panic!( - "unable to send code action for filename {filename} in line number {line_number}", - ) - }) - } - - pub async fn request_available_code_lens_in_file( - &mut self, - filename: &str, - ) -> Option> { - self.server - .code_lens(CodeLensParams { - text_document: TextDocumentIdentifier::new(url_from(filename)), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }) - .await - .unwrap_or_else(|_| panic!("unable to send code lens for filename {filename}")) - } -} - -fn url_from(filename: &str) -> Url { - Url::parse(&format!("file://{}", filename)).expect("unable to convert filename &str to Url") -} - -impl Default for TestClient { - fn default() -> Self { - Self::new() - } -} - -#[derive(Default, Clone)] -pub struct TestClientRecorder { - messages_shown: Arc>>, - diagnostics_for_each_file: Arc>>>, -} - -#[async_trait::async_trait] -impl LSPClient for TestClientRecorder { - async fn show_message(&self, message_type: MessageType, message: M) { - self.messages_shown - .lock() - .await - .push((message_type, message.to_string())); - } - - async fn publish_diagnostics( - &self, - url: &str, - diagnostics: Vec, - _other: Option, - ) { - self.diagnostics_for_each_file - .lock() - .await - .insert(url.to_string(), diagnostics); - } -} - -impl TestClientRecorder { - pub async fn messages_shown(&self) -> Vec<(MessageType, String)> { - self.messages_shown.lock().await.clone() - } -}