diff --git a/Cargo.lock b/Cargo.lock index f4b648b..dc23d51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,6 +158,50 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +[[package]] +name = "bollard" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ccca1260af6a459d75994ad5acc1651bcabcbdbc41467cc9786519ab854c30" +dependencies = [ + "base64", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-util", + "hyperlocal", + "log", + "pin-project-lite", + "serde", + "serde_derive", + "serde_json", + "serde_repr", + "serde_urlencoded", + "thiserror", + "tokio", + "tokio-util", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-stubs" +version = "1.47.1-rc.27.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f179cfbddb6e77a5472703d4b30436bff32929c0aa8a9008ecf23d1d3cdd0da" +dependencies = [ + "serde", + "serde_repr", + "serde_with", +] + [[package]] name = "bumpalo" version = "3.17.0" @@ -202,9 +246,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.34" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e958897981290da2a852763fe9cdb89cd36977a5d729023127095fa94d95e2ff" +checksum = "d8aa86934b44c19c50f87cc2790e19f54f7a67aedb64101c2e1a2e5ecfb73944" dependencies = [ "clap_builder", "clap_derive", @@ -212,9 +256,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.34" +version = "4.5.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83b0f35019843db2160b5bb19ae09b4e6411ac33fc6a712003c33e03090e2489" +checksum = "2414dbb2dd0695280da6ea9261e327479e9d37b0630f6b53ba2a11c60c679fd9" dependencies = [ "anstream", "anstyle", @@ -275,6 +319,16 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "deranged" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cfac68e08048ae1883171632c2aef3ebc555621ae56fbccce1cbf22dd7f058" +dependencies = [ + "powerfmt", + "serde", +] + [[package]] name = "dirs" version = "6.0.0" @@ -344,6 +398,18 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", +] + [[package]] name = "fnv" version = "1.0.7" @@ -514,13 +580,19 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.8.0", "slab", "tokio", "tokio-util", "tracing", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -539,6 +611,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "http" version = "1.3.1" @@ -579,6 +657,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.6.0" @@ -592,6 +676,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -599,6 +684,21 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + [[package]] name = "hyper-rustls" version = "0.27.5" @@ -652,6 +752,21 @@ dependencies = [ "tracing", ] +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.63" @@ -815,6 +930,17 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + [[package]] name = "indexmap" version = "2.8.0" @@ -823,6 +949,7 @@ checksum = "3954d50fe15b02142bf25d3b8bdadb634ec3948f103d04ffe3031bc8fe9d7058" dependencies = [ "equivalent", "hashbrown 0.15.2", + "serde", ] [[package]] @@ -882,6 +1009,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.9.0", "libc", + "redox_syscall", ] [[package]] @@ -984,6 +1112,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-traits" version = "0.2.19" @@ -1131,6 +1265,21 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.94" @@ -1155,6 +1304,36 @@ version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74765f6d916ee2faa39bc8e68e4f3ed8949b48cccdac59983d287a7cb71ce9c5" +[[package]] +name = "rand" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" +dependencies = [ + "rand_chacha", + "rand_core", + "zerocopy", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.2", +] + [[package]] name = "redox_syscall" version = "0.5.10" @@ -1270,9 +1449,9 @@ checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" [[package]] name = "rustix" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e56a18552996ac8d29ecc3b190b4fdbb2d91ca4ec396de7bbffaf43f3d637e96" +checksum = "d97817398dd4bb2e6da002002db259209759911da105da92bec29ccb12cf58bf" dependencies = [ "bitflags 2.9.0", "errno", @@ -1446,6 +1625,23 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6b6f7f2fcb69f747921f79f3926bd1e203fce4fef62c268dd3abfb6d86029aa" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.8.0", + "serde", + "serde_derive", + "serde_json", + "time", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -1572,20 +1768,25 @@ dependencies = [ [[package]] name = "sysdig-lsp" -version = "0.3.0" +version = "0.4.0" dependencies = [ "async-trait", + "bollard", + "bytes", "chrono", "clap", "dirs", + "futures", "itertools", "lazy_static", + "rand", "regex", "reqwest", "semver", "serde", "serde_json", "serial_test", + "tar", "thiserror", "tokio", "tower-lsp", @@ -1615,6 +1816,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.19.1" @@ -1658,6 +1870,37 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2295,6 +2538,16 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" +[[package]] +name = "xattr" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d65cbf2f12c15564212d48f4e3dfb87923d25d611f2aed18f4cb23f0413d89e" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.7.5" @@ -2319,6 +2572,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2586fea28e186957ef732a5f8b3be2da217d65c5969d4b1e17f973ebbe876879" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a996a8f63c5c4448cd959ac1bab0aaa3306ccfd060472f85943ee0750f0169be" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zerofrom" version = "0.1.6" diff --git a/Cargo.toml b/Cargo.toml index 315690f..2e68101 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sysdig-lsp" -version = "0.3.0" +version = "0.4.0" edition = "2024" authors = [ "Sysdig Inc." ] readme = "README.md" @@ -11,16 +11,21 @@ publish = false # We don't want to publish it to crates.io yet. [dependencies] async-trait = "0.1.85" +bollard = "0.18.1" +bytes = "1.10.1" chrono = { version = "0.4.40", features = ["serde"] } clap = { version = "4.5.34", features = ["derive"] } dirs = "6.0.0" +futures = "0.3.31" itertools = "0.14.0" +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" serial_test = { version = "3.2.0", features = ["file_locks"] } +tar = "0.4.44" thiserror = "2.0.12" tokio = { version = "1.43.0", features = ["full"] } tower-lsp = "0.20.0" diff --git a/README.md b/README.md index 8b57e46..bb26e55 100644 --- a/README.md +++ b/README.md @@ -15,11 +15,11 @@ helping you detect vulnerabilities and misconfigurations earlier in the developm ## Features -| Feature | **[VSCode Extension](https://github.com/sysdiglabs/vscode-extension)** | **Sysdig LSP** | +| 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 | In roadmap | +| Build and Scan Dockerfile | Supported | [Supported](./docs/features/build_and_scan.md) (0.4.0+) | | Layered image analysis | Supported | In roadmap | | Docker-compose image analysis | Supported | In roadmap | | K8s Manifest image analysis | Supported | In roadmap | diff --git a/docs/features/README.md b/docs/features/README.md index 9baaa8c..443e96b 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -10,4 +10,8 @@ Sysdig LSP provides tools to integrate container security checks into your devel - Displays actionable commands directly within the editor (e.g., initiating base image scans). - Enables quick access to frequently performed actions. +## [Build and Scan](./build_and_scan.md) +- Builds and scans the entire final Dockerfile image used in production. +- Supports multi-stage Dockerfiles, analyzing final stage and explicitly copied artifacts from intermediate stages. + See the linked documents for more details. diff --git a/docs/features/build_and_scan.gif b/docs/features/build_and_scan.gif new file mode 100644 index 0000000..d421dc5 Binary files /dev/null and b/docs/features/build_and_scan.gif differ diff --git a/docs/features/build_and_scan.md b/docs/features/build_and_scan.md new file mode 100644 index 0000000..b097acf --- /dev/null +++ b/docs/features/build_and_scan.md @@ -0,0 +1,37 @@ +# Build and Scan + +Sysdig LSP builds your entire Dockerfile and scans the resulting final image to identify vulnerabilities early in your development workflow. +This ensures the exact image used in production is secure and compliant. + +> [!IMPORTANT] +> Sysdig LSP analyzes the fully built final image, including all instructions executed during the build process. +> +> In multi-stage Dockerfiles, only artifacts copied into the final stage using instructions like `COPY --from=build` are analyzed, as intermediate stages are not part of the final runtime environment. + +![Sysdig LSP executing build and scan in idea-community](./build_and_scan.gif) + +## Examples + +### Single-stage Dockerfile (scanned entirely) + +```dockerfile +# Base image and all instructions are scanned +FROM alpine:latest +RUN apk add --no-cache python3 +COPY ./app /app +``` + +### Multi-stage Dockerfile (partially scanned) + +```dockerfile +# Build stage (scanned only for artifacts copied to final stage) +FROM golang:1.19 AS build +RUN go build -o app main.go + +# Final image (fully scanned) +FROM alpine:3.17 +COPY --from=build /app /app +ENTRYPOINT ["/app"] +``` + +In this multi-stage Dockerfile, Sysdig LSP scans the complete final built image, including the final runtime stage (`alpine:3.17`) and any artifacts explicitly copied from previous stages (`golang:1.19`). diff --git a/flake.lock b/flake.lock index 25425a7..bc6b4d7 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1742272065, - "narHash": "sha256-ud8vcSzJsZ/CK+r8/v0lyf4yUntVmDq6Z0A41ODfWbE=", + "lastModified": 1743568003, + "narHash": "sha256-ZID5T65E8ruHqWRcdvZLsczWDOAWIE7om+vQOREwiX0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "3549532663732bfd89993204d40543e9edaec4f2", + "rev": "b7ba7f9f45c5cd0d8625e9e217c28f8eb6a19a76", "type": "github" }, "original": { diff --git a/src/app/commands.rs b/src/app/commands.rs index fddd5f8..4158302 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -1,9 +1,16 @@ +use std::{ + path::{Path, PathBuf}, + str::FromStr, +}; + use tower_lsp::{ - jsonrpc::{Error, ErrorCode, Result}, + jsonrpc::{Error, Result}, lsp_types::{Diagnostic, DiagnosticSeverity, MessageType, Position, Range}, }; -use super::{ImageScanner, InMemoryDocumentDatabase, LSPClient}; +use super::{ + ImageBuilder, ImageScanner, InMemoryDocumentDatabase, LSPClient, lsp_server::WithContext, +}; pub struct CommandExecutor { client: C, @@ -59,36 +66,27 @@ impl CommandExecutor where C: LSPClient, { - pub async fn scan_image_from_file( + pub async fn scan_image_from_file( &self, uri: &str, line: u32, - image_scanner: &S, + image_scanner: &impl ImageScanner, ) -> Result<()> { let document_text = self .document_database .read_document_text(uri) .await - .ok_or_else(|| Error { - code: ErrorCode::InternalError, - message: "unable to obtain document to scan".into(), - data: None, + .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 { - code: ErrorCode::ParseError, - message: format!("unable to retrieve image for the selected line: {}", line) - .into(), - data: None, - })?; - - let range_for_selected_line = document_text - .lines() - .nth(line as usize) - .map(|x| x.len() as u32) - .unwrap_or(u32::MAX); + 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, @@ -99,11 +97,7 @@ where let scan_result = image_scanner .scan_image(image_for_selected_line) .await - .map_err(|e| Error { - code: ErrorCode::InternalError, - message: e.to_string().into(), - data: None, - })?; + .map_err(|e| Error::internal_error().with_message(e.to_string()))?; self.show_message( MessageType::INFO, @@ -112,11 +106,20 @@ where .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 { - start: Position::new(line, 0), - end: Position::new(line, range_for_selected_line), - }, + range: range_for_selected_line, severity: Some(DiagnosticSeverity::HINT), message: "No vulnerabilities found.".to_owned(), ..Default::default() @@ -145,4 +148,104 @@ where .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 = { + 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.has_vulnerabilities() { + let v = &scan_result.vulnerabilities; + diagnostic.message = format!( + "Vulnerabilities found for Dockerfile in {}: {} Critical, {} High, {} Medium, {} Low, {} Negligible", + uri_without_file_path, v.critical, v.high, v.medium, v.low, v.negligible + ); + + diagnostic.severity = Some(if scan_result.is_compliant { + DiagnosticSeverity::INFORMATION + } else { + DiagnosticSeverity::ERROR + }); + } + + diagnostic + }; + + self.document_database + .remove_diagnostics(uri.to_str().unwrap()) + .await; + self.document_database + .append_document_diagnostics(uri.to_str().unwrap(), &[diagnostic]) + .await; + self.publish_all_diagnostics().await + } } diff --git a/src/app/component_factory.rs b/src/app/component_factory.rs index dba733b..bdf72aa 100644 --- a/src/app/component_factory.rs +++ b/src/app/component_factory.rs @@ -1,12 +1,10 @@ -use std::{env::VarError, sync::Arc}; +use std::env::VarError; +use bollard::Docker; use serde::Deserialize; use thiserror::Error; -use tokio::sync::{OwnedRwLockReadGuard, RwLock}; -use crate::infra::{SysdigAPIToken, SysdigImageScanner}; - -use super::ImageScanner; +use crate::infra::{DockerImageBuilder, SysdigAPIToken, SysdigImageScanner}; #[derive(Clone, Debug, Default, Deserialize)] pub struct Config { @@ -19,11 +17,12 @@ pub struct SysdigConfig { api_token: Option, } -#[derive(Clone)] +#[derive(Clone, Default)] pub struct ComponentFactory { config: Option, - scanner: Arc>>, + scanner: Option, + builder: Option, } #[derive(Error, Debug)] @@ -33,51 +32,49 @@ pub enum ComponentFactoryError { #[error("unable to retrieve sysdig api token from env var: {0}")] UnableToRetrieveAPITokenFromEnvVar(#[from] VarError), + + #[error("docker client error: {0:?}")] + DockerClientError(#[from] bollard::errors::Error), } impl ComponentFactory { - pub fn uninit() -> Self { - Self { - config: None, - scanner: Default::default(), - } - } - - pub async fn is_initialized(&self) -> bool { - self.config.is_some() - } - - pub async fn initialize_with(&mut self, config: Config) { + pub fn initialize_with(&mut self, config: Config) { self.config.replace(config); - self.scanner.write().await.take(); + self.scanner.take(); } - pub async fn image_scanner( - &self, - ) -> Result>, ComponentFactoryError> { - { - let scanner = self.scanner.clone().read_owned().await; - if scanner.is_some() { - return Ok(scanner); - } - }; + pub fn image_scanner(&mut self) -> Result { + if self.scanner.is_some() { + return Ok(self.scanner.clone().unwrap()); + } - let Some(config) = self.config.clone() else { + let Some(config) = &self.config else { return Err(ComponentFactoryError::ConfigurationNotProvided); }; let token = config .sysdig .api_token + .clone() .map(Ok) .unwrap_or_else(|| std::env::var("SECURE_API_TOKEN").map(SysdigAPIToken))?; - self.scanner - .write() - .await - .replace(SysdigImageScanner::new(config.sysdig.api_url, token)); + 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()); + } - Ok(self.scanner.clone().read_owned().await) + 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()) } } @@ -85,19 +82,33 @@ impl ComponentFactory { mod test { use super::{ComponentFactory, Config}; - #[tokio::test] - async fn it_loads_the_factory_uninit() { - let factory = ComponentFactory::uninit(); + #[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.is_initialized().await); + assert!(factory.image_scanner().is_err()); } - #[tokio::test] - async fn it_creates_a_scanner_with_the_provided_config() { - let mut factory = ComponentFactory::uninit(); + #[test] + fn it_creates_a_scanner_after_initializing() { + let mut factory = ComponentFactory::default(); + + factory.initialize_with(Config::default()); + + assert!(factory.image_scanner().is_ok()); + } - factory.initialize_with(Config::default()).await; + #[test] + fn it_creates_a_builder_without_config() { + let mut factory = ComponentFactory::default(); - assert!(factory.is_initialized().await); + assert!(factory.image_builder().is_ok()); } } diff --git a/src/app/image_builder.rs b/src/app/image_builder.rs new file mode 100644 index 0000000..5162c6e --- /dev/null +++ b/src/app/image_builder.rs @@ -0,0 +1,19 @@ +use std::{error::Error, path::Path}; + +use thiserror::Error; + +#[async_trait::async_trait] +pub trait ImageBuilder { + async fn build_image(&self, containerfile: &Path) -> Result; +} + +pub struct ImageBuildResult { + pub image_id: String, + pub image_name: String, +} + +#[derive(Error, Debug)] +pub enum ImageBuildError { + #[error("image builder error: {0}")] + ImageBuilderError(#[from] Box), +} diff --git a/src/app/scanner.rs b/src/app/image_scanner.rs similarity index 92% rename from src/app/scanner.rs rename to src/app/image_scanner.rs index 6c75abe..ad1ddaf 100644 --- a/src/app/scanner.rs +++ b/src/app/image_scanner.rs @@ -33,7 +33,4 @@ pub struct Vulnerabilities { pub enum ImageScanError { #[error("error in the internal scanner execution: {0}")] InternalScannerError(Box), - - #[error("unknown error")] - Unknown(#[from] Box), } diff --git a/src/app/lsp_server.rs b/src/app/lsp_server.rs index dae2521..7ff4aa6 100644 --- a/src/app/lsp_server.rs +++ b/src/app/lsp_server.rs @@ -1,4 +1,6 @@ use std::borrow::Cow; +use std::path::PathBuf; +use std::str::FromStr; use serde_json::{Value, json}; use tokio::sync::RwLock; @@ -31,7 +33,7 @@ impl LSPServer { LSPServer { command_executor: CommandExecutor::new(client, document_database.clone()), query_executor: QueryExecutor::new(document_database.clone()), - component_factory: RwLock::new(ComponentFactory::uninit()), // to be initialized in the initialize method of the LSP + component_factory: Default::default(), // to be initialized in the initialize method of the LSP } } } @@ -42,19 +44,13 @@ where { async fn initialize_component_factory_with(&self, config: &Value) -> Result<()> { let Ok(config) = serde_json::from_value::(config.clone()) else { - return Err(lsp_error( - ErrorCode::InternalError, - format!("unable to transform json into config: {}", config), - )); + 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) - .await; + self.component_factory.write().await.initialize_with(config); debug!("updated configuration"); Ok(()) @@ -62,13 +58,15 @@ where } pub enum SupportedCommands { - ExecuteScan, + 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::ExecuteScan => "sysdig-lsp.execute-scan", + Self::ExecuteBaseImageScan => "sysdig-lsp.execute-scan", + Self::ExecuteBuildAndScan => "sysdig-lsp.execute-build-and-scan", }) } } @@ -78,7 +76,8 @@ impl TryFrom<&str> for SupportedCommands { fn try_from(value: &str) -> std::result::Result { match value { - "sysdig-lsp.execute-scan" => Ok(SupportedCommands::ExecuteScan), + "sysdig-lsp.execute-scan" => Ok(SupportedCommands::ExecuteBaseImageScan), + "sysdig-lsp.execute-build-and-scan" => Ok(SupportedCommands::ExecuteBuildAndScan), _ => Err(format!("command not supported: {}", value)), } } @@ -110,7 +109,10 @@ where resolve_provider: Some(false), }), execute_command_provider: Some(ExecuteCommandOptions { - commands: vec![SupportedCommands::ExecuteScan.to_string()], + commands: vec![ + SupportedCommands::ExecuteBaseImageScan.to_string(), + SupportedCommands::ExecuteBuildAndScan.to_string(), + ], ..Default::default() }), ..Default::default() @@ -150,18 +152,17 @@ 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()) .await else { - return Err(lsp_error( - ErrorCode::InternalError, - format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ), - )); + return Err(Error::internal_error().with_message(format!( + "unable to extract document content for document: {}", + ¶ms.text_document.uri + ))); }; let Some(last_line_starting_with_from_statement) = content @@ -175,43 +176,46 @@ where }; let Ok(line_selected_as_usize) = usize::try_from(params.range.start.line) else { - return Err(lsp_error( - ErrorCode::InternalError, - format!("unable to parse u32 as usize: {}", params.range.start.line), - )); + 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 { - let action = Command { + 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::ExecuteScan.to_string(), + command: SupportedCommands::ExecuteBaseImageScan.to_string(), arguments: Some(vec![ json!(params.text_document.uri), json!(line_selected_as_usize), ]), - }; - - return Ok(Some(vec![CodeActionOrCommand::Command(action)])); + })); } - return Ok(None); + Ok(Some(code_actions)) } async fn code_lens(&self, params: CodeLensParams) -> Result>> { - info!("{}", format!("received code lens params: {params:?}")); + let mut code_lens = vec![]; let Some(content) = self .query_executor .get_document_text(params.text_document.uri.as_str()) .await else { - return Err(lsp_error( - ErrorCode::InternalError, - format!( - "unable to extract document content for document: {}", - ¶ms.text_document.uri - ), - )); + return Err(Error::internal_error().with_message(format!( + "unable to extract document content for document: {}", + ¶ms.text_document.uri + ))); }; let Some(last_line_starting_with_from_statement) = content @@ -224,88 +228,61 @@ where return Ok(None); }; - let scan_base_image_lens = CodeLens { - range: Range { - start: Position { - line: last_line_starting_with_from_statement as u32, - character: 0, - }, - end: Position { - line: last_line_starting_with_from_statement as u32, - character: 0, - }, - }, + 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::ExecuteScan.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(vec![scan_base_image_lens])) + Ok(Some(code_lens)) } async fn execute_command(&self, params: ExecuteCommandParams) -> Result> { let command: SupportedCommands = params.command.as_str().try_into().map_err(|e| { - lsp_error( - ErrorCode::InternalError, - format!("unable to parse command: {}", e), - ) + Error::internal_error().with_message(format!("unable to parse command: {}", e)) })?; - match command { - SupportedCommands::ExecuteScan => { - if params.arguments.len() < 2 { - return Err(lsp_error( - ErrorCode::InternalError, - format!( - "error executing command '{}', invalid number of arguments: {}, expected 2", - command, - params.arguments.len() - ), - )); - } - - let uri = params - .arguments - .first() - .and_then(|x| x.as_str()) - .unwrap_or_default(); - let line = params - .arguments - .get(1) - .and_then(|x| x.as_u64()) - .and_then(|x| u32::try_from(x).ok()) - .unwrap_or_default(); - - let component_factory_lock = self.component_factory.read().await; - let image_scanner = component_factory_lock.image_scanner().await.map_err(|e| { - lsp_error( - ErrorCode::InternalError, - format!("unable to create image scanner: {e}"), - ) - })?; - - self.command_executor - .scan_image_from_file( - uri, - line, - image_scanner.as_ref().ok_or_else(|| { - lsp_error( - ErrorCode::InternalError, - "unable to retrieve created image scanner", - ) - })?, - ) - .await?; - - Ok(None) + 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), + }; + + result.map_err(|mut e: Error| { + e.message = format!("error calling command: '{command}': {e}").into(); + e + }) } async fn shutdown(&self) -> Result<()> { @@ -313,10 +290,94 @@ where } } -fn lsp_error(code: ErrorCode, message: impl Into>) -> Error { - Error { - code, - message: message.into(), - data: None, +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(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 = { + 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_from_file(uri, line, &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/mod.rs b/src/app/mod.rs index 3d396e2..2969b17 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,12 +1,14 @@ mod commands; mod component_factory; mod document_database; +mod image_builder; +mod image_scanner; mod lsp_client; mod lsp_server; mod queries; -mod scanner; pub use document_database::*; +pub use image_builder::{ImageBuildError, ImageBuildResult, ImageBuilder}; +pub use image_scanner::{ImageScanError, ImageScanResult, ImageScanner, Vulnerabilities}; pub use lsp_client::LSPClient; pub use lsp_server::LSPServer; -pub use scanner::{ImageScanError, ImageScanResult, ImageScanner, Vulnerabilities}; diff --git a/src/infra/docker_image_builder.rs b/src/infra/docker_image_builder.rs new file mode 100644 index 0000000..7e29200 --- /dev/null +++ b/src/infra/docker_image_builder.rs @@ -0,0 +1,197 @@ +use std::path::Path; + +use bollard::{Docker, image::BuildImageOptions, secret::BuildInfo}; +use bytes::Bytes; +use futures::StreamExt; +use thiserror::Error; +use tracing::info; + +use crate::app::{ImageBuildError, ImageBuildResult, ImageBuilder}; + +#[derive(Error, Debug)] +pub(in crate::infra) enum DockerImageBuilderError { + #[error("internal tokio join error: {0}")] + TokioJoin(#[from] tokio::task::JoinError), + + #[error("internal io error: {0}")] + IO(#[from] std::io::Error), + + #[error("internal docker client error: {0:?}")] + Docker(#[from] bollard::errors::Error), + + #[error("internal generic error: {0}")] + Generic(String), +} + +impl From for ImageBuildError { + fn from(value: DockerImageBuilderError) -> Self { + ImageBuildError::ImageBuilderError(Box::new(value)) + } +} + +#[derive(Clone)] +pub struct DockerImageBuilder { + docker_client: Docker, +} + +impl DockerImageBuilder { + pub fn new(docker_client: Docker) -> Self { + Self { docker_client } + } + + async fn build_image_from_dockerfile( + &self, + containerfile: &Path, + ) -> Result { + let tar_contents = self + .pack_containerfile_dir_into_a_tar(containerfile) + .await?; + + let image_name = format!("sysdig-lsp-image-build-{}", rand::random::()); + let mut results = self.docker_client.build_image( + BuildImageOptions { + dockerfile: containerfile + .file_name() + .and_then(|osstr| osstr.to_str()) + .unwrap(), + t: image_name.as_str(), + rm: true, + ..Default::default() + }, + None, + Some(Bytes::from_owner(tar_contents)), + ); + + while let Some(result) = results.next().await { + match result { + Ok(BuildInfo { aux, .. }) if aux.is_some() => { + let image_id = aux.unwrap().id.unwrap(); + info!("image built: {}", &image_id); + return Ok(ImageBuildResult { + image_name, + image_id, + }); + } + Err(error) => return Err(DockerImageBuilderError::Docker(error)), + _ => {} + } + } + + Err(DockerImageBuilderError::Generic( + "image was built, but no id was detected, this should have never happened".to_string(), + )) + } + + async fn pack_containerfile_dir_into_a_tar( + &self, + containerfile: &Path, + ) -> Result, DockerImageBuilderError> { + let Some(parent) = containerfile.parent() else { + return Err(DockerImageBuilderError::Generic( + "unable to find parent for provided containerfile".to_string(), + )); + }; + let parent = parent.to_owned(); + + tokio::task::spawn_blocking(move || -> Result, DockerImageBuilderError> { + let mut tar_builder = tar::Builder::new(Vec::new()); + + tar_builder.append_dir_all(".", parent)?; + tar_builder.finish()?; + + Ok(tar_builder.into_inner()?) + }) + .await? + } +} + +#[async_trait::async_trait] +impl ImageBuilder for DockerImageBuilder { + async fn build_image(&self, containerfile: &Path) -> Result { + Ok(self.build_image_from_dockerfile(containerfile).await?) + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, str::FromStr}; + + use bollard::Docker; + + use crate::{ + app::{ImageBuildError, ImageBuilder}, + infra::DockerImageBuilder, + }; + + #[tokio::test] + async fn it_builds_a_dockerfile() { + let docker_client = Docker::connect_with_local_defaults().unwrap(); + let image_builder = DockerImageBuilder::new(docker_client); + + let image_built = image_builder + .build_image(&PathBuf::from_str("tests/fixtures/Dockerfile").unwrap()) + .await + .unwrap(); + + assert!( + image_built + .image_name + .starts_with("sysdig-lsp-image-build-") + ); + assert!(!image_built.image_id.is_empty()); + } + + #[tokio::test] + async fn it_builds_a_containerfile() { + let docker_client = Docker::connect_with_local_defaults().unwrap(); + let image_builder = DockerImageBuilder::new(docker_client); + + let image_built = image_builder + .build_image(&PathBuf::from_str("tests/fixtures/Containerfile").unwrap()) + .await + .unwrap(); + + assert!( + image_built + .image_name + .starts_with("sysdig-lsp-image-build-") + ); + assert!(!image_built.image_id.is_empty()); + } + + #[tokio::test] + async fn it_fails_to_build_non_existent_dockerfile() { + let docker_client = Docker::connect_with_local_defaults().unwrap(); + let image_builder = DockerImageBuilder::new(docker_client); + + let image_built = image_builder + .build_image(&PathBuf::from_str("tests/fixtures/Nonexistent.dockerfile").unwrap()) + .await; + + assert!(image_built.is_err()); + assert_eq!( + image_built.err().unwrap().to_string(), + "image builder error: internal docker client error: DockerResponseServerError { status_code: 500, message: \"Cannot locate specified Dockerfile: Nonexistent.dockerfile\" }" + ); + } + + #[tokio::test] + async fn it_builds_an_invalid_dockerfile_and_fails() { + let docker_client = Docker::connect_with_local_defaults().unwrap(); + let image_builder = DockerImageBuilder::new(docker_client); + + let image_built = image_builder + .build_image(&PathBuf::from_str("tests/fixtures/Invalid.dockerfile").unwrap()) + .await; + + assert!(image_built.is_err()); + assert!(matches!( + image_built, + Err(ImageBuildError::ImageBuilderError(_)) + )); + assert_eq!( + image_built.err().unwrap().to_string(), + "image builder error: internal docker client error: DockerStreamError { error: \"The command '/bin/sh -c apt update # should fail, apt is not present in alpine' returned a non-zero code: 127\" }" + ); + } +} diff --git a/src/infra/mod.rs b/src/infra/mod.rs index 032c7f9..b25a885 100644 --- a/src/infra/mod.rs +++ b/src/infra/mod.rs @@ -1,6 +1,8 @@ +mod docker_image_builder; mod scanner_binary_manager; mod sysdig_image_scanner; mod sysdig_image_scanner_result; pub use sysdig_image_scanner::{SysdigAPIToken, SysdigImageScanner}; pub mod lsp_logger; +pub use docker_image_builder::DockerImageBuilder; diff --git a/src/infra/sysdig_image_scanner.rs b/src/infra/sysdig_image_scanner.rs index b63b51f..b8a92ba 100644 --- a/src/infra/sysdig_image_scanner.rs +++ b/src/infra/sysdig_image_scanner.rs @@ -90,6 +90,7 @@ impl SysdigImageScanner { "--output-schema=v1", "--separate-by-layer", "--console-log", + "--skipupload", "--apiurl", self.url.as_str(), ]; diff --git a/tests/fixtures/Dockerfile b/tests/fixtures/Dockerfile index 55155e8..da56154 100644 --- a/tests/fixtures/Dockerfile +++ b/tests/fixtures/Dockerfile @@ -1,3 +1,3 @@ FROM nginx:latest -RUN echo "Hello" +RUN apt update && apt full-upgrade -y diff --git a/tests/fixtures/Invalid.dockerfile b/tests/fixtures/Invalid.dockerfile new file mode 100644 index 0000000..72ca6ef --- /dev/null +++ b/tests/fixtures/Invalid.dockerfile @@ -0,0 +1,3 @@ +FROM alpine:latest + +RUN apt update # should fail, apt is not present in alpine diff --git a/tests/general.rs b/tests/general.rs index c150217..2a017b0 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -33,11 +33,18 @@ async fn when_the_client_asks_for_the_existing_code_actions_it_receives_the_avai 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://dockerfile/"), json!(0)]) - })] + 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!(0)]) + }) + ] ); } @@ -53,7 +60,7 @@ async fn when_the_client_asks_for_the_existing_code_actions_but_the_dockerfile_c let response_for_first_line = client .request_available_actions_in_line("Dockerfile", 0) .await; - assert!(response_for_first_line.is_none()); + assert!(response_for_first_line.unwrap().is_empty()); let response_for_second_line = client .request_available_actions_in_line("Dockerfile", 1) @@ -61,11 +68,18 @@ async fn when_the_client_asks_for_the_existing_code_actions_but_the_dockerfile_c assert_eq!( response_for_second_line.unwrap(), - vec![CodeActionOrCommand::Command(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) - })] + 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!(1)]) + }) + ] ); } @@ -86,24 +100,26 @@ async fn when_the_client_asks_for_the_existing_code_lens_it_receives_the_availab // Expect a CodeLens with the appropriate command. assert_eq!( response.unwrap(), - vec![CodeLens { - range: Range { - start: Position { - line: 0, - character: 0 - }, - end: Position { - line: 0, - character: 0 - } + vec![ + CodeLens { + range: Range::new(Position::new(0, 0), Position::new(0, 0)), + 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 }, - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) - }), - data: None - }] + CodeLens { + range: Range::new(Position::new(0, 0), Position::new(0, 0)), + command: Some(Command { + title: "Scan base image".to_string(), + command: "sysdig-lsp.execute-scan".to_string(), + arguments: Some(vec![json!("file://dockerfile/"), json!(0)]) + }), + data: None + } + ] ); } @@ -121,23 +137,25 @@ async fn when_the_client_asks_for_the_existing_code_lens_but_the_dockerfile_cont assert_eq!( response.unwrap(), - vec![CodeLens { - range: Range { - start: Position { - line: 1, - character: 0 - }, - end: Position { - line: 1, - character: 0 - } + vec![ + CodeLens { + range: Range::new(Position::new(1, 0), Position::new(1, 0)), + 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 }, - command: Some(Command { - title: "Scan base image".to_string(), - command: "sysdig-lsp.execute-scan".to_string(), - arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) - }), - data: None - }] + CodeLens { + range: Range::new(Position::new(1, 0), Position::new(1, 0)), + command: Some(Command { + title: "Scan base image".to_string(), + command: "sysdig-lsp.execute-scan".to_string(), + arguments: Some(vec![json!("file://dockerfile/"), json!(1)]) + }), + data: None + } + ] ); }