diff --git a/Cargo.lock b/Cargo.lock index c5f2214..a541c4f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "async-trait" version = "0.1.89" @@ -356,6 +362,12 @@ dependencies = [ "syn", ] +[[package]] +name = "doc-comment" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -423,6 +435,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -606,12 +624,30 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + [[package]] name = "hashbrown" version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1057,6 +1093,18 @@ dependencies = [ "url", ] +[[package]] +name = "marked-yaml" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a76cf4e66a8ffccfce983161b0faafe61a5ef03fe875ef2e3deb897e4e915fa" +dependencies = [ + "doc-comment", + "hashlink", + "serde", + "yaml-rust2", +] + [[package]] name = "memchr" version = "2.7.5" @@ -1700,6 +1748,19 @@ dependencies = [ "time", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.11.4", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -1823,7 +1884,7 @@ dependencies = [ [[package]] name = "sysdig-lsp" -version = "0.5.1" +version = "0.6.0" dependencies = [ "async-trait", "bollard", @@ -1834,12 +1895,14 @@ dependencies = [ "futures", "itertools", "lazy_static", + "marked-yaml", "rand", "regex", "reqwest", "semver", "serde", "serde_json", + "serde_yaml", "serial_test", "tar", "thiserror", @@ -2191,6 +2254,12 @@ version = "1.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -2651,6 +2720,17 @@ dependencies = [ "rustix", ] +[[package]] +name = "yaml-rust2" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2462ea039c445496d8793d052e13787f2b90e750b833afee748e601c17621ed9" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink", +] + [[package]] name = "yoke" version = "0.8.0" diff --git a/Cargo.toml b/Cargo.toml index bb00875..056ac57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sysdig-lsp" -version = "0.5.1" +version = "0.6.0" edition = "2024" authors = [ "Sysdig Inc." ] readme = "README.md" @@ -18,12 +18,14 @@ clap = { version = "4.5.34", features = ["derive"] } dirs = "6.0.0" futures = "0.3.31" itertools = "0.14.0" +marked-yaml = { version = "0.8.0", features = ["serde"] } rand = "0.9.0" regex = "1.11.1" reqwest = "0.12.14" semver = "1.0.26" serde = { version = "1.0.219", features = ["alloc", "derive"] } serde_json = "1.0.135" +serde_yaml = "0.9.34" serial_test = { version = "3.2.0", features = ["file_locks"] } tar = "0.4.44" thiserror = "2.0.12" diff --git a/README.md b/README.md index 616c8a7..f982314 100644 --- a/README.md +++ b/README.md @@ -15,16 +15,16 @@ helping you detect vulnerabilities and misconfigurations earlier in the developm ## Features -| Feature | **[VSCode Extension](https://github.com/sysdiglabs/vscode-extension)** | **[Sysdig LSP](./docs/features/README.md)** | -|---------------------------------|------------------------------------------------------------------------|----------------------------------------------------------| -| Scan base image in Dockerfile | Supported | [Supported](./docs/features/scan_base_image.md) (0.1.0+) | -| Code lens support | Supported | [Supported](./docs/features/code_lens.md) (0.2.0+) | -| Build and Scan Dockerfile | Supported | [Supported](./docs/features/build_and_scan.md) (0.4.0+) | -| Layered image analysis | Supported | [Supported](./docs/features/layered_analysis.md) (0.5.0+)| -| Docker-compose image analysis | Supported | In roadmap | -| K8s Manifest image analysis | Supported | In roadmap | -| Infrastructure-as-code analysis | Supported | In roadmap | -| Vulnerability explanation | Supported | In roadmap | +| Feature | **[VSCode Extension](https://github.com/sysdiglabs/vscode-extension)** | **[Sysdig LSP](./docs/features/README.md)** | +|---------------------------------|------------------------------------------------------------------------|------------------------------------------------------------------------| +| Scan base image in Dockerfile | Supported | [Supported](./docs/features/scan_base_image.md) (0.1.0+) | +| Code lens support | Supported | [Supported](./docs/features/code_lens.md) (0.2.0+) | +| Build and Scan Dockerfile | Supported | [Supported](./docs/features/build_and_scan.md) (0.4.0+) | +| Layered image analysis | Supported | [Supported](./docs/features/layered_analysis.md) (0.5.0+) | +| Docker-compose image analysis | Supported | [Supported](./docs/features/docker_compose_image_analysis.md) (0.6.0+) | +| K8s Manifest image analysis | Supported | In roadmap | +| Infrastructure-as-code analysis | Supported | In roadmap | +| Vulnerability explanation | Supported | In roadmap | ## Build diff --git a/docs/features/README.md b/docs/features/README.md index fe99a0c..0d10d56 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -18,4 +18,7 @@ Sysdig LSP provides tools to integrate container security checks into your devel - Scans each Dockerfile layer individually for precise vulnerability identification. - Supports detailed analysis in single-stage and multi-stage Dockerfiles. +## [Docker-compose Image Analysis](./docker_compose_image_analysis.md) +- Scans the images defined in your `docker-compose.yml` files for vulnerabilities. + See the linked documents for more details. diff --git a/docs/features/docker_compose_image_analysis.gif b/docs/features/docker_compose_image_analysis.gif new file mode 100644 index 0000000..8dfb0ad Binary files /dev/null and b/docs/features/docker_compose_image_analysis.gif differ diff --git a/docs/features/docker_compose_image_analysis.md b/docs/features/docker_compose_image_analysis.md new file mode 100644 index 0000000..b648e11 --- /dev/null +++ b/docs/features/docker_compose_image_analysis.md @@ -0,0 +1,20 @@ +# Docker-compose Image Analysis + +Sysdig LSP scans the images defined in your `docker-compose.yml` files to identify vulnerabilities. + +> [!IMPORTANT] +> Sysdig LSP analyzes each service's image in your compose file. + +![Sysdig LSP executing docker-compose image scan](./docker_compose_image_analysis.gif) + +## Example + +```yaml +services: + web: + image: nginx:latest + db: + image: postgres:13 +``` + +In this example, Sysdig LSP will provide actions to scan both `nginx:latest` and `postgres:13` images. diff --git a/src/app/commands.rs b/src/app/commands.rs index 07ef9ab..80ca70b 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -67,59 +67,33 @@ impl CommandExecutor where C: LSPClient, { - pub async fn scan_image_from_file( + pub async fn scan_image( &self, uri: &str, - line: u32, + range: Range, + image_name: &str, image_scanner: &impl ImageScanner, ) -> Result<()> { - let document_text = self - .document_database - .read_document_text(uri) - .await - .ok_or_else(|| { - Error::internal_error().with_message("unable to obtain document to scan") - })?; - - let image_for_selected_line = - self.image_from_line(line, &document_text).ok_or_else(|| { - Error::parse_error().with_message(format!( - "unable to retrieve image for the selected line: {line}" - )) - })?; - self.show_message( MessageType::INFO, - format!("Starting scan of {image_for_selected_line}...").as_str(), + format!("Starting scan of {image_name}...").as_str(), ) .await; let scan_result = image_scanner - .scan_image(image_for_selected_line) + .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_for_selected_line}.").as_str(), + format!("Finished scan of {image_name}.").as_str(), ) .await; let 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, + range, severity: Some(DiagnosticSeverity::HINT), message: "No vulnerabilities found.".to_owned(), ..Default::default() @@ -128,7 +102,7 @@ where if scan_result.has_vulnerabilities() { diagnostic.message = format!( "Vulnerabilities found for {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", - image_for_selected_line, + image_name, scan_result.count_vulns_of_severity(VulnSeverity::Critical), scan_result.count_vulns_of_severity(VulnSeverity::High), scan_result.count_vulns_of_severity(VulnSeverity::Medium), diff --git a/src/app/lsp_server.rs b/src/app/lsp_server.rs index b698b7d..6e6615a 100644 --- a/src/app/lsp_server.rs +++ b/src/app/lsp_server.rs @@ -11,7 +11,7 @@ use tower_lsp::lsp_types::{ CodeLens, CodeLensOptions, CodeLensParams, Command, DidChangeConfigurationParams, DidChangeTextDocumentParams, DidOpenTextDocumentParams, ExecuteCommandOptions, ExecuteCommandParams, InitializeParams, InitializeResult, InitializedParams, MessageType, - Position, Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, + Range, ServerCapabilities, TextDocumentSyncCapability, TextDocumentSyncKind, }; use tracing::{debug, info}; @@ -19,6 +19,7 @@ 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, @@ -83,6 +84,92 @@ impl TryFrom<&str> for SupportedCommands { } } +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 @@ -152,8 +239,6 @@ where } async fn code_action(&self, params: CodeActionParams) -> Result> { - let mut code_actions = vec![]; - let Some(content) = self .query_executor .get_document_text(params.text_document.uri.as_str()) @@ -165,48 +250,23 @@ where ))); }; - let Some(last_line_starting_with_from_statement) = content - .lines() - .enumerate() - .filter(|(_, line)| line.trim_start().starts_with("FROM ")) - .map(|(line_num, _)| line_num) - .last() - else { - return Ok(None); - }; - - let Ok(line_selected_as_usize) = usize::try_from(params.range.start.line) else { - return Err(Error::internal_error().with_message(format!( - "unable to parse u32 as usize: {}", - params.range.start.line - ))); - }; - - if last_line_starting_with_from_statement == line_selected_as_usize { - code_actions.push(CodeActionOrCommand::Command(Command { - title: "Build and scan".to_string(), - command: SupportedCommands::ExecuteBuildAndScan.to_string(), - arguments: Some(vec![ - json!(params.text_document.uri), - json!(line_selected_as_usize), - ]), - })); - code_actions.push(CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: SupportedCommands::ExecuteBaseImageScan.to_string(), - arguments: Some(vec![ - json!(params.text_document.uri), - json!(line_selected_as_usize), - ]), - })); - } + 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 mut code_lens = vec![]; - let Some(content) = self .query_executor .get_document_text(params.text_document.uri.as_str()) @@ -218,48 +278,21 @@ where ))); }; - let Some(last_line_starting_with_from_statement) = content - .lines() - .enumerate() - .filter(|(_, line)| line.trim_start().starts_with("FROM ")) - .map(|(line_num, _)| line_num) - .last() - else { - return Ok(None); - }; + 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(); - code_lens.push(CodeLens { - range: Range::new( - Position::new(last_line_starting_with_from_statement as u32, 0), - Position::new(last_line_starting_with_from_statement as u32, 0), - ), - command: Some(Command { - title: "Build and scan".to_string(), - command: SupportedCommands::ExecuteBuildAndScan.to_string(), - arguments: Some(vec![ - json!(params.text_document.uri), - json!(last_line_starting_with_from_statement), - ]), - }), - data: None, - }); - code_lens.push(CodeLens { - range: Range::new( - Position::new(last_line_starting_with_from_statement as u32, 0), - Position::new(last_line_starting_with_from_statement as u32, 0), - ), - command: Some(Command { - title: "Scan base image".to_string(), - command: SupportedCommands::ExecuteBaseImageScan.to_string(), - arguments: Some(vec![ - json!(params.text_document.uri), - json!(last_line_starting_with_from_statement), - ]), - }), - data: None, - }); - - Ok(Some(code_lens)) + Ok(Some(code_lenses)) } async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { @@ -308,12 +341,20 @@ async fn execute_command_scan_base_image( 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(range) = params.arguments.get(1) else { + return Err(Error::internal_error().with_message("no range 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 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 = { @@ -325,7 +366,7 @@ async fn execute_command_scan_base_image( server .command_executor - .scan_image_from_file(uri, line, &image_scanner) + .scan_image(uri, range, image_name, &image_scanner) .await?; Ok(()) diff --git a/src/infra/compose_ast_parser.rs b/src/infra/compose_ast_parser.rs new file mode 100644 index 0000000..deddb1a --- /dev/null +++ b/src/infra/compose_ast_parser.rs @@ -0,0 +1,386 @@ +use tower_lsp::lsp_types::{Position, Range}; + +#[derive(Debug, PartialEq)] +pub struct ImageInstruction { + pub image_name: String, + pub range: Range, +} + +#[derive(Debug)] +pub enum ParseError { + InvalidYaml(marked_yaml::LoadError), +} + +pub fn parse_compose_file(content: &str) -> Result, ParseError> { + let mut instructions = Vec::new(); + + let node = marked_yaml::parse_yaml(0, content).map_err(ParseError::InvalidYaml)?; + find_images_recursive(&node, &mut instructions, content); + + Ok(instructions) +} + +fn find_images_recursive( + node: &marked_yaml::Node, + instructions: &mut Vec, + content: &str, +) { + match node { + marked_yaml::Node::Mapping(map) => { + if let Some(services) = map.get("services") { + find_images_recursive(services, instructions, content); + return; // Stop descending further from the root if 'services' is found + } + + for (key, value) in map.iter() { + if key.as_str() == "image" { + if let Some(instruction) = try_create_image_instruction(value, content) { + instructions.push(instruction); + } + } else { + find_images_recursive(value, instructions, content); + } + } + } + marked_yaml::Node::Sequence(seq) => { + for item in seq.iter() { + find_images_recursive(item, instructions, content); + } + } + _ => {} + } +} + +fn try_create_image_instruction( + node: &marked_yaml::Node, + content: &str, +) -> Option { + let marked_yaml::Node::Scalar(scalar) = node else { + return None; + }; + + let image_name = scalar.as_str().trim().to_string(); + if !is_valid_image_name(&image_name) { + return None; + } + + let start = node.span().start()?; + + let range = calculate_range(start, &image_name, content); + Some(ImageInstruction { image_name, range }) +} + +fn is_valid_image_name(name: &str) -> bool { + !name.is_empty() && name != "null" +} + +fn calculate_range(start: &marked_yaml::Marker, image_name: &str, content: &str) -> Range { + let start_line = start.line() as u32 - 1; + let start_char = start.column() as u32 - 1; + + let start_line_content = content.lines().nth(start_line as usize).unwrap_or(""); + let first_char = start_line_content.chars().nth(start_char as usize); + + let mut raw_len = image_name.len(); + if let Some(c) = first_char + && (c == '"' || c == '\'') + { + raw_len += 2; + } + + let end_char = start_char + raw_len as u32; + + Range { + start: Position { + line: start_line, + character: start_char, + }, + end: Position { + line: start_line, + character: end_char, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp::lsp_types::Position; + + #[test] + fn test_parse_simple_compose_file() { + let content = r#" +services: + web: + image: nginx:latest +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ImageInstruction { + image_name: "nginx:latest".to_string(), + range: Range { + start: Position { + line: 3, + character: 11 + }, + end: Position { + line: 3, + character: 23 + }, + }, + } + ); + } + + #[test] + fn test_parse_compose_file_with_multiple_services() { + let content = r#" +version: '3.8' +services: + web: + image: nginx:latest + db: + image: postgres:13 + api: + build: . +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + ImageInstruction { + image_name: "nginx:latest".to_string(), + range: Range { + start: Position { + line: 4, + character: 11 + }, + end: Position { + line: 4, + character: 23 + }, + }, + } + ); + assert_eq!( + result[1], + ImageInstruction { + image_name: "postgres:13".to_string(), + range: Range { + start: Position { + line: 6, + character: 11 + }, + end: Position { + line: 6, + character: 22 + }, + }, + } + ); + } + + #[test] + fn test_parse_compose_file_no_image() { + let content = r#" +services: + web: + build: . +"#; + let result = parse_compose_file(content).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_empty_file() { + let content = ""; + let result = parse_compose_file(content).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_invalid_yaml() { + let content = r#" +services: + web: + image: nginx:latest + db + image: postgres:13 +"#; + let result = parse_compose_file(content); + assert!(result.is_err()); + } + + #[test] + fn test_parse_with_quoted_keys() { + let content = r#" +services: + web: + "image": nginx:latest +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ImageInstruction { + image_name: "nginx:latest".to_string(), + range: Range { + start: Position { + line: 3, + character: 13 + }, + end: Position { + line: 3, + character: 25 + }, + }, + } + ); + } + + #[test] + fn test_parse_with_quoted_values() { + let content = r#" +services: + web: + image: "nginx:latest" + db: + image: 'postgres:13' +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 2); + assert_eq!( + result[0], + ImageInstruction { + image_name: "nginx:latest".to_string(), + range: Range { + start: Position { + line: 3, + character: 11 + }, + end: Position { + line: 3, + character: 25 + }, + }, + } + ); + assert_eq!( + result[1], + ImageInstruction { + image_name: "postgres:13".to_string(), + range: Range { + start: Position { + line: 5, + character: 11 + }, + end: Position { + line: 5, + character: 24 + }, + }, + } + ); + } + + #[test] + fn test_parse_with_multiline_literal() { + let content = r#" +services: + web: + image: | + nginx:latest +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ImageInstruction { + image_name: "nginx:latest".to_string(), + range: Range { + start: Position { + line: 4, + character: 6 + }, + end: Position { + line: 4, + character: 18 + }, + }, + } + ); + } + + #[test] + fn test_parse_with_complex_image_name() { + let content = r#" +services: + complex_service: + image: private-registry.company.com:5000/project/team/service-image:1.2.3-beta +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ImageInstruction { + image_name: + "private-registry.company.com:5000/project/team/service-image:1.2.3-beta" + .to_string(), + range: Range { + start: Position { + line: 3, + character: 11 + }, + end: Position { + line: 3, + character: 82 + }, + }, + } + ); + } + + #[test] + fn test_parse_with_null_or_empty_image_values() { + let content = r#" +services: + web: + image: + db: + image: "" + cache: + image: null +"#; + let result = parse_compose_file(content).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_parse_with_end_of_line_comment() { + let content = r#" +services: + web: + image: nginx:latest # Use the latest nginx image +"#; + let result = parse_compose_file(content).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!( + result[0], + ImageInstruction { + image_name: "nginx:latest".to_string(), + range: Range { + start: Position { + line: 3, + character: 11 + }, + end: Position { + line: 3, + character: 23 + }, + }, + } + ); + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 3dc34ae..86096ee 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,3 +1,4 @@ +mod compose_ast_parser; mod docker_image_builder; mod dockerfile_ast_parser; mod scanner_binary_manager; @@ -6,5 +7,6 @@ mod sysdig_image_scanner_result; pub use sysdig_image_scanner::{SysdigAPIToken, SysdigImageScanner}; pub mod lsp_logger; +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/tests/fixtures/compose.yaml b/tests/fixtures/compose.yaml new file mode 100644 index 0000000..a1a3e2b --- /dev/null +++ b/tests/fixtures/compose.yaml @@ -0,0 +1,14 @@ +# Test compose file with various YAML features +services: + # Web service using a quoted key for the image + web: + "image": nginx:latest # Inline comment + + # Database service using a literal block scalar for the image + db: + image: | + postgres:13 + + # Another service for good measure + api: + image: my-api:1.0 diff --git a/tests/fixtures/docker-compose.yml b/tests/fixtures/docker-compose.yml new file mode 100644 index 0000000..0aa08ab --- /dev/null +++ b/tests/fixtures/docker-compose.yml @@ -0,0 +1,5 @@ +services: + web: + image: nginx:latest + db: + image: postgres:13 diff --git a/tests/general.rs b/tests/general.rs index 2a017b0..d091dc4 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -42,7 +42,11 @@ async fn when_the_client_asks_for_the_existing_code_actions_it_receives_the_avai CodeActionOrCommand::Command(Command { title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) + arguments: Some(vec![ + json!("file://dockerfile/"), + json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 11}}), + json!("alpine"), + ]) }) ] ); @@ -77,7 +81,11 @@ async fn when_the_client_asks_for_the_existing_code_actions_but_the_dockerfile_c CodeActionOrCommand::Command(Command { title: "Scan base image".to_string(), command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) + arguments: Some(vec![ + json!("file://dockerfile/"), + json!({"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 11}}), + json!("ubuntu"), + ]) }) ] ); @@ -102,7 +110,7 @@ async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_availab response.unwrap(), vec![ CodeLens { - range: Range::new(Position::new(0, 0), Position::new(0, 0)), + 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(), @@ -111,11 +119,15 @@ async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_availab data: None }, CodeLens { - range: Range::new(Position::new(0, 0), Position::new(0, 0)), + 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!(0)]) + arguments: Some(vec![ + json!("file://dockerfile/"), + json!({"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 11}}), + json!("alpine"), + ]) }), data: None } @@ -139,7 +151,7 @@ async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_cont response.unwrap(), vec![ CodeLens { - range: Range::new(Position::new(1, 0), Position::new(1, 0)), + 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(), @@ -148,11 +160,147 @@ async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_cont data: None }, CodeLens { - range: Range::new(Position::new(1, 0), Position::new(1, 0)), + 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!(1)]) + arguments: Some(vec![ + json!("file://dockerfile/"), + json!({"start": {"line": 1, "character": 0}, "end": {"line": 1, "character": 11}}), + json!("ubuntu"), + ]) + }), + data: None + } + ] + ); +} + +#[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; + + let response = client + .request_available_code_lens_in_file("docker-compose.yml") + .await; + + 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 + }, + 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 + } + ] + ); +} + +#[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; + + let response = client + .request_available_actions_in_line("docker-compose.yml", 2) + .await; + + 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"), + ]) + })] + ); +} + +#[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 }