diff --git a/Cargo.lock b/Cargo.lock index d039aae..8b3ab39 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19,6 +19,23 @@ dependencies = [ "version_check", ] +[[package]] +name = "aho-corasick" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2969dcb958b36655471fc61f7e416fa76033bdd4bfed0678d8fee1e2d07a1f0" +dependencies = [ + "memchr", +] + +[[package]] +name = "auditable-cyclonedx" +version = "0.1.0" +dependencies = [ + "auditable-serde", + "cyclonedx-bom", +] + [[package]] name = "auditable-extract" version = "0.3.2" @@ -49,18 +66,39 @@ dependencies = [ "topological-sort", ] +[[package]] +name = "auditable2cdx" +version = "0.1.0" +dependencies = [ + "auditable-cyclonedx", + "auditable-info", + "serde_json", +] + [[package]] name = "autocfg" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "binfarce" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18464ccbb85e5dede30d70cc7676dc9950a0fb7dbf595a43d765be9123c616a2" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "camino" version = "1.1.1" @@ -134,6 +172,36 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "cyclonedx-bom" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed94ea2aaea25fdfec8a03ce34f92c4d2c00d741d0de681b923256448d3835b" +dependencies = [ + "base64", + "fluent-uri", + "once_cell", + "ordered-float", + "packageurl", + "regex", + "serde", + "serde_json", + "spdx", + "thiserror", + "time", + "uuid", + "xml-rs", +] + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", +] + [[package]] name = "dyn-clone" version = "1.0.9" @@ -146,6 +214,21 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e5c1c8368803113bf0c9584fc495a58b86dc8a29edbf8fe877d21d9507e797" +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags", +] + [[package]] name = "form_urlencoded" version = "1.1.0" @@ -155,6 +238,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -170,6 +264,12 @@ dependencies = [ "ahash", ] +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + [[package]] name = "idna" version = "0.3.0" @@ -190,6 +290,16 @@ dependencies = [ "hashbrown 0.12.3", ] +[[package]] +name = "indexmap" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", +] + [[package]] name = "itoa" version = "1.0.3" @@ -198,15 +308,15 @@ checksum = "6c8af84674fe1f223a982c933a0ee1086ac4d4052aa0fb8060c12c6ad838e754" [[package]] name = "libc" -version = "0.2.132" +version = "0.2.153" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" [[package]] name = "memchr" -version = "2.5.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" [[package]] name = "miniz_oxide" @@ -217,6 +327,21 @@ dependencies = [ "adler", ] +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + [[package]] name = "object" version = "0.30.3" @@ -225,15 +350,34 @@ checksum = "ea86265d3d3dcb6a27fc51bd29a4bf387fae9d2986b823079d4986af253eb439" dependencies = [ "crc32fast", "hashbrown 0.13.2", - "indexmap", + "indexmap 1.9.1", "memchr", ] [[package]] name = "once_cell" -version = "1.14.0" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "ordered-float" +version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f7254b99e31cad77da24b08ebf628882739a608578bb1bcdfc1f9c21260d7c0" +checksum = "a76df7075c7d4d01fdcb46c912dd17fba5b60c78ea480b475f2b6ab6f666584e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "packageurl" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c53362339d1c48910f1b0c35e2ae96e2d32e442c7dc3ac5f622908ec87221f08" +dependencies = [ + "percent-encoding", + "thiserror", +] [[package]] name = "percent-encoding" @@ -247,24 +391,59 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "proc-macro2" -version = "1.0.43" +version = "1.0.78" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.21" +version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ "proc-macro2", ] +[[package]] +name = "regex" +version = "1.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b62dbe01f0b06f9d8dc7d49e05a0785f153b00b2c227856282f671e0318c9b15" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bb987efffd3c6d0d8f5f89510bb458559eab11e4f869acb20bf845e016259cd" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08c74e62047bb2de4ff487b251e4a92e24f48745648451635cec7d591162d9f" + [[package]] name = "ryu" version = "1.0.11" @@ -292,7 +471,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 1.0.99", ] [[package]] @@ -306,22 +485,22 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.147" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d193d69bae983fc11a79df82342761dfbf28a99fc8d203dca4c3c1b590948965" +checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.147" +version = "1.0.197" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f1d362ca8fc9c3e3a7484440752472d68a6caa98f1ab81d99b5dfe517cec852" +checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.50", ] [[package]] @@ -332,15 +511,16 @@ checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 1.0.99", ] [[package]] name = "serde_json" -version = "1.0.85" +version = "1.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44" +checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" dependencies = [ + "indexmap 2.2.3", "itoa", "ryu", "serde", @@ -355,6 +535,21 @@ dependencies = [ "serde", ] +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + +[[package]] +name = "spdx" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bde1398b09b9f93fc2fc9b9da86e362693e999d3a54a8ac47a99a5a73f638b" +dependencies = [ + "smallvec", +] + [[package]] name = "syn" version = "1.0.99" @@ -366,6 +561,68 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "2.0.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74f1bdc9872430ce9b75da68329d1c1746faf50ffac5f19e02b71e37ff881ffb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.50", +] + +[[package]] +name = "time" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinyvec" version = "1.6.0" @@ -408,7 +665,7 @@ version = "0.19.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "239410c8609e8125456927e6707163a3b1fdb40561e4b803bc041f466ccfdc13" dependencies = [ - "indexmap", + "indexmap 1.9.1", "serde", "serde_spanned", "toml_datetime", @@ -453,12 +710,27 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "uuid" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f00cc9702ca12d3c81455259621e676d0f7251cec66a21e98fe2e9a37db93b2a" +dependencies = [ + "getrandom", +] + [[package]] name = "version_check" version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + [[package]] name = "which" version = "4.3.0" @@ -478,3 +750,9 @@ checksum = "ae8970b36c66498d8ff1d66685dc86b91b29db0c7739899012f63a63814b4b28" dependencies = [ "memchr", ] + +[[package]] +name = "xml-rs" +version = "0.8.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fcb9cbac069e033553e8bb871be2fbdffcab578eb25bd0f7c508cedc6dcd75a" diff --git a/Cargo.toml b/Cargo.toml index de85ac4..dd10c59 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,4 +5,5 @@ members = [ "auditable-extract", "auditable-serde", "cargo-auditable", + "auditable-cyclonedx", "auditable2cdx", ] diff --git a/auditable-cyclonedx/Cargo.toml b/auditable-cyclonedx/Cargo.toml new file mode 100644 index 0000000..0cbd753 --- /dev/null +++ b/auditable-cyclonedx/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "auditable-cyclonedx" +version = "0.1.0" +edition = "2021" +authors = ["Sergey \"Shnatsel\" Davidoff "] +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-secure-code/cargo-auditable" +description = "Convert data encoded by `cargo auditable` to CycloneDX format" +categories = ["encoding"] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cyclonedx-bom = "0.5.0" +auditable-serde = {version = "0.6.1", path = "../auditable-serde"} diff --git a/auditable-cyclonedx/src/lib.rs b/auditable-cyclonedx/src/lib.rs new file mode 100644 index 0000000..af87169 --- /dev/null +++ b/auditable-cyclonedx/src/lib.rs @@ -0,0 +1,130 @@ +#![forbid(unsafe_code)] + +use std::str::FromStr; + +pub use auditable_serde; +use auditable_serde::{Package, Source}; +pub use cyclonedx_bom; + +use cyclonedx_bom::models::property::{Properties, Property}; +use cyclonedx_bom::prelude::*; +use cyclonedx_bom::{ + external_models::uri::Purl, + models::{ + component::Classification, + component::Component, + dependency::{Dependencies, Dependency}, + metadata::Metadata, + }, +}; + +/// Converts the metadata embedded by `cargo auditable` to a minimal CycloneDX document +/// that is heavily optimized to reduce the size +pub fn auditable_to_minimal_cdx(input: &auditable_serde::VersionInfo) -> Bom { + let mut bom = Bom { + serial_number: None, // the serial number would mess with reproducible builds + ..Default::default() + }; + + // The toplevel component goes into its own field, as per the spec: + // https://cyclonedx.org/docs/1.5/json/#metadata_component + let (root_idx, root_pkg) = root_package(input); + let root_component = pkg_to_component(root_pkg, root_idx); + let metadata = Metadata { + component: Some(root_component), + ..Default::default() + }; + bom.metadata = Some(metadata); + + // Fill in the component list, excluding the toplevel component (already encoded) + let components: Vec = input + .packages + .iter() + .enumerate() + .filter(|(_idx, pkg)| !pkg.root) + .map(|(idx, pkg)| pkg_to_component(pkg, idx)) + .collect(); + let components = Components(components); + bom.components = Some(components); + + // Populate the dependency tree. Actually really easy, it's the same format as ours! + let dependencies: Vec = input + .packages + .iter() + .enumerate() + .map(|(idx, pkg)| Dependency { + dependency_ref: idx.to_string(), + dependencies: pkg.dependencies.iter().map(|idx| idx.to_string()).collect(), + }) + .collect(); + let dependencies = Dependencies(dependencies); + bom.dependencies = Some(dependencies); + + // Validate the generated SBOM if running in debug mode (or release with debug assertions) + if cfg!(debug_assertions) { + assert_eq!(bom.validate(), ValidationResult::Passed); + } + bom +} + +fn pkg_to_component(pkg: &auditable_serde::Package, idx: usize) -> Component { + let component_type = if pkg.root { + Classification::Application + } else { + Classification::Library + }; + // The only requirement for `bom_ref` according to the spec is that it's unique, + // so we just keep the unique numbering already used in the original + let bom_ref = idx.to_string(); + let mut result = Component::new( + component_type, + &pkg.name, + &pkg.version.to_string(), + Some(bom_ref), + ); + // PURL encodes the package origin (registry, git, local) - sort of, anyway + let purl = purl(pkg); + let purl = Purl::from_str(&purl).unwrap(); + result.purl = Some(purl); + // Record the dependency kind + match pkg.kind { + // `Runtime` is the default and does not need to be recorded. + auditable_serde::DependencyKind::Runtime => (), + auditable_serde::DependencyKind::Build => { + let p = Property::new("cdx:rustc:dependency_kind".to_owned(), "build"); + result.properties = Some(Properties(vec![p])); + } + } + result +} + +fn root_package(input: &auditable_serde::VersionInfo) -> (usize, &Package) { + // we can unwrap here because VersionInfo is already validated during deserialization + input + .packages + .iter() + .enumerate() + .find(|(_idx, pkg)| pkg.root) + .expect("VersionInfo contains no root package!") +} + +fn purl(pkg: &auditable_serde::Package) -> String { + // The purl crate exposed by `cyclonedx-bom` doesn't support the qualifiers we need, + // so we just build the PURL as a string. + // Yeah, we could use *yet another* dependency to build the PURL, + // but we use it such trivial ways that it isn't worth the trouble. + // Specifically, the crate names that crates.io accepts don't need percent-encoding + // and the fixed values we put in arguments don't either + // (but percent-encoding is underspecified and not interoperable anyway, + // see e.g. https://github.com/package-url/purl-spec/pull/261) + let mut purl = format!("pkg:cargo/{}@{}", pkg.name, pkg.version); + purl.push_str(match &pkg.source { + Source::CratesIo => "", // this is the default, nothing to qualify + Source::Git => "&vcs_url=redacted", + Source::Local => "&download_url=redacted", + Source::Registry => "&repository_url=redacted", + Source::Other(_) => "&download_url=redacted", + unknown => panic!("Unknown source: {:?}", unknown), + }); + purl +} diff --git a/auditable2cdx/Cargo.toml b/auditable2cdx/Cargo.toml new file mode 100644 index 0000000..8d1f15f --- /dev/null +++ b/auditable2cdx/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "auditable2cdx" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +auditable-info = {version = "0.7.0", path = "../auditable-info"} +auditable-cyclonedx = {version = "0.1.0", path = "../auditable-cyclonedx"} +serde_json = {version = "1.0.114", features = ["preserve_order"] } # the feature is needed for workarounds module only diff --git a/auditable2cdx/src/main.rs b/auditable2cdx/src/main.rs new file mode 100644 index 0000000..47488a7 --- /dev/null +++ b/auditable2cdx/src/main.rs @@ -0,0 +1,19 @@ +use std::{io::Write, path::Path}; + +use auditable_cyclonedx::auditable_to_minimal_cdx; +use auditable_info::audit_info_from_file; + +mod workarounds; + +fn main() { + let input_filename = std::env::args_os() + .nth(1) + .expect("No input file specified!"); + let info = audit_info_from_file(Path::new(&input_filename), Default::default()).unwrap(); + let cyclonedx = auditable_to_minimal_cdx(&info); + let mut json_bytes: Vec = Vec::new(); + cyclonedx.output_as_json_v1_3(&mut json_bytes).unwrap(); + let min_json = workarounds::minify_bom(&json_bytes); + let mut stdout = std::io::stdout().lock(); + stdout.write_all(min_json.as_bytes()).unwrap(); +} diff --git a/auditable2cdx/src/workarounds.rs b/auditable2cdx/src/workarounds.rs new file mode 100644 index 0000000..d824b20 --- /dev/null +++ b/auditable2cdx/src/workarounds.rs @@ -0,0 +1,30 @@ +/// Accepts BOM in JSON and minifies it, +/// working around https://github.com/CycloneDX/cyclonedx-rust-cargo/issues/628 +pub fn minify_bom(bom: &[u8]) -> String { + let mut json: serde_json::Value = serde_json::from_slice(bom).unwrap(); + // clear the unnecessary toplevel fields + let toplevel = json.as_object_mut().unwrap(); + toplevel.remove("version"); + toplevel.remove("serialNumber"); + // clear components field if empty + if let Some(components) = toplevel.get_mut("components") { + let components = components.as_array().unwrap(); + if components.is_empty() { + toplevel.remove("components"); + } + } + // clear empty arrays in dependencies + if let Some(deps) = toplevel.get_mut("dependencies") { + let deps = deps.as_array_mut().unwrap(); + deps.iter_mut().for_each(|dependency| { + if let Some(deps_array) = dependency.get("dependsOn") { + let deps_array = deps_array.as_array().unwrap(); + if deps_array.is_empty() { + dependency.as_object_mut().unwrap().remove("dependsOn"); + } + } + }); + } + // .to_string() writes the minified JSON, unlike .to_string_pretty() + serde_json::to_string(&json).unwrap() +}