diff --git a/.gitignore b/.gitignore index 90856732..73eaa7b5 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ *.iml **/node_modules go/csaf_ffi/lib/ +csaf-service/config/local.toml diff --git a/Cargo.lock b/Cargo.lock index e69c8999..2277acc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -111,6 +117,15 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "askama" version = "0.14.0" @@ -153,12 +168,76 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "basic-toml" version = "0.1.10" @@ -373,6 +452,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "config" +version = "0.15.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139" +dependencies = [ + "pathdiff", + "serde_core", + "toml 1.1.3+spec-1.1.0", + "winnow 1.0.3", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -388,6 +479,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.8.2" @@ -466,7 +566,7 @@ dependencies = [ [[package]] name = "csaf-converter" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "clap", @@ -475,7 +575,7 @@ dependencies = [ [[package]] name = "csaf-ffi" -version = "0.5.1" +version = "0.5.2" dependencies = [ "csaf-rs", "serde_json", @@ -485,7 +585,7 @@ dependencies = [ [[package]] name = "csaf-result-json" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anstream", "anstyle", @@ -498,7 +598,7 @@ dependencies = [ [[package]] name = "csaf-rs" -version = "0.5.1" +version = "0.5.2" dependencies = [ "chrono", "criterion", @@ -518,12 +618,34 @@ dependencies = [ "strum", "symspell", "tempfile", + "thiserror 2.0.20", + "utoipa", "uuid", ] +[[package]] +name = "csaf-service" +version = "0.1.1" +dependencies = [ + "axum", + "config", + "csaf-rs", + "http", + "http-body-util", + "serde", + "serde_json", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "utoipa", + "utoipa-swagger-ui", +] + [[package]] name = "csaf-validator" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anstream", "anstyle", @@ -585,6 +707,17 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_builder" version = "0.20.2" @@ -645,9 +778,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "email_address" @@ -697,6 +830,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.4.1" @@ -726,6 +869,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fraction" version = "0.15.4" @@ -745,6 +897,15 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -770,9 +931,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -902,6 +1063,86 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +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.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -1195,6 +1436,21 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.0" @@ -1207,12 +1463,49 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + [[package]] name = "nom" version = "7.1.3" @@ -1223,6 +1516,15 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + [[package]] name = "num" version = "0.4.3" @@ -1378,6 +1680,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1701,6 +2009,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -1836,6 +2150,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1857,6 +2182,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1868,12 +2205,37 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "siphasher" version = "1.0.3" @@ -1898,6 +2260,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c" +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "spdx" version = "0.13.5" @@ -2001,6 +2373,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "synstructure" version = "0.13.2" @@ -2074,6 +2452,15 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -2094,6 +2481,34 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -2109,6 +2524,19 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.3", +] + [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" @@ -2136,7 +2564,7 @@ dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -2145,7 +2573,7 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -2154,9 +2582,115 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + [[package]] name = "type-generator" -version = "0.5.1" +version = "0.5.2" dependencies = [ "clap", "json_dotpath", @@ -2227,6 +2761,12 @@ dependencies = [ "typify-impl", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -2285,7 +2825,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -2341,7 +2881,7 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_meta", ] @@ -2382,6 +2922,18 @@ dependencies = [ "weedle2", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -2394,6 +2946,48 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utoipa" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.117", +] + +[[package]] +name = "utoipa-swagger-ui" +version = "9.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d047458f1b5b65237c2f6dc6db136945667f40a7668627b3490b9513a3d43a55" +dependencies = [ + "axum", + "base64", + "mime_guess", + "regex", + "rust-embed", + "serde", + "serde_json", + "url", + "utoipa", + "zip", +] + [[package]] name = "uuid" version = "1.24.1" @@ -2416,6 +3010,12 @@ dependencies = [ "vsimd", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version_check" version = "0.9.5" @@ -2438,6 +3038,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -2664,9 +3270,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -2816,9 +3422,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -2868,8 +3474,40 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12598812502ed0105f607f941c386f43d441e00148fce9dec3ca5ffb0bde9308" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml index c0e6bcd1..77009b9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [workspace] -members = ["csaf-validator", "csaf-rs", "type-generator", "csaf-converter", "csaf-ffi", "csaf-result-json"] +members = ["csaf-validator", "csaf-rs", "type-generator", "csaf-converter", "csaf-ffi", "csaf-result-json", "csaf-service"] resolver = "2" [workspace.package] -version = "0.5.1" +version = "0.5.2" edition = "2024" rust-version = "1.88.0" license = "Apache-2.0" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..b2a805dd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM rust:1.95-alpine3.21 AS builder + +RUN apk add --no-cache musl-dev curl + +WORKDIR /app + +# Copy workspace files +COPY Cargo.toml Cargo.lock ./ +COPY csaf-rs/ csaf-rs/ +COPY csaf-service/ csaf-service/ +COPY csaf-validator/ csaf-validator/ +COPY csaf-converter/ csaf-converter/ +COPY type-generator/ type-generator/ + +# Drop csaf-ffi from workspace members (not needed for the service) +RUN sed -i 's/, "csaf-ffi"//' Cargo.toml + +# Build the service in release mode (musl is the native target on Alpine) +RUN cargo build --release -p csaf-service + +# Runtime stage +FROM alpine:3.21 + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /app/target/release/csaf-service /usr/local/bin/csaf-service + +ENV CSAF_SERVICE__SERVER__PORT=8082 +# Listen on all interfaces for container compatibility +ENV CSAF_SERVICE__SERVER__HOST=0.0.0.0 +EXPOSE 8082 + +ENTRYPOINT ["csaf-service"] diff --git a/README.md b/README.md index f129ee5a..1cbb3cd5 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This is work-in-progress. - `csaf-validator` contains a command line tool to validate CSAF documents. - `csaf-rs` contains the actual validator library which currently publishes a crate to [crates.io](https://crates.io/crates/csaf-rs). - `csaf-ffi` contains [UniFFI](https://github.com/mozilla/uniffi-rs) bindings that expose `csaf-rs` to other languages (Go, WASM/TypeScript, and more). +- `csaf-service` contains a web API that exposes the validation functionality of `csaf-rs` via HTTP endpoints. - `go/` contains generated Go bindings and integration tests. - `wasm/` contains generated WASM/TypeScript bindings and integration tests. @@ -190,47 +191,6 @@ cd go go test -v ./csaf_ffi/ ``` -As a demonstration there is a small CLI and Webserver example included. - -##### Cli - -```bash -cd go -CGO_LDFLAGS="-L$HOME/.cache/csaf-ffi/lib/$(go env GOOS)_$(go env GOARCH)" go run -buildvcs=false ./cmd/example/ -``` - -##### Web server (API) - -```bash -cd go -CGO_LDFLAGS="-L$HOME/.cache/csaf-ffi/lib/$(go env GOOS)_$(go env GOARCH)" go run -buildvcs=false ./cmd/webapi/ -``` - -The server listens on port `8080` by default. Set the `PORT` environment variable -to use a different port: - -```bash -PORT=9090 go run -buildvcs=false ./cmd/webapi/ -``` - -*Endpoints* - -| Method | Path | Description | -|--------|------|-------------| -| `POST` | `/api/validate/json` | Validate a CSAF document sent as a raw JSON body | -| `POST` | `/api/validate/upload` | Validate a CSAF document sent as a multipart file upload (field: `file`) | - -Both endpoints accept the optional query parameter `?preset=basic` (default), -`?preset=extended`, or `?preset=full`. - -*Example:* - -```bash -curl -X POST http://localhost:8080/api/validate/json?preset=basic \ - -H 'Content-Type: application/json' \ - --data-binary @my-csaf.json -``` - #### WASM The WASM bindings are generated via [uniffi-bindgen-js](https://crates.io/crates/uniffi-bindgen-js). @@ -245,6 +205,20 @@ cargo install uniffi-bindgen-js --version 0.2.1 This creates TypeScript + WASM output in `wasm/`. + +## Docker + +You can also run the service via Docker. To build and run the container, use the following commands: +```bash +docker build -t csaf-service . +docker run -p 8082:8082 csaf-service +``` +> Note: This is intended for development and testing purposes only, as the container is not hardened for production use. + +You can then access the service at `http://localhost:8082` and navigate to `/swagger-ui` for the API documentation. +For further configuration options, please refer to the [csaf-service README](csaf-service/README.md). + + ## Implementation status in regards to the Standard * ✅ Implemented diff --git a/csaf-converter/Cargo.toml b/csaf-converter/Cargo.toml index 1f95bb56..d817921f 100644 --- a/csaf-converter/Cargo.toml +++ b/csaf-converter/Cargo.toml @@ -14,6 +14,6 @@ publish = false workspace = true [dependencies] -csaf-rs = { path = "../csaf-rs", version = "0.5.1", features = ["default", "converter"] } +csaf-rs = { path = "../csaf-rs", version = "0.5.2", features = ["default", "converter"] } anyhow = "1.0.93" clap = { version = "4.5.23", features = ["derive"] } diff --git a/csaf-ffi/src/document.rs b/csaf-ffi/src/document.rs index 130682eb..d48a82f9 100644 --- a/csaf-ffi/src/document.rs +++ b/csaf-ffi/src/document.rs @@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex}; -use csaf::csaf::raw::{HasParsed, RawDocument}; +use csaf::csaf::raw::RawDocument; use csaf::csaf2_0::loader::load_document as load_2_0; use csaf::csaf2_1::loader::load_document as load_2_1; use csaf::schema::csaf2_0::schema::CommonSecurityAdvisoryFramework as Csaf20; @@ -89,22 +89,10 @@ impl CsafDocument { message: "lock poisoned".into(), })?; let result = match &*guard { - DocumentInner::V20(raw) => { - let parsed = raw - .get_parsed() - .as_ref() - .map_err(|e| CsafError::LoadError { message: e.clone() })?; - validate_by_preset(parsed, &self.version_string, &preset) - }, - DocumentInner::V21(raw) => { - let parsed = raw - .get_parsed() - .as_ref() - .map_err(|e| CsafError::LoadError { message: e.clone() })?; - validate_by_preset(parsed, &self.version_string, &preset) - }, + DocumentInner::V20(raw) => validate_by_preset(raw, &self.version_string, &preset), + DocumentInner::V21(raw) => validate_by_preset(raw, &self.version_string, &preset), }; - Ok(result.into()) + result.map_or_else(|e| Err(CsafError::from(e)), |r| Ok(r.into())) } /// Run a single validation test by ID. @@ -113,20 +101,8 @@ impl CsafDocument { message: "lock poisoned".into(), })?; let result = match &*guard { - DocumentInner::V20(raw) => { - let parsed = raw - .get_parsed() - .as_ref() - .map_err(|e| CsafError::LoadError { message: e.clone() })?; - validate_by_test(parsed, &test_id) - }, - DocumentInner::V21(raw) => { - let parsed = raw - .get_parsed() - .as_ref() - .map_err(|e| CsafError::LoadError { message: e.clone() })?; - validate_by_test(parsed, &test_id) - }, + DocumentInner::V20(raw) => validate_by_test(raw, &test_id), + DocumentInner::V21(raw) => validate_by_test(raw, &test_id), }; Ok((&result).into()) } @@ -138,20 +114,8 @@ impl CsafDocument { message: "lock poisoned".into(), })?; let result = match &*guard { - DocumentInner::V20(raw) => { - let parsed = raw - .get_parsed() - .as_ref() - .map_err(|e| CsafError::LoadError { message: e.clone() })?; - validate_by_tests(parsed, &self.version_string, &refs) - }, - DocumentInner::V21(raw) => { - let parsed = raw - .get_parsed() - .as_ref() - .map_err(|e| CsafError::LoadError { message: e.clone() })?; - validate_by_tests(parsed, &self.version_string, &refs) - }, + DocumentInner::V20(raw) => validate_by_tests(raw, &self.version_string, &refs), + DocumentInner::V21(raw) => validate_by_tests(raw, &self.version_string, &refs), }; Ok(result.into()) } diff --git a/csaf-ffi/src/lib.rs b/csaf-ffi/src/lib.rs index ff38ce3f..8aef0938 100644 --- a/csaf-ffi/src/lib.rs +++ b/csaf-ffi/src/lib.rs @@ -3,9 +3,10 @@ //! This crate provides a foreign-function interface (FFI) layer on top of `csaf-rs`, //! enabling Go, WASM, and other language bindings via Mozilla UniFFI. +use csaf::csaf_traits::CsafVersion; use csaf::csaf2_0::loader::load_document as load_document_2_0; use csaf::csaf2_1::loader::load_document as load_document_2_1; -use csaf::validation::validate_by_preset; +use csaf::validation::{Validatable, validate_by_preset}; pub mod document; pub mod types; @@ -29,6 +30,21 @@ pub enum CsafError { #[error("Document load error: {message}")] LoadError { message: String }, + + #[error("Invalid validation preset: {message}")] + InvalidPreset { message: String }, +} + +impl From for CsafError { + fn from(r: csaf::validation::CsafError) -> Self { + match r { + csaf::validation::CsafError::InvalidJson { message } => Self::InvalidJson { message }, + csaf::validation::CsafError::InvalidPreset { preset } => Self::InvalidPreset { message: preset }, + csaf::validation::CsafError::UnsupportedVersion { version } => Self::UnsupportedVersion { version }, + csaf::validation::CsafError::LoadError { message } => Self::LoadError { message }, + csaf::validation::CsafError::MissingVersion { message } => Self::MissingVersion { message }, + } + } } // --------------------------------------------------------------------------- @@ -81,6 +97,13 @@ pub struct ValidationResult { pub num_not_found: u64, } +/// A validation test and the primary preset it belongs to. +#[derive(Debug, Clone, uniffi::Record)] +pub struct TestInPreset { + pub name: String, + pub preset: String, +} + // --------------------------------------------------------------------------- // Conversion helpers: csaf-rs types → FFI types // --------------------------------------------------------------------------- @@ -183,7 +206,7 @@ pub fn validate_csaf(json_str: String, preset: String) -> Result Result Result { let doc = load_document_2_0(&json_str).map_err(|e| CsafError::LoadError { message: e.to_string() })?; - Ok(validate_by_preset(&doc, "2.0", &preset).into()) + validate_by_preset(&doc, "2.0", &preset).map_or_else(|e| Err(CsafError::from(e)), |f| Ok(ValidationResult::from(f))) } /// Validate a CSAF 2.1 document from a JSON string. @@ -207,7 +230,53 @@ pub fn validate_csaf_2_0(json_str: String, preset: String) -> Result Result { let doc = load_document_2_1(&json_str).map_err(|e| CsafError::LoadError { message: e.to_string() })?; - Ok(validate_by_preset(&doc, "2.1", &preset).into()) + validate_by_preset(&doc, "2.1", &preset).map_or_else(|e| Err(CsafError::from(e)), |f| Ok(ValidationResult::from(f))) +} + +/// Retrieve all available validation tests for a CSAF version. +/// +/// For each test, returns the test number and the primary preset it belongs to. +#[uniffi::export] +pub fn get_tests(version: String) -> Result, CsafError> { + match CsafVersion::try_from(version.clone()) { + Ok(CsafVersion::X20) => Ok( + csaf::schema::csaf2_0::schema::CommonSecurityAdvisoryFramework::get_tests() + .iter() + .map(|(name, preset)| TestInPreset { + name: (*name).to_string(), + preset: (*preset).to_string(), + }) + .collect(), + ), + Ok(CsafVersion::X21) => Ok( + csaf::schema::csaf2_1::schema::CommonSecurityAdvisoryFramework::get_tests() + .iter() + .map(|(name, preset)| TestInPreset { + name: (*name).to_string(), + preset: (*preset).to_string(), + }) + .collect(), + ), + Err(_) => Err(CsafError::UnsupportedVersion { version }), + } +} + +/// Retrieve the test IDs belonging to a preset for a CSAF version. +#[uniffi::export] +pub fn get_tests_in_preset(version: String, preset: String) -> Result, CsafError> { + match CsafVersion::try_from(version.clone()) { + Ok(CsafVersion::X20) => { + csaf::schema::csaf2_0::schema::CommonSecurityAdvisoryFramework::tests_in_preset(&preset) + .map(|tests| tests.into_iter().map(String::from).collect()) + .map_err(CsafError::from) + }, + Ok(CsafVersion::X21) => { + csaf::schema::csaf2_1::schema::CommonSecurityAdvisoryFramework::tests_in_preset(&preset) + .map(|tests| tests.into_iter().map(String::from).collect()) + .map_err(CsafError::from) + }, + Err(_) => Err(CsafError::UnsupportedVersion { version }), + } } /// Validate a CSAF document from a JSON string and return the result as JSON. diff --git a/csaf-result-json/Cargo.toml b/csaf-result-json/Cargo.toml index b9e1d2b8..d5db3030 100644 --- a/csaf-result-json/Cargo.toml +++ b/csaf-result-json/Cargo.toml @@ -15,7 +15,7 @@ anstream = "1.0.0" anstyle = "1.0.8" anyhow = "1.0.93" clap = { version = "4.5.23", features = ["derive"] } -csaf-rs = { path = "../csaf-rs", version = "0.5.1" } +csaf-rs = { path = "../csaf-rs", version = "0.5.2" } serde_json = "1" serde = { version = "1", features = ["derive"] } diff --git a/csaf-result-json/src/main.rs b/csaf-result-json/src/main.rs index 1113dc94..7fac27e3 100644 --- a/csaf-result-json/src/main.rs +++ b/csaf-result-json/src/main.rs @@ -192,8 +192,8 @@ where let mut test_ids: Vec<&str> = tests .iter() .flat_map(|test_or_preset| match T::tests_in_preset(test_or_preset) { - Some(test_ids) => test_ids, - None => vec![*test_or_preset], + Ok(test_ids) => test_ids, + Err(_) => vec![*test_or_preset], }) .collect(); diff --git a/csaf-rs/Cargo.toml b/csaf-rs/Cargo.toml index e7ae90fb..21d71303 100644 --- a/csaf-rs/Cargo.toml +++ b/csaf-rs/Cargo.toml @@ -20,6 +20,7 @@ crate-type = ["rlib"] [features] default = [] converter = [] +utoipa = ["dep:utoipa"] [dependencies] cvss-rs = "0.4.0" @@ -38,6 +39,8 @@ oxilangtag = "0.1.5" ssvc = "0.3.0" strum = { version = "0.28", features = ["derive"] } symspell = "0.5.2" +thiserror = { version = "2" } +utoipa = { version = "5", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { version = "1.17.0", features = ["v7", "serde", "js"] } @@ -54,4 +57,3 @@ tempfile = "3" [[bench]] name = "validation_benchmark" harness = false - diff --git a/csaf-rs/src/csaf/enums/csaf_version.rs b/csaf-rs/src/csaf/enums/csaf_version.rs index fdf91027..f3dbf225 100644 --- a/csaf-rs/src/csaf/enums/csaf_version.rs +++ b/csaf-rs/src/csaf/enums/csaf_version.rs @@ -3,8 +3,48 @@ /// Contrary to other enums that are based on enums in the generated schemas, we are re-defining /// this enum in the trait. Each schema only contains an enum with "their" version, and merging them /// would be more complex than defining them here and mapping to them in each implementation. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize)] pub enum CsafVersion { + #[serde(rename = "2.0")] X20, + #[serde(rename = "2.1")] X21, } + +impl CsafVersion { + pub fn as_str(&self) -> &'static str { + match self { + CsafVersion::X20 => "2.0", + CsafVersion::X21 => "2.1", + } + } +} + +impl AsRef for CsafVersion { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl std::fmt::Display for CsafVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CsafVersion::X20 => write!(f, "2.0"), + CsafVersion::X21 => write!(f, "2.1"), + } + } +} + +impl TryFrom for CsafVersion { + type Error = String; + + fn try_from(value: String) -> Result { + match value.as_str() { + "2.0" => Ok(CsafVersion::X20), + "2.1" => Ok(CsafVersion::X21), + _ => Err(format!( + "Unsupported CSAF version: {value}. Supported versions are 2.0 and 2.1." + )), + } + } +} diff --git a/csaf-rs/src/csaf/raw.rs b/csaf-rs/src/csaf/raw.rs index fcedbc55..a78afe05 100644 --- a/csaf-rs/src/csaf/raw.rs +++ b/csaf-rs/src/csaf/raw.rs @@ -2,7 +2,7 @@ use std::cell::OnceCell; use serde::de::DeserializeOwned; -use crate::validation::{TestFinding, TestResult, TestResultStatus, Validatable}; +use crate::validation::{CsafError, TestFinding, TestResult, TestResultStatus, Validatable}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct RawDocument { @@ -59,11 +59,23 @@ where T: HasParsed + RawValidatable, T::Parsed: Validatable, { + fn get_presets() -> Vec<&'static str> { + T::Parsed::get_presets() + } + /// Returns the test IDs belonging to a preset - fn tests_in_preset(preset: &str) -> Option> { + fn tests_in_preset(preset: &str) -> Result, CsafError> { T::Parsed::tests_in_preset(preset) } + fn get_tests() -> Vec<(&'static str, &'static str)> { + T::Parsed::get_tests() + } + + fn has_test(test_id: &str) -> bool { + T::Parsed::has_test(test_id) + } + /// Runs a test by test ID fn run_test(&self, test_id: &str) -> TestResult { let raw_result = self::RawValidatable::run_raw_test(self, test_id); diff --git a/csaf-rs/src/csaf2_0/validation.rs b/csaf-rs/src/csaf2_0/validation.rs index 8707444e..09565a53 100644 --- a/csaf-rs/src/csaf2_0/validation.rs +++ b/csaf-rs/src/csaf2_0/validation.rs @@ -1,8 +1,12 @@ +use std::collections::HashSet; +use std::fmt::Display; +use std::sync::OnceLock; + use crate::csaf::raw::{RawDocument, RawValidatable}; use crate::csaf2_0::testcases::*; use crate::schema::csaf2_0::schema::CommonSecurityAdvisoryFramework; use crate::test_validation::TestValidator; -use crate::validation::{TestFinding, TestResult, TestResultStatus, Validatable, ValidationError}; +use crate::validation::{CsafError, TestFinding, TestResult, TestResultStatus, Validatable, ValidationError}; use crate::validations::test_schema::validate_schema_csaf_2_0; fn to_test_result(test_id: &str, result: Option>>) -> TestResult { @@ -32,24 +36,147 @@ fn to_test_result(test_id: &str, result: Option &str { + match self { + Preset::Schema => PRESET_NAME_SCHEMA, + Preset::Mandatory => PRESET_NAME_MANDATORY, + Preset::Optional => PRESET_NAME_OPTIONAL, + Preset::Informative => PRESET_NAME_INFORMATIVE, + Preset::Basic => PRESET_NAME_BASIC, + Preset::Extended => PRESET_NAME_EXTENDED, + Preset::Full => PRESET_NAME_FULL, + } + } +} + +impl Display for Preset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl TryFrom<&str> for Preset { + type Error = CsafError; + + fn try_from(value: &str) -> Result { + match value { + PRESET_NAME_SCHEMA => Ok(Preset::Schema), + PRESET_NAME_MANDATORY => Ok(Preset::Mandatory), + PRESET_NAME_OPTIONAL => Ok(Preset::Optional), + PRESET_NAME_INFORMATIVE => Ok(Preset::Informative), + PRESET_NAME_BASIC => Ok(Preset::Basic), + PRESET_NAME_EXTENDED => Ok(Preset::Extended), + PRESET_NAME_FULL => Ok(Preset::Full), + other => Err(CsafError::InvalidPreset { + preset: other.to_string(), + }), + } + } +} + +/// Lazily-built set of all known test IDs (schema + mandatory + recommended + informative) +/// for O(1) membership lookups, instead of scanning the individual arrays linearly on +/// every `has_test` call. +static KNOWN_TESTS: OnceLock> = OnceLock::new(); + +fn known_tests() -> &'static HashSet<&'static str> { + KNOWN_TESTS.get_or_init(|| { + let mut set: HashSet<&'static str> = + HashSet::with_capacity(1 + MANDATORY_TESTS.len() + RECOMMENDED_TESTS.len() + INFORMATIVE_TESTS.len()); + set.insert(Preset::Schema.as_str()); + set.extend(MANDATORY_TESTS.iter().copied()); + set.extend(RECOMMENDED_TESTS.iter().copied()); + set.extend(INFORMATIVE_TESTS.iter().copied()); + set + }) +} + impl Validatable for CommonSecurityAdvisoryFramework { - fn tests_in_preset(preset: &str) -> Option> { - match preset { - "basic" => Some([vec!["schema"], mandatory_tests()].concat()), - "extended" => Some([vec!["schema"], mandatory_tests(), recommended_tests()].concat()), - "full" => Some( - [ - vec!["schema"], - mandatory_tests(), - recommended_tests(), - informative_tests(), - ] - .concat(), - ), - _ => None, + fn get_presets() -> Vec<&'static str> { + vec![ + Preset::Schema.as_str(), + Preset::Mandatory.as_str(), + Preset::Optional.as_str(), + Preset::Informative.as_str(), + Preset::Basic.as_str(), + Preset::Extended.as_str(), + Preset::Full.as_str(), + ] + } + + fn tests_in_preset(preset: &str) -> Result, CsafError> { + match Preset::try_from(preset) { + Ok(Preset::Schema) => Ok(vec![Preset::Schema.as_str()]), + Ok(Preset::Mandatory) => Ok(mandatory_tests()), + Ok(Preset::Optional) => Ok(recommended_tests()), + Ok(Preset::Informative) => Ok(informative_tests()), + Ok(Preset::Basic) => Ok([vec![Preset::Schema.as_str()], mandatory_tests()].concat()), + Ok(Preset::Extended) => { + Ok([vec![Preset::Schema.as_str()], mandatory_tests(), recommended_tests()].concat()) + }, + Ok(Preset::Full) => Ok([ + vec![Preset::Schema.as_str()], + mandatory_tests(), + recommended_tests(), + informative_tests(), + ] + .concat()), + Err(err) => Err(err), } } + fn get_tests() -> Vec<(&'static str, &'static str)> { + let mut tests = + Vec::with_capacity(1 + MANDATORY_TESTS.len() + RECOMMENDED_TESTS.len() + INFORMATIVE_TESTS.len()); + + tests.push((Preset::Schema.as_str(), Preset::Schema.as_str())); + tests.extend( + MANDATORY_TESTS + .iter() + .copied() + .map(|id| (id, Preset::Mandatory.as_str())), + ); + tests.extend( + RECOMMENDED_TESTS + .iter() + .copied() + .map(|id| (id, Preset::Optional.as_str())), + ); + tests.extend( + INFORMATIVE_TESTS + .iter() + .copied() + .map(|id| (id, Preset::Informative.as_str())), + ); + + tests + } + + fn has_test(test_id: &str) -> bool { + known_tests().contains(test_id) + } + fn run_test(&self, test_id: &str) -> TestResult { to_test_result( test_id, @@ -154,7 +281,7 @@ impl RawValidatable for RawDocument { to_test_result( test_id, match test_id { - "schema" => Some(validate_schema_csaf_2_0(self)), + PRESET_NAME_SCHEMA => Some(validate_schema_csaf_2_0(self)), "6.2.13" => Some(ValidatorForTest6_2_13.validate(self)), "6.2.20" => Some(ValidatorForTest6_2_20.validate(self)), _ => None, diff --git a/csaf-rs/src/csaf2_1/validation.rs b/csaf-rs/src/csaf2_1/validation.rs index f07990cb..51dd80a0 100644 --- a/csaf-rs/src/csaf2_1/validation.rs +++ b/csaf-rs/src/csaf2_1/validation.rs @@ -1,8 +1,12 @@ +use std::collections::HashSet; +use std::fmt::Display; +use std::sync::OnceLock; + use crate::csaf::raw::{RawDocument, RawValidatable}; use crate::csaf2_1::testcases::*; use crate::schema::csaf2_1::schema::CommonSecurityAdvisoryFramework; use crate::test_validation::TestValidator; -use crate::validation::{TestFinding, TestResult, TestResultStatus, Validatable, ValidationError}; +use crate::validation::{CsafError, TestFinding, TestResult, TestResultStatus, Validatable, ValidationError}; use crate::validations::test_schema::validate_schema_csaf_2_1; fn to_test_result(test_id: &str, result: Option>>) -> TestResult { @@ -32,49 +36,185 @@ fn to_test_result(test_id: &str, result: Option &str { + match self { + Preset::Schema => PRESET_NAME_SCHEMA, + Preset::Mandatory => PRESET_NAME_MANDATORY, + Preset::Recommended => PRESET_NAME_RECOMMENDED, + Preset::Informative => PRESET_NAME_INFORMATIVE, + Preset::Basic => PRESET_NAME_BASIC, + Preset::Extended => PRESET_NAME_EXTENDED, + Preset::Full => PRESET_NAME_FULL, + Preset::ExternalRequestFree => PRESET_NAME_EXTERNAL_REQUEST_FREE, + Preset::ConsistentRevisionHistory => PRESET_NAME_CONSISTENT_REVISION_HISTORY, + Preset::ConsistentDateTimes => PRESET_NAME_CONSISTENT_DATETIMES, + Preset::Ssvc => PRESET_NAME_SSVC, + } + } +} + +impl Display for Preset { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.as_str()) + } +} +impl TryFrom<&str> for Preset { + type Error = CsafError; + + fn try_from(value: &str) -> Result { + match value { + PRESET_NAME_MANDATORY => Ok(Preset::Mandatory), + PRESET_NAME_RECOMMENDED => Ok(Preset::Recommended), + PRESET_NAME_INFORMATIVE => Ok(Preset::Informative), + PRESET_NAME_SCHEMA => Ok(Preset::Schema), + PRESET_NAME_BASIC => Ok(Preset::Basic), + PRESET_NAME_EXTENDED => Ok(Preset::Extended), + PRESET_NAME_FULL => Ok(Preset::Full), + PRESET_NAME_EXTERNAL_REQUEST_FREE => Ok(Preset::ExternalRequestFree), + PRESET_NAME_CONSISTENT_REVISION_HISTORY => Ok(Preset::ConsistentRevisionHistory), + PRESET_NAME_CONSISTENT_DATETIMES => Ok(Preset::ConsistentDateTimes), + PRESET_NAME_SSVC => Ok(Preset::Ssvc), + _ => Err(CsafError::InvalidPreset { + preset: value.to_string(), + }), + } + } +} + +/// Lazily-built set of all known test IDs (schema + mandatory + recommended + informative) +/// for O(1) membership lookups, instead of scanning the individual arrays linearly on +/// every `has_test` call. +static KNOWN_TESTS: OnceLock> = OnceLock::new(); + +fn known_tests() -> &'static HashSet<&'static str> { + KNOWN_TESTS.get_or_init(|| { + let mut set: HashSet<&'static str> = + HashSet::with_capacity(1 + MANDATORY_TESTS.len() + RECOMMENDED_TESTS.len() + INFORMATIVE_TESTS.len()); + set.insert(Preset::Schema.as_str()); + set.extend(MANDATORY_TESTS.iter().copied()); + set.extend(RECOMMENDED_TESTS.iter().copied()); + set.extend(INFORMATIVE_TESTS.iter().copied()); + set + }) +} + impl Validatable for CommonSecurityAdvisoryFramework { - fn tests_in_preset(preset: &str) -> Option> { - match preset { - "mandatory" => Some(mandatory_tests()), - "recommended" => Some(recommended_tests()), - "informative" => Some(informative_tests()), - "schema" => Some(vec!["schema"]), - "basic" => Some([vec!["schema"], mandatory_tests()].concat()), - "extended" => Some([vec!["schema"], mandatory_tests(), recommended_tests()].concat()), - "full" => Some( - [ - vec!["schema"], - mandatory_tests(), - recommended_tests(), - informative_tests(), - ] - .concat(), - ), - "external-request-free" => Some( - [ - vec!["schema"], - mandatory_tests(), - recommended_tests(), - informative_tests(), - ] - .concat() - .into_iter() - .filter(|id| *id != "6.3.6" && *id != "6.3.7") - .collect(), - ), - "consistent-revision-history" => Some(vec![ + fn get_presets() -> Vec<&'static str> { + vec![ + Preset::Schema.as_str(), + Preset::Mandatory.as_str(), + Preset::Recommended.as_str(), + Preset::Informative.as_str(), + Preset::Basic.as_str(), + Preset::Extended.as_str(), + Preset::Full.as_str(), + Preset::ExternalRequestFree.as_str(), + Preset::ConsistentRevisionHistory.as_str(), + Preset::ConsistentDateTimes.as_str(), + Preset::Ssvc.as_str(), + ] + } + + fn tests_in_preset(preset: &str) -> Result, CsafError> { + match Preset::try_from(preset) { + Ok(Preset::Schema) => Ok(vec![Preset::Schema.as_str()]), + Ok(Preset::Mandatory) => Ok(mandatory_tests()), + Ok(Preset::Recommended) => Ok(recommended_tests()), + Ok(Preset::Informative) => Ok(informative_tests()), + Ok(Preset::Basic) => Ok([vec![Preset::Schema.as_str()], mandatory_tests()].concat()), + Ok(Preset::Extended) => { + Ok([vec![Preset::Schema.as_str()], mandatory_tests(), recommended_tests()].concat()) + }, + Ok(Preset::Full) => Ok([ + vec![Preset::Schema.as_str()], + mandatory_tests(), + recommended_tests(), + informative_tests(), + ] + .concat()), + Ok(Preset::ExternalRequestFree) => Ok([ + vec![Preset::Schema.as_str()], + mandatory_tests(), + recommended_tests(), + informative_tests(), + ] + .concat() + .into_iter() + .filter(|id| *id != "6.3.6" && *id != "6.3.7") + .collect()), + Ok(Preset::ConsistentRevisionHistory) => Ok(vec![ "6.1.14", "6.1.18", "6.1.19", "6.1.21", "6.1.22", "6.1.37", "6.2.4", "6.2.5", "6.2.6", "6.2.21", "6.2.33", ]), - "consistent-date-times" => Some(vec!["6.1.37", "6.1.45", "6.1.49", "6.1.51", "6.1.52", "6.1.53"]), - "ssvc" => Some(vec![ + Ok(Preset::ConsistentDateTimes) => Ok(vec!["6.1.37", "6.1.45", "6.1.49", "6.1.51", "6.1.52", "6.1.53"]), + Ok(Preset::Ssvc) => Ok(vec![ "6.1.46", "6.1.47", "6.1.48", "6.1.49", "6.2.3", "6.2.34", "6.2.35", "6.2.36", "6.2.37", "6.3.13", "6.3.14", "6.3.15", ]), - _ => None, + Err(e) => Err(e), } } + fn get_tests() -> Vec<(&'static str, &'static str)> { + let mut tests = + Vec::with_capacity(1 + MANDATORY_TESTS.len() + RECOMMENDED_TESTS.len() + INFORMATIVE_TESTS.len()); + + tests.push((Preset::Schema.as_str(), Preset::Schema.as_str())); + tests.extend( + MANDATORY_TESTS + .iter() + .copied() + .map(|id| (id, Preset::Mandatory.as_str())), + ); + tests.extend( + RECOMMENDED_TESTS + .iter() + .copied() + .map(|id| (id, Preset::Recommended.as_str())), + ); + tests.extend( + INFORMATIVE_TESTS + .iter() + .copied() + .map(|id| (id, Preset::Informative.as_str())), + ); + + tests + } + + fn has_test(test_id: &str) -> bool { + known_tests().contains(test_id) + } + fn run_test(&self, test_id: &str) -> TestResult { to_test_result( test_id, @@ -263,7 +403,7 @@ impl RawValidatable for RawDocument { to_test_result( test_id, match test_id { - "schema" => Some(validate_schema_csaf_2_1(self)), + PRESET_NAME_SCHEMA => Some(validate_schema_csaf_2_1(self)), "6.2.13" => Some(ValidatorForTest6_2_13.validate(self)), "6.2.20" => Some(ValidatorForTest6_2_20.validate(self)), _ => None, diff --git a/csaf-rs/src/macros/test_gen.rs b/csaf-rs/src/macros/test_gen.rs index f02b1e65..458abe34 100644 --- a/csaf-rs/src/macros/test_gen.rs +++ b/csaf-rs/src/macros/test_gen.rs @@ -221,19 +221,28 @@ macro_rules! define_test_cases_aggregate { /// Global constant instance of all test cases pub const $const_name: TestCases = TestCases::new(); + /// All mandatory test IDs. + pub const MANDATORY_TESTS: &[&'static str] = &[$($m_struct::<$m_validator>::ID),*]; + + /// All recommended test IDs. + pub const RECOMMENDED_TESTS: &[&'static str] = &[$($r_struct::<$r_validator>::ID),*]; + + /// All informative test IDs. + pub const INFORMATIVE_TESTS: &[&'static str] = &[$($i_struct::<$i_validator>::ID),*]; + /// Get all mandatory tests as IDs pub fn mandatory_tests() -> Vec<&'static str> { - vec![$($const_name.$m_inst.id()),*] + MANDATORY_TESTS.to_vec() } /// Get all optional tests as IDs pub fn recommended_tests() -> Vec<&'static str> { - vec![$($const_name.$r_inst.id()),*] + RECOMMENDED_TESTS.to_vec() } /// Get all informative tests as IDs pub fn informative_tests() -> Vec<&'static str> { - vec![$($const_name.$i_inst.id()),*] + INFORMATIVE_TESTS.to_vec() } }; } diff --git a/csaf-rs/src/validation.rs b/csaf-rs/src/validation.rs index ca968456..79c6cdbc 100644 --- a/csaf-rs/src/validation.rs +++ b/csaf-rs/src/validation.rs @@ -3,7 +3,26 @@ use std::fmt::Display; use TestResultStatus::*; use serde::{Deserialize, Serialize}; +#[derive(Debug, serde::Serialize, serde::Deserialize, thiserror::Error)] +pub enum CsafError { + #[error("Invalid JSON: {message}")] + InvalidJson { message: String }, + + #[error("Missing CSAF version: {message}")] + MissingVersion { message: String }, + + #[error("Unsupported CSAF version: {version}")] + UnsupportedVersion { version: String }, + + #[error("Invalid preset: {preset}")] + InvalidPreset { preset: String }, + + #[error("Error loading document: {message}")] + LoadError { message: String }, +} + #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct TestFindingData { pub message: String, @@ -29,6 +48,7 @@ impl TestFinding { } #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct ValidationError { pub message: String, @@ -57,6 +77,7 @@ pub trait IntoTestFindingError { /// Result of executing a single test #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct TestResult { /// The test ID that was executed @@ -67,6 +88,7 @@ pub struct TestResult { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub enum TestResultStatus { Success, @@ -102,6 +124,7 @@ impl Display for TestResultStatus { /// Result of a CSAF validation #[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] #[serde(rename_all = "camelCase")] pub struct ValidationResult { /// Whether the validation was successful (no errors) @@ -125,10 +148,10 @@ pub trait Validate { fn validate_by_test(&self, test_id: &str) -> TestResult; /// Validates this object according to specific test IDs and returns detailed results - fn validate_by_tests(&self, version: &str, test_ids: &[&str]) -> ValidationResult; + fn validate_by_tests(&self, version: &str, test_ids: &[&str]) -> Result; /// Validates this object according to a validation preset and returns detailed results - fn validate_by_preset(&self, version: &str, preset: &str) -> ValidationResult; + fn validate_by_preset(&self, version: &str, preset: &str) -> Result; } /// Represents something which is validatable according to the CSAF standard. @@ -137,8 +160,20 @@ pub trait Validate { /// /// It can then be used to validate documents with [validate_by_preset] or [validate_by_tests]. pub trait Validatable { + /// Return the available presets + fn get_presets() -> Vec<&'static str>; + /// Returns the test IDs belonging to a preset - fn tests_in_preset(preset: &str) -> Option>; + fn tests_in_preset(preset: &str) -> Result, CsafError>; + + /// Return the available test IDs with their associated preset + /// This can be used to list all available tests and their presets for a given version. + fn get_tests() -> Vec<(&'static str, &'static str)>; + + /// Return whether a test ID exists for this version. + fn has_test(test_id: &str) -> bool { + Self::get_tests().iter().any(|(id, _)| *id == test_id) + } /// Runs a test by test ID fn run_test(&self, test_id: &str) -> TestResult; @@ -149,8 +184,13 @@ pub trait Validatable { /// This function will check, whether the test_id exists in the Validatable's /// tests. If it does, it will execute the test function and return the result. /// If not, it will return a TestResult indicating that the test was not found. -pub fn validate_by_test(target: &impl Validatable, test_id: &str) -> TestResult { - // Try to execute the test specified by the test_id +pub fn validate_by_test(target: &V, test_id: &str) -> TestResult { + if !V::has_test(test_id) { + return TestResult { + test_id: test_id.to_string(), + status: TestResultStatus::NotFound, + }; + } target.run_test(test_id) } @@ -162,9 +202,11 @@ pub fn validate_by_tests(target: &impl Validatable, version: &str, test_ids: &[& let mut num_infos: usize = 0; let mut num_not_found: usize = 0; - // Loop through tests and gather all results and errors + // Loop through tests and gather all results and errors. + // Call run_test directly — test IDs here are already validated, so the + // has_test guard in validate_by_test would be redundant overhead. for test_id in test_ids { - let test_result = validate_by_test(target, test_id); + let test_result = target.run_test(test_id); match &test_result.status { Failure { errors, @@ -195,10 +237,10 @@ pub fn validate_by_tests(target: &impl Validatable, version: &str, test_ids: &[& } /// Validate document with a preset and return detailed results. -pub fn validate_by_preset(target: &V, version: &str, preset: &str) -> ValidationResult { - // Retrieve the test IDs for the given preset - let test_ids: Vec<&str> = V::tests_in_preset(preset).unwrap_or(vec![]); - - // Forward them to validate_by_tests - validate_by_tests(target, version, &test_ids) +pub fn validate_by_preset( + target: &V, + version: &str, + preset: &str, +) -> Result { + V::tests_in_preset(preset).map(|test_ids| validate_by_tests(target, version, &test_ids)) } diff --git a/csaf-service/Cargo.toml b/csaf-service/Cargo.toml new file mode 100644 index 00000000..9273bfbb --- /dev/null +++ b/csaf-service/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "csaf-service" +version = "0.1.1" +edition = "2024" +rust-version = "1.88.0" +publish = false +license.workspace = true +repository.workspace = true +keywords.workspace = true +readme.workspace = true + +[[bin]] +name = "csaf-service" +path = "src/main.rs" + +[dependencies] +csaf = { path = "../csaf-rs", package = "csaf-rs", version = "0.5.2", features = ["utoipa"] } +axum = { version = "0.8", features = ["json"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +config = { version = "0.15", default-features = false, features = ["toml"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +utoipa = { version = "5", features = ["axum_extras"] } +utoipa-swagger-ui = { version = "9", features = ["axum"] } + +[dev-dependencies] +tower = { version = "0.5", features = ["util"] } +http = "1" +http-body-util = "0.1" + diff --git a/csaf-service/README.md b/csaf-service/README.md new file mode 100644 index 00000000..84a9ca51 --- /dev/null +++ b/csaf-service/README.md @@ -0,0 +1,32 @@ +# How to + +If you want to use the `csaf-rs` library behind a web API, you can use this `csaf-service` to expose it directly or via a Dockerfile. + +Run +```bash +cargo run -p csaf-service --release +``` + +## Configuration + +Settings are loaded, in increasing order of precedence, from: +1. Built-in defaults, compiled from [`config/default.toml`](config/default.toml). +2. `config/local.toml` — optional, gitignored, for per-developer or per-deployment + overrides. Copy [`config/local.example.toml`](config/local.example.toml) to + `config/local.toml` and adjust as needed (e.g. to relax CORS locally). Only + include the keys you want to change. +3. Environment variables prefixed with `CSAF_SERVICE__` (double underscore, + also used as the nesting separator). + +| Setting | Config key | Environment variable | Description | Default Value | +| --- | --- | --- | --- | --- | +| Host | `server.host` | `CSAF_SERVICE__SERVER__HOST` | The host the service listens on. Change to `0.0.0.0` to listen on all devices. | `localhost` | +| Port | `server.port` | `CSAF_SERVICE__SERVER__PORT` | The port the service listens on. | `8082` | +| Body limit | `server.body_limit_mb` | `CSAF_SERVICE__SERVER__BODY_LIMIT_MB` | The maximum request body size in MB. Hard-capped at 150 MB as required by the standard, regardless of this setting. | `150` | +| Permissive CORS | `cors.permissive` | `CSAF_SERVICE__CORS__PERMISSIVE` | Whether to use a permissive CORS policy (allow all origins/methods/headers). Do not use in production. | `false` | +| Allowed origins | `cors.allowed_origins` | `CSAF_SERVICE__CORS__ALLOWED_ORIGINS` | Allow-list of origins for CORS, used when `permissive` is `false`. | `[]` | +| Allowed methods | `cors.allowed_methods` | `CSAF_SERVICE__CORS__ALLOWED_METHODS` | Allow-list of HTTP methods for CORS, used when `permissive` is `false`. | `["GET", "POST"]` | +| Log level | `logging.level` | `CSAF_SERVICE__LOGGING__LEVEL` | Default log filter, used only when the `RUST_LOG` environment variable is not set. Accepts any `tracing_subscriber::EnvFilter` directive, e.g. `"info,tower_http=debug"`. | `info` | +| Request log level | `logging.request_level` | `CSAF_SERVICE__LOGGING__REQUEST_LEVEL` | Level at which incoming/outgoing HTTP requests are logged by `tower_http`'s `TraceLayer` (one of `trace`, `debug`, `info`, `warn`, `error`). | `debug` | + +`RUST_LOG` always takes precedence over `logging.level` when set, e.g. `RUST_LOG=debug cargo run -p csaf-service` for verbose logging without touching config. diff --git a/csaf-service/config/default.toml b/csaf-service/config/default.toml new file mode 100644 index 00000000..6df6bfb8 --- /dev/null +++ b/csaf-service/config/default.toml @@ -0,0 +1,35 @@ +# Default configuration for csaf-service. +# +# Do not edit this file for local overrides — copy `config/local.example.toml` +# to `config/local.toml` instead (it is gitignored and takes precedence over +# these defaults). Any setting can also be overridden via environment +# variables prefixed with `CSAF_SERVICE__` (double underscore, also used as +# the nesting separator), e.g. `CSAF_SERVICE__SERVER__PORT=9000` or +# `CSAF_SERVICE__CORS__PERMISSIVE=true`. Environment variables take +# precedence over both this file and `config/local.toml`. + +[server] +# Host the service listens on. Use "0.0.0.0" to listen on all interfaces. +host = "localhost" +port = 8082 +# Maximum accepted request body size, in MiB. Hard-capped at 150 MiB as +# suggested by the CSAF standard, regardless of this setting. +body_limit_mb = 150 + +[cors] +# When true, allows all origins/methods/headers. Do not use in production. +permissive = false +# Explicit allow-list used when `permissive` is false. Empty means no +# cross-origin requests are allowed. +allowed_origins = [] +allowed_methods = ["GET", "POST"] + +[logging] +# Default log filter, used when the RUST_LOG environment variable is not +# set. Accepts anything tracing_subscriber's EnvFilter understands, e.g. +# "info" or "info,tower_http=debug". +level = "info" +# Level at which incoming/outgoing HTTP requests are logged (tower_http's +# TraceLayer defaults to "debug", which is easy to miss at the default +# "info" level). One of: trace, debug, info, warn, error. +request_level = "debug" diff --git a/csaf-service/config/local.example.toml b/csaf-service/config/local.example.toml new file mode 100644 index 00000000..97253f4a --- /dev/null +++ b/csaf-service/config/local.example.toml @@ -0,0 +1,13 @@ +# Local development override file (template). +# +# Copy this file to `config/local.toml` (which is gitignored) and adjust as +# needed. Values here override `config/default.toml`; only include the +# settings you actually want to change — omitted keys keep their default. +# +# Example: relax CORS for local frontend development. + +[cors] +permissive = true + +[logging] +request_level = "info" diff --git a/csaf-service/src/handlers/health.rs b/csaf-service/src/handlers/health.rs new file mode 100644 index 00000000..ff2c49e5 --- /dev/null +++ b/csaf-service/src/handlers/health.rs @@ -0,0 +1,29 @@ +use axum::{Json, response::IntoResponse}; + +/// Health check endpoint. +#[utoipa::path( + get, + path = "/api/v1/health", + responses( + (status = 200, description = "Service is healthy") + ), + tag = "health" +)] +pub(crate) async fn health() -> impl IntoResponse { + Json(serde_json::json!({"status": "ok"})) +} + +#[cfg(test)] +mod tests { + use crate::routes; + use crate::test_helpers::get_json; + use axum::http::StatusCode; + + #[tokio::test] + async fn returns_ok_status() { + let (status, json) = get_json(routes::HEALTH).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["status"], "ok"); + } +} diff --git a/csaf-service/src/handlers/mod.rs b/csaf-service/src/handlers/mod.rs new file mode 100644 index 00000000..ebad5703 --- /dev/null +++ b/csaf-service/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub(crate) mod health; +pub(crate) mod v1; diff --git a/csaf-service/src/handlers/v1/errors.rs b/csaf-service/src/handlers/v1/errors.rs new file mode 100644 index 00000000..db762d45 --- /dev/null +++ b/csaf-service/src/handlers/v1/errors.rs @@ -0,0 +1,28 @@ +use axum::{Json, http::StatusCode}; +use serde::Serialize; +use utoipa::ToSchema; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ErrorResponse { + pub error: String, + #[serde(rename = "statusCode")] + pub status_code: u16, + pub message: Option, + pub code: String, +} + +pub(crate) fn error_response( + status: StatusCode, + code: impl Into, + message: impl Into, +) -> (StatusCode, Json) { + ( + status, + Json(ErrorResponse { + status_code: status.as_u16(), + error: status.to_string(), + message: Some(message.into()), + code: code.into(), + }), + ) +} diff --git a/csaf-service/src/handlers/v1/get_tests.rs b/csaf-service/src/handlers/v1/get_tests.rs new file mode 100644 index 00000000..f905eac5 --- /dev/null +++ b/csaf-service/src/handlers/v1/get_tests.rs @@ -0,0 +1,63 @@ +use axum::extract::Query; +use axum::{Json, http::StatusCode}; +use csaf::csaf_traits::CsafVersion; +use csaf::schema::csaf2_0::schema::CommonSecurityAdvisoryFramework as Csaf2_0; +use csaf::schema::csaf2_1::schema::CommonSecurityAdvisoryFramework as Csaf2_1; +use csaf::validation::Validatable; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::handlers::v1::errors::{ErrorResponse, error_response}; + +#[derive(Debug, Deserialize)] +pub(crate) struct LegacyTestsQuery { + pub version: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, PartialEq)] +pub(crate) struct TestInPreset { + pub name: String, + pub preset: String, +} + +/// Retrieve all tests. +#[utoipa::path( + get, + path = "/api/v1/tests", + description = "Retrieve all tests for the requested CSAF version (2.0 by default). For each test, return the test number as well as the primary preset it belongs to.", + params( + ("version" = Option, Query, description = "CSAF version (2.0 or 2.1). Defaults to 2.0."), + ), + responses( + (status = 200, description = "List of available tests", body = Vec), + (status = 404, description = "Invalid version", body = ErrorResponse), + ), + tag = "meta" +)] +pub(crate) async fn get_tests( + Query(query): Query, +) -> Result>, (StatusCode, Json)> { + let version = CsafVersion::try_from(query.version.clone().unwrap_or_else(|| "2.0".to_string())) + .map_err(|e| error_response(StatusCode::NOT_FOUND, "INVALID_VERSION", e))?; + Ok(Json(tests_for_version(&version))) +} + +/// Returns the list of known preset names for a CSAF version. +fn tests_for_version(version: &CsafVersion) -> Vec { + match version { + CsafVersion::X20 => Csaf2_0::get_tests() + .iter() + .map(|f| TestInPreset { + name: f.0.to_string(), + preset: f.1.to_string(), + }) + .collect(), + CsafVersion::X21 => Csaf2_1::get_tests() + .iter() + .map(|f| TestInPreset { + name: f.0.to_string(), + preset: f.1.to_string(), + }) + .collect(), + } +} diff --git a/csaf-service/src/handlers/v1/mod.rs b/csaf-service/src/handlers/v1/mod.rs new file mode 100644 index 00000000..a6879a18 --- /dev/null +++ b/csaf-service/src/handlers/v1/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod errors; +pub(crate) mod get_tests; +pub(crate) mod validate; diff --git a/csaf-service/src/handlers/v1/validate.rs b/csaf-service/src/handlers/v1/validate.rs new file mode 100644 index 00000000..679d070f --- /dev/null +++ b/csaf-service/src/handlers/v1/validate.rs @@ -0,0 +1,379 @@ +use axum::{Json, http::StatusCode}; +use csaf::csaf_traits::CsafVersion; +use csaf::validation::{TestResultStatus, Validatable, ValidationResult, validate_by_tests}; +use csaf::{ + csaf::loader::detect_version, csaf2_0::loader::load_document as load_2_0, + csaf2_1::loader::load_document as load_2_1, +}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::handlers::v1::errors::{ErrorResponse, error_response}; + +type CsafDoc20 = csaf::csaf::raw::RawDocument; +type CsafDoc21 = csaf::csaf::raw::RawDocument; + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(tag = "type")] +pub(crate) enum TestOrPreset { + #[serde(rename = "test")] + Test { name: String }, + #[serde(rename = "preset")] + Preset { name: String }, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub(crate) struct ValidateBody { + pub tests: Vec, + pub document: serde_json::Value, +} + +/// Legacy validation response matching the secvisogram csaf-validator-service format. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ValidateResponse { + pub is_valid: bool, + pub tests: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) struct TestResult { + pub name: String, + pub is_valid: bool, + pub errors: Vec, + pub warnings: Vec, + pub infos: Vec, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub(crate) struct Finding { + pub instance_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +fn to_legacy_response(result: ValidationResult) -> ValidateResponse { + let tests: Vec = result + .test_results + .into_iter() + .map(|tr| { + // ignore the is_valid flag from the TestResultStatus and instead compute it from the errors vector, + // since the legacy response format uses isValid = true if there are no errors, even if there are warnings or infos. + // Legacy calculates overall is_valid from the individual is_valid flags, so this has to be set according to the spec. + // Our TestResultStatus is doesn't do that, but instead has a separate status for warnings and infos, so we have to convert it here. + // see https://github.com/secvisogram/csaf-validator-service/issues/239 for reference + let (_, errors, warnings, infos) = match tr.status { + TestResultStatus::Success | TestResultStatus::Skipped => (true, vec![], vec![], vec![]), + TestResultStatus::NotFound => (false, vec![], vec![], vec![]), + TestResultStatus::Failure { + errors: errs, + warnings: warns, + infos: info_items, + } => { + let errors = errs + .into_iter() + .map(|e| Finding { + instance_path: e.instance_path, + message: Some(e.message), + }) + .collect(); + let warnings = warns + .into_iter() + .map(|w| Finding { + instance_path: w.instance_path, + message: Some(w.message), + }) + .collect(); + let infos = info_items + .into_iter() + .map(|i| Finding { + instance_path: i.instance_path, + message: Some(i.message), + }) + .collect(); + (false, errors, warnings, infos) + }, + }; + TestResult { + name: tr.test_id, + is_valid: errors.is_empty(), + errors, + warnings, + infos, + } + }) + .collect(); + + ValidateResponse { + is_valid: tests.iter().all(|t| t.is_valid), + tests, + } +} + +/// Converts a legacy test name such as `"mandatory_6_1_10"` to the canonical +/// dot-separated form `"6.1.10"`. Already-canonical names like `"6.1.10"` or +/// `"schema"` are returned unchanged. +fn from_potential_legacy_name(name: &str) -> String { + const PREFIXES: &[&str] = &[ + "mandatoryTest_", + "optionalTest_", + "recommendedTest_", + "informativeTest_", + ]; + if name == "csaf_2_0_strict" { + return "schema".to_string(); + } + for prefix in PREFIXES { + if let Some(rest) = name.strip_prefix(prefix) { + return rest.replace('_', "."); + } + } + name.to_string() +} + +/// Validate a CSAF document. +#[utoipa::path( + post, + path = "/api/v1/validate", + description = "Evaluates a CSAF document against a selected set of tests.
+ At least one entry has to be provided in the **tests** array. + Each entry provided runs either a single named test or a named preset (which expands to a fixed set of tests). + Duplicate tests from overlapping entries are automatically removed.
+ Available presets:
+ - schema (JSON schema validation)
+ - mandatory (all mandatory tests from section 6.1)
+ - optional (all optional tests from section 6.2)
+ - informative (all informative tests from section 6.3)
+ - basic (schema & mandatory)
+ - extended (basic & optional)
+ - full (extended & informative)
+ ", + request_body( + content = ValidateBody, + description = "Validation request with document and tests/presets", + examples( + ("Validate with a single test" = ( + // summary = "Validate with a single test", + value = json!({"tests": [{"type": "test", "name": "6.1.15"}], "document": {"document": {"category": "csaf_base", "csaf_version": "2.0", "publisher": {"category": "vendor", "name": "Example", "namespace": "https://example.com"}, "title": "Example", "tracking": {"current_release_date": "2024-01-01T00:00:00Z", "id": "Example-001", "initial_release_date": "2024-01-01T00:00:00Z", "revision_history": [{"date": "2024-01-01T00:00:00Z", "number": "1", "summary": "Initial"}], "status": "final", "version": "1"}}}}) + )), + ("Validate with the basic preset" = ( + summary = "Validate with the basic preset", + value = json!({"tests": [{"type": "preset", "name": "basic"}], "document": {"document": {"category": "csaf_base", "csaf_version": "2.0"}}}) + )), + ("Combine individual tests with presets" = ( + summary = "Combine individual tests with presets", + value = json!({"tests": [{"type": "preset", "name": "basic"}, {"type": "test", "name": "6.2.1"}], "document": {"document": {"category": "csaf_base", "csaf_version": "2.0"}}}) + )) + ) + ), + responses( + (status = 200, description = "Validation result", body = ValidateResponse, + examples( + ("Valid document" = ( + summary = "All tests passed", + value = json!({"isValid": true, "tests": [{"name": "schema", "isValid": true, "errors": [], "warnings": [], "infos": []}]}) + )), + ("Invalid document" = ( + summary = "Document has validation errors", + value = json!({"isValid": false, "tests": [{"name": "schema", "isValid": false, "errors": [{"instancePath": "/document", "message": "required property 'publisher' is missing"}], "warnings": [], "infos": []}]}) + )) + ) + ), + (status = 400, description = "Invalid request", body = ErrorResponse) + ), + tag = "validation" +)] +pub(crate) async fn validate( + Json(body): Json, +) -> Result, (StatusCode, Json)> { + let json_value = body.document; + + let version = { + let detected = detect_version(json_value.clone()) + .map_err(|e| error_response(StatusCode::BAD_REQUEST, "PARSE_ERROR", e.to_string()))?; + CsafVersion::try_from(detected).map_err(|e| error_response(StatusCode::BAD_REQUEST, "INVALID_VERSION", e))? + }; + + let mut test_ids: Vec = Vec::new(); + for entry in &body.tests { + match entry { + TestOrPreset::Test { name } => test_ids.push(from_potential_legacy_name(name)), + TestOrPreset::Preset { name } => { + let preset_tests = match version { + CsafVersion::X20 => CsafDoc20::tests_in_preset(name) + .map_err(|e| error_response(StatusCode::BAD_REQUEST, "CSAF_ERROR", e.to_string()))?, + CsafVersion::X21 => CsafDoc21::tests_in_preset(name) + .map_err(|e| error_response(StatusCode::BAD_REQUEST, "CSAF_ERROR", e.to_string()))?, + }; + test_ids.extend(preset_tests.iter().map(|s| s.to_string())); + }, + } + } + + test_ids.sort(); + test_ids.dedup(); + + if test_ids.is_empty() { + test_ids.push("schema".to_string()); + } + + let test_id_refs: Vec<&str> = test_ids.iter().map(|s| s.as_str()).collect(); + + let result = match version { + CsafVersion::X20 => { + let doc = load_2_0(json_value).map_err(|e| { + error_response( + StatusCode::BAD_REQUEST, + "PARSE_ERROR", + format!("Failed to load CSAF 2.0 document: {e}"), + ) + })?; + validate_by_tests(&doc, version.as_str(), &test_id_refs) + }, + CsafVersion::X21 => { + let doc = load_2_1(json_value).map_err(|e| { + error_response( + StatusCode::BAD_REQUEST, + "PARSE_ERROR", + format!("Failed to load CSAF 2.1 document: {e}"), + ) + })?; + validate_by_tests(&doc, version.as_str(), &test_id_refs) + }, + }; + + // ToDo reenable this when all tests are implemented to catch bad input. + // if result.num_not_found > 0 { + // return Err(error_response( + // StatusCode::BAD_REQUEST, + // "TEST_NOT_FOUND", + // format!("One or more tests not found: {test_ids:?}"), + // )); + // } + Ok(Json(to_legacy_response(result))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::routes; + use crate::test_helpers::post_json; + use axum::http::StatusCode; + + #[test] + fn test_from_potential_legacy_name() { + assert_eq!(from_potential_legacy_name("mandatoryTest_6_1_10"), "6.1.10"); + assert_eq!(from_potential_legacy_name("mandatoryTest_6_1_1"), "6.1.1"); + assert_eq!(from_potential_legacy_name("optionalTest_6_2_3"), "6.2.3"); + assert_eq!(from_potential_legacy_name("recommendedTest_6_2_3"), "6.2.3"); + assert_eq!(from_potential_legacy_name("informativeTest_6_3_1"), "6.3.1"); + assert_eq!(from_potential_legacy_name("6.1.10"), "6.1.10"); + assert_eq!(from_potential_legacy_name("schema"), "schema"); + } + + fn valid_csaf_2_0() -> serde_json::Value { + let bytes = include_bytes!( + "../../../../csaf/csaf_2.0/test/validator/data/mandatory/oasis_csaf_tc-csaf_2_0-2021-6-1-01-11.json" + ); + serde_json::from_slice(bytes).unwrap() + } + + #[test] + fn test_or_preset_serializes_with_type_tag() { + let test = TestOrPreset::Test { + name: "schema".to_string(), + }; + let json = serde_json::to_value(&test).unwrap(); + assert_eq!(json["type"], "test"); + assert_eq!(json["name"], "schema"); + + let preset = TestOrPreset::Preset { + name: "basic".to_string(), + }; + let json = serde_json::to_value(&preset).unwrap(); + assert_eq!(json["type"], "preset"); + assert_eq!(json["name"], "basic"); + } + + #[test] + fn test_or_preset_deserializes_from_type_tag() { + let json = serde_json::json!({"type": "test", "name": "schema"}); + let parsed: TestOrPreset = serde_json::from_value(json).unwrap(); + assert!(matches!(parsed, TestOrPreset::Test { name } if name == "schema")); + + let json = serde_json::json!({"type": "preset", "name": "basic"}); + let parsed: TestOrPreset = serde_json::from_value(json).unwrap(); + assert!(matches!(parsed, TestOrPreset::Preset { name } if name == "basic")); + } + + #[tokio::test] + async fn validate_legacy_with_test() { + let body = serde_json::json!({ + "tests": [{"type": "test", "name": "schema"}], + "document": valid_csaf_2_0() + }); + let (status, json) = post_json(routes::V1_VALIDATE, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["isValid"], true); + assert!(!json["tests"].as_array().unwrap().is_empty()); + } + + #[tokio::test] + async fn validate_legacy_accepts_document_metadata_directly() { + let body = serde_json::json!({ + "tests": [{"type": "test", "name": "schema"}], + "document": valid_csaf_2_0() + }); + let (status, json) = post_json(routes::V1_VALIDATE, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["isValid"], true); + } + + #[tokio::test] + async fn validate_legacy_with_preset() { + let body = serde_json::json!({ + "tests": [{"type": "preset", "name": "basic"}], + "document": valid_csaf_2_0() + }); + let (status, json) = post_json(routes::V1_VALIDATE, body).await; + + assert_eq!(status, StatusCode::OK); + assert_eq!(json["isValid"], true); + } + + #[tokio::test] + async fn validate_legacy_response_structure() { + let body = serde_json::json!({ + "tests": [{"type": "test", "name": "schema"}], + "document": valid_csaf_2_0() + }); + let (status, json) = post_json(routes::V1_VALIDATE, body).await; + + assert_eq!(status, StatusCode::OK); + // Check legacy response shape + assert!(json["isValid"].is_boolean()); + let tests = json["tests"].as_array().unwrap(); + let test_result = &tests[0]; + assert!(test_result["name"].is_string()); + assert!(test_result["isValid"].is_boolean()); + assert!(test_result["errors"].is_array()); + assert!(test_result["warnings"].is_array()); + assert!(test_result["infos"].is_array()); + } + + #[tokio::test] + async fn validate_legacy_invalid_document() { + let body = serde_json::json!({ + "tests": [{"type": "test", "name": "schema"}], + "document": "not an object" + }); + let (status, json) = post_json(routes::V1_VALIDATE, body).await; + + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!json["error"].as_str().unwrap().is_empty()); + } +} diff --git a/csaf-service/src/main.rs b/csaf-service/src/main.rs new file mode 100644 index 00000000..b55bea32 --- /dev/null +++ b/csaf-service/src/main.rs @@ -0,0 +1,122 @@ +mod handlers; +mod routes; +mod settings; +mod test_helpers; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::http::{HeaderValue, Method}; +use axum::routing::{get, post}; +use tower_http::cors::CorsLayer; +use tower_http::trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}; +use utoipa::OpenApi; +use utoipa_swagger_ui::SwaggerUi; + +use crate::handlers::health::*; +use crate::handlers::v1::errors::*; +use crate::handlers::v1::get_tests::*; +use crate::handlers::v1::validate::*; +use crate::settings::{CorsSettings, Settings}; + +fn build_cors_layer(cors: &CorsSettings) -> CorsLayer { + if cors.permissive { + tracing::warn!("Permissive CORS is enabled — do not use in production"); + return CorsLayer::permissive(); + } + + let origins: Vec = cors + .allowed_origins + .iter() + .filter_map(|origin| { + origin + .parse() + .inspect_err(|_| tracing::warn!("Ignoring invalid CORS origin: {origin}")) + .ok() + }) + .collect(); + + let methods: Vec = cors + .allowed_methods + .iter() + .filter_map(|method| { + method + .parse() + .inspect_err(|_| tracing::warn!("Ignoring invalid CORS method: {method}")) + .ok() + }) + .collect(); + + let mut layer = CorsLayer::new(); + if !origins.is_empty() { + layer = layer.allow_origin(origins); + } + if !methods.is_empty() { + layer = layer.allow_methods(methods); + } + layer +} + +#[derive(OpenApi)] +#[openapi( + paths( + handlers::health::health, + handlers::v1::get_tests::get_tests, + handlers::v1::validate::validate, + ), + components(schemas( + ErrorResponse, + TestInPreset, + ValidateBody, + TestOrPreset, + ValidateResponse, + TestResult, + Finding + )), + tags( + (name = "meta", description = "CSAF preset and test listing and details"), + (name = "validation", description = "CSAF document validation"), + (name = "health", description = "Service health checks"), + ), + info( + title = "CSAF Validation API", + version = "0.5.2", + description = "REST API for validating CSAF (Common Security Advisory Framework) documents against the OASIS CSAF standard. + Uses csaf-rs under the hood. Currently conforms to the format defined by Secvisogram CSAF Validator Service (see https://github.com/secvisogram/csaf-validator-service)." + ) +)] +struct ApiDoc; + +#[tokio::main] +async fn main() { + let settings = Settings::load().expect("Failed to load configuration"); + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| settings.logging.level.clone().into()), + ) + .init(); + + let addr = settings.addr(); + let cors_layer = build_cors_layer(&settings.cors); + let request_level = settings.request_log_level(); + let trace_layer = TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(request_level)) + .on_request(DefaultOnRequest::new().level(request_level)) + .on_response(DefaultOnResponse::new().level(request_level)); + + let app = Router::new() + .route(routes::HEALTH, get(health)) + .route(routes::V1_TESTS, get(get_tests)) + .route(routes::V1_VALIDATE, post(validate)) + .merge(SwaggerUi::new("/openapi").url("/api/openapi.json", ApiDoc::openapi())) + .layer(DefaultBodyLimit::max(settings.body_limit_bytes())) + .layer(cors_layer) + .layer(trace_layer); + + tracing::info!("Starting CSAF Validation API on {addr}"); + tracing::info!("Swagger UI available at http://{addr}/openapi/"); + + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + axum::serve(listener, app).await.unwrap(); +} diff --git a/csaf-service/src/routes.rs b/csaf-service/src/routes.rs new file mode 100644 index 00000000..ebb45bdc --- /dev/null +++ b/csaf-service/src/routes.rs @@ -0,0 +1,3 @@ +pub const HEALTH: &str = "/api/v1/health"; +pub const V1_TESTS: &str = "/api/v1/tests"; +pub const V1_VALIDATE: &str = "/api/v1/validate"; diff --git a/csaf-service/src/settings.rs b/csaf-service/src/settings.rs new file mode 100644 index 00000000..6d525edf --- /dev/null +++ b/csaf-service/src/settings.rs @@ -0,0 +1,95 @@ +use config::{Config, ConfigError, Environment, File, FileFormat}; +use serde::Deserialize; +use tracing::Level; + +/// Defaults are compiled into the binary so the service runs correctly even +/// when no config directory is present at runtime (e.g. a bare binary or a +/// minimal container image). `config/default.toml` remains the single +/// source of truth for these values and is kept in sync via `include_str!`. +const DEFAULT_CONFIG: &str = include_str!("../config/default.toml"); + +#[derive(Debug, Deserialize, Clone)] +pub struct ServerSettings { + pub host: String, + pub port: u16, + pub body_limit_mb: usize, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct CorsSettings { + pub permissive: bool, + #[serde(default)] + pub allowed_origins: Vec, + #[serde(default)] + pub allowed_methods: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct LoggingSettings { + /// Default log filter, used when the `RUST_LOG` environment variable is + /// not set. Accepts anything `tracing_subscriber::EnvFilter` understands + /// (e.g. `"info"` or `"info,tower_http=debug"`). + pub level: String, + /// Level at which incoming/outgoing HTTP requests are logged by + /// `tower_http`'s `TraceLayer`. Must be one of `trace`, `debug`, `info`, + /// `warn`, `error`. + pub request_level: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Settings { + pub server: ServerSettings, + pub cors: CorsSettings, + pub logging: LoggingSettings, +} + +impl Settings { + /// Loads settings from, in increasing order of precedence: + /// 1. Built-in defaults, compiled from `config/default.toml`. + /// 2. `csaf-service/config/local.toml` (optional, gitignored, + /// per-developer/per-deployment overrides). It is looked up both + /// relative to the crate directory and relative to the workspace + /// root, so it is found whether the service is run from the + /// workspace root (`cargo run -p csaf-service`) or from within + /// `csaf-service/` directly. + /// 3. Environment variables prefixed with `CSAF_SERVICE__` (note the + /// double underscore, which is also used as the nesting separator), + /// e.g. `CSAF_SERVICE__SERVER__PORT` or + /// `CSAF_SERVICE__CORS__PERMISSIVE`. + pub fn load() -> Result { + Config::builder() + .add_source(File::from_str(DEFAULT_CONFIG, FileFormat::Toml)) + .add_source(File::with_name("config/local").required(false)) + .add_source(File::with_name("csaf-service/config/local").required(false)) + .add_source( + Environment::with_prefix("CSAF_SERVICE") + .separator("__") + .list_separator(",") + .with_list_parse_key("cors.allowed_origins") + .with_list_parse_key("cors.allowed_methods") + .try_parsing(true), + ) + .build()? + .try_deserialize() + } + + pub fn body_limit_bytes(&self) -> usize { + self.server.body_limit_mb.saturating_mul(1024 * 1024) + } + + pub fn addr(&self) -> String { + format!("{}:{}", self.server.host, self.server.port) + } + + /// Parses `logging.request_level`, falling back to `INFO` (and logging a + /// warning) if it isn't a valid tracing level. + pub fn request_log_level(&self) -> Level { + self.logging.request_level.parse().unwrap_or_else(|_| { + tracing::warn!( + "Invalid logging.request_level '{}', falling back to INFO", + self.logging.request_level + ); + Level::INFO + }) + } +} diff --git a/csaf-service/src/test_helpers.rs b/csaf-service/src/test_helpers.rs new file mode 100644 index 00000000..061a1729 --- /dev/null +++ b/csaf-service/src/test_helpers.rs @@ -0,0 +1,52 @@ +#![cfg(test)] + +use axum::Router; +use axum::body::Body; +use axum::http::StatusCode; +use axum::routing::{get, post}; +use http::Request; +use http_body_util::BodyExt; +use tower::ServiceExt; + +use crate::handlers::health::health; +use crate::handlers::v1::{get_tests::get_tests, validate::validate}; +use crate::routes; + +/// Creates a router with all endpoints registered for testing. +pub fn app() -> Router { + Router::new() + .route(routes::HEALTH, get(health)) + .route(routes::V1_TESTS, get(get_tests)) + .route(routes::V1_VALIDATE, post(validate)) +} + +/// Sends a GET request to the given URI and returns the status and parsed JSON. +pub async fn get_json(uri: &str) -> (StatusCode, serde_json::Value) { + let response = app() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + (status, json) +} + +/// Sends a POST request with a JSON body and returns the status and parsed JSON. +pub async fn post_json(uri: &str, body: serde_json::Value) -> (StatusCode, serde_json::Value) { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + (status, json) +} diff --git a/csaf-validator/Cargo.toml b/csaf-validator/Cargo.toml index 4ee6070e..ef9f97aa 100644 --- a/csaf-validator/Cargo.toml +++ b/csaf-validator/Cargo.toml @@ -17,4 +17,4 @@ anstream = "1.0.0" anstyle = "1.0.8" anyhow = "1.0.93" clap = { version = "4.5.23", features = ["derive"] } -csaf-rs = { path = "../csaf-rs", version = "0.5.1" } +csaf-rs = { path = "../csaf-rs", version = "0.5.2" } diff --git a/csaf-validator/src/main.rs b/csaf-validator/src/main.rs index 4c297176..6128047b 100644 --- a/csaf-validator/src/main.rs +++ b/csaf-validator/src/main.rs @@ -98,9 +98,17 @@ where let test_ids: Vec<_> = args .test .iter() - .flat_map(|test_or_preset| match T::tests_in_preset(test_or_preset) { - Some(test_ids) => test_ids, - None => vec![test_or_preset.as_str()], + .flat_map(|test_or_preset| { + let presets = T::get_presets(); + // Try to find a matching preset for the document version + let matched_preset = presets + .iter() + .find(|p| p.to_string().eq_ignore_ascii_case(test_or_preset)); + match matched_preset { + Some(preset) => T::tests_in_preset(preset).unwrap_or_default(), + // no matching preset found, treat the argument as a test ID + None => vec![test_or_preset.as_str()], + } }) .collect(); diff --git a/go/cmd/example/main.go b/go/cmd/example/main.go deleted file mode 100644 index cbd07932..00000000 --- a/go/cmd/example/main.go +++ /dev/null @@ -1,58 +0,0 @@ -package main - -import ( - "fmt" - "os" - - "github.com/csaf-rs/csaf/go/csaf_ffi" -) - -func main() { - if len(os.Args) < 2 { - fmt.Fprintf(os.Stderr, "Usage: %s [preset]\n", os.Args[0]) - fmt.Fprintf(os.Stderr, " preset: basic (default), extended, full\n") - os.Exit(1) - } - - path := os.Args[1] - preset := "basic" - if len(os.Args) > 2 { - preset = os.Args[2] - } - - data, err := os.ReadFile(path) - if err != nil { - fmt.Fprintf(os.Stderr, "Error reading file: %v\n", err) - os.Exit(1) - } - - result, err := csaf_ffi.ValidateCsaf(string(data), preset) - if err != nil { - fmt.Fprintf(os.Stderr, "Validation error: %v\n", err) - os.Exit(1) - } - - status := "✅ VALID" - if !result.Success { - status = "❌ INVALID" - } - - fmt.Printf("%s (CSAF %s)\n", status, result.Version) - fmt.Printf(" Errors: %d, Warnings: %d, Infos: %d, Not Found: %d\n", - result.NumErrors, result.NumWarnings, result.NumInfos, result.NumNotFound) - - for _, tr := range result.TestResults { - switch tr.Status.(type) { - case csaf_ffi.TestResultStatusSuccess: - fmt.Printf(" ✅ %s\n", tr.TestId) - case csaf_ffi.TestResultStatusFailure: - f := tr.Status.(csaf_ffi.TestResultStatusFailure) - fmt.Printf(" ❌ %s (%d errors, %d warnings, %d infos)\n", - tr.TestId, len(f.Errors), len(f.Warnings), len(f.Infos)) - case csaf_ffi.TestResultStatusNotFound: - fmt.Printf(" ⚠️ %s (not found)\n", tr.TestId) - case csaf_ffi.TestResultStatusSkipped: - fmt.Printf(" ⏭️ %s (skipped)\n", tr.TestId) - } - } -} diff --git a/go/cmd/webapi/main.go b/go/cmd/webapi/main.go deleted file mode 100644 index 417cff7a..00000000 --- a/go/cmd/webapi/main.go +++ /dev/null @@ -1,127 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "io" - "log" - "net/http" - "os" - - "github.com/csaf-rs/csaf/go/csaf_ffi" -) - -type errorResponse struct { - Error string `json:"error"` -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(v) -} - -func writeError(w http.ResponseWriter, status int, msg string) { - writeJSON(w, status, errorResponse{Error: msg}) -} - -func validate(w http.ResponseWriter, jsonStr string, preset string) { - result, err := csaf_ffi.ValidateCsaf(jsonStr, preset) - if err != nil { - writeError(w, http.StatusUnprocessableEntity, err.Error()) - return - } - writeJSON(w, http.StatusOK, result) -} - -func presetParam(r *http.Request) string { - if p := r.URL.Query().Get("preset"); p != "" { - return p - } - return "basic" -} - -// POST /api/validate/upload — multipart file upload -func handleValidateUpload(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "POST required") - return - } - - r.Body = http.MaxBytesReader(w, r.Body, 50<<20) - - file, _, err := r.FormFile("file") - if err != nil { - writeError(w, http.StatusBadRequest, "missing or invalid 'file' field: "+err.Error()) - return - } - defer file.Close() - - data, err := io.ReadAll(file) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read file: "+err.Error()) - return - } - - validate(w, string(data), presetParam(r)) -} - -// POST /api/validate/json — raw JSON body -func handleValidateJSON(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - writeError(w, http.StatusMethodNotAllowed, "POST required") - return - } - - r.Body = http.MaxBytesReader(w, r.Body, 50<<20) - - data, err := io.ReadAll(r.Body) - if err != nil { - writeError(w, http.StatusBadRequest, "failed to read request body: "+err.Error()) - return - } - if len(data) == 0 { - writeError(w, http.StatusBadRequest, "empty request body") - return - } - - validate(w, string(data), presetParam(r)) -} - -// CORS middleware -func corsMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") - w.Header().Set("Access-Control-Allow-Headers", "Content-Type") - - if r.Method == http.MethodOptions { - w.WriteHeader(http.StatusNoContent) - return - } - - next.ServeHTTP(w, r) - }) -} - -func main() { - addr := ":8080" - if port := os.Getenv("PORT"); port != "" { - addr = ":" + port - } - - mux := http.NewServeMux() - mux.HandleFunc("/api/validate/upload", handleValidateUpload) - mux.HandleFunc("/api/validate/json", handleValidateJSON) - - handler := corsMiddleware(mux) - - fmt.Printf("CSAF Validation API listening on %s\n", addr) - fmt.Println(" POST /api/validate/upload — multipart file upload (field: 'file')") - fmt.Println(" POST /api/validate/json — raw JSON body") - fmt.Println(" Query param: ?preset=basic|extended|full") - - if err := http.ListenAndServe(addr, handler); err != nil { - log.Fatal(err) - } -} diff --git a/go/csaf_ffi/csaf_ffi.go b/go/csaf_ffi/csaf_ffi.go index bfa6c504..7964272c 100644 --- a/go/csaf_ffi/csaf_ffi.go +++ b/go/csaf_ffi/csaf_ffi.go @@ -369,6 +369,24 @@ func uniffiCheckChecksums() { // If this happens try cleaning and rebuilding your project panic("csaf_ffi: UniFFI contract version mismatch") } + { + checksum := rustCall(func(_uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_csaf_ffi_checksum_func_get_tests() + }) + if checksum != 26988 { + // If this happens try cleaning and rebuilding your project + panic("csaf_ffi: uniffi_csaf_ffi_checksum_func_get_tests: UniFFI API checksum mismatch") + } + } + { + checksum := rustCall(func(_uniffiStatus *C.RustCallStatus) C.uint16_t { + return C.uniffi_csaf_ffi_checksum_func_get_tests_in_preset() + }) + if checksum != 22002 { + // If this happens try cleaning and rebuilding your project + panic("csaf_ffi: uniffi_csaf_ffi_checksum_func_get_tests_in_preset: UniFFI API checksum mismatch") + } + } { checksum := rustCall(func(_uniffiStatus *C.RustCallStatus) C.uint16_t { return C.uniffi_csaf_ffi_checksum_func_validate_csaf() @@ -984,6 +1002,51 @@ func (_ FfiDestroyerProductReference) Destroy(value ProductReference) { value.Destroy() } +// A validation test and the primary preset it belongs to. +type TestInPreset struct { + Name string + Preset string +} + +func (r *TestInPreset) Destroy() { + FfiDestroyerString{}.Destroy(r.Name) + FfiDestroyerString{}.Destroy(r.Preset) +} + +type FfiConverterTestInPreset struct{} + +var FfiConverterTestInPresetINSTANCE = FfiConverterTestInPreset{} + +func (c FfiConverterTestInPreset) Lift(rb RustBufferI) TestInPreset { + return LiftFromRustBuffer[TestInPreset](c, rb) +} + +func (c FfiConverterTestInPreset) Read(reader io.Reader) TestInPreset { + return TestInPreset{ + FfiConverterStringINSTANCE.Read(reader), + FfiConverterStringINSTANCE.Read(reader), + } +} + +func (c FfiConverterTestInPreset) Lower(value TestInPreset) C.RustBuffer { + return LowerIntoRustBuffer[TestInPreset](c, value) +} + +func (c FfiConverterTestInPreset) LowerExternal(value TestInPreset) ExternalCRustBuffer { + return RustBufferFromC(LowerIntoRustBuffer[TestInPreset](c, value)) +} + +func (c FfiConverterTestInPreset) Write(writer io.Writer, value TestInPreset) { + FfiConverterStringINSTANCE.Write(writer, value.Name) + FfiConverterStringINSTANCE.Write(writer, value.Preset) +} + +type FfiDestroyerTestInPreset struct{} + +func (_ FfiDestroyerTestInPreset) Destroy(value TestInPreset) { + value.Destroy() +} + // Result of a single validation test. type TestResult struct { TestId string @@ -1247,6 +1310,7 @@ var ErrCsafErrorInvalidJson = fmt.Errorf("CsafErrorInvalidJson") var ErrCsafErrorMissingVersion = fmt.Errorf("CsafErrorMissingVersion") var ErrCsafErrorUnsupportedVersion = fmt.Errorf("CsafErrorUnsupportedVersion") var ErrCsafErrorLoadError = fmt.Errorf("CsafErrorLoadError") +var ErrCsafErrorInvalidPreset = fmt.Errorf("CsafErrorInvalidPreset") // Variant structs type CsafErrorInvalidJson struct { @@ -1361,6 +1425,34 @@ func (self CsafErrorLoadError) Is(target error) bool { return target == ErrCsafErrorLoadError } +type CsafErrorInvalidPreset struct { + Message string +} + +func NewCsafErrorInvalidPreset( + message string, +) *CsafError { + return &CsafError{err: &CsafErrorInvalidPreset{ + Message: message}} +} + +func (e CsafErrorInvalidPreset) destroy() { + FfiDestroyerString{}.Destroy(e.Message) +} + +func (err CsafErrorInvalidPreset) Error() string { + return fmt.Sprint("InvalidPreset", + ": ", + + "Message=", + err.Message, + ) +} + +func (self CsafErrorInvalidPreset) Is(target error) bool { + return target == ErrCsafErrorInvalidPreset +} + type FfiConverterCsafError struct{} var FfiConverterCsafErrorINSTANCE = FfiConverterCsafError{} @@ -1397,6 +1489,10 @@ func (c FfiConverterCsafError) Read(reader io.Reader) *CsafError { return &CsafError{&CsafErrorLoadError{ Message: FfiConverterStringINSTANCE.Read(reader), }} + case 5: + return &CsafError{&CsafErrorInvalidPreset{ + Message: FfiConverterStringINSTANCE.Read(reader), + }} default: panic(fmt.Sprintf("Unknown error code %d in FfiConverterCsafError.Read()", errorID)) } @@ -1416,6 +1512,9 @@ func (c FfiConverterCsafError) Write(writer io.Writer, value *CsafError) { case *CsafErrorLoadError: writeInt32(writer, 4) FfiConverterStringINSTANCE.Write(writer, variantValue.Message) + case *CsafErrorInvalidPreset: + writeInt32(writer, 5) + FfiConverterStringINSTANCE.Write(writer, variantValue.Message) default: _ = variantValue panic(fmt.Sprintf("invalid error value `%v` in FfiConverterCsafError.Write", value)) @@ -1434,6 +1533,8 @@ func (_ FfiDestroyerCsafError) Destroy(value *CsafError) { variantValue.destroy() case CsafErrorLoadError: variantValue.destroy() + case CsafErrorInvalidPreset: + variantValue.destroy() default: _ = variantValue panic(fmt.Sprintf("invalid error value `%v` in FfiDestroyerCsafError.Destroy", value)) @@ -1903,6 +2004,53 @@ func (FfiDestroyerSequenceString) Destroy(sequence []string) { } } +type FfiConverterSequenceTestInPreset struct{} + +var FfiConverterSequenceTestInPresetINSTANCE = FfiConverterSequenceTestInPreset{} + +func (c FfiConverterSequenceTestInPreset) Lift(rb RustBufferI) []TestInPreset { + return LiftFromRustBuffer[[]TestInPreset](c, rb) +} + +func (c FfiConverterSequenceTestInPreset) Read(reader io.Reader) []TestInPreset { + length := readInt32(reader) + if length == 0 { + return nil + } + result := make([]TestInPreset, 0, length) + for i := int32(0); i < length; i++ { + result = append(result, FfiConverterTestInPresetINSTANCE.Read(reader)) + } + return result +} + +func (c FfiConverterSequenceTestInPreset) Lower(value []TestInPreset) C.RustBuffer { + return LowerIntoRustBuffer[[]TestInPreset](c, value) +} + +func (c FfiConverterSequenceTestInPreset) LowerExternal(value []TestInPreset) ExternalCRustBuffer { + return RustBufferFromC(LowerIntoRustBuffer[[]TestInPreset](c, value)) +} + +func (c FfiConverterSequenceTestInPreset) Write(writer io.Writer, value []TestInPreset) { + if len(value) > math.MaxInt32 { + panic("[]TestInPreset is too large to fit into Int32") + } + + writeInt32(writer, int32(len(value))) + for _, item := range value { + FfiConverterTestInPresetINSTANCE.Write(writer, item) + } +} + +type FfiDestroyerSequenceTestInPreset struct{} + +func (FfiDestroyerSequenceTestInPreset) Destroy(sequence []TestInPreset) { + for _, value := range sequence { + FfiDestroyerTestInPreset{}.Destroy(value) + } +} + type FfiConverterSequenceTestResult struct{} var FfiConverterSequenceTestResultINSTANCE = FfiConverterSequenceTestResult{} @@ -1997,6 +2145,38 @@ func (FfiDestroyerSequenceValidationError) Destroy(sequence []ValidationError) { } } +// Retrieve all available validation tests for a CSAF version. +// +// For each test, returns the test number and the primary preset it belongs to. +func GetTests(version string) ([]TestInPreset, error) { + _uniffiRV, _uniffiErr := rustCallWithError[*CsafError](FfiConverterCsafError{}, func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return GoRustBuffer{ + inner: C.uniffi_csaf_ffi_fn_func_get_tests(FfiConverterStringINSTANCE.Lower(version), _uniffiStatus), + } + }) + if _uniffiErr != nil { + var _uniffiDefaultValue []TestInPreset + return _uniffiDefaultValue, _uniffiErr + } else { + return FfiConverterSequenceTestInPresetINSTANCE.Lift(_uniffiRV), nil + } +} + +// Retrieve the test IDs belonging to a preset for a CSAF version. +func GetTestsInPreset(version string, preset string) ([]string, error) { + _uniffiRV, _uniffiErr := rustCallWithError[*CsafError](FfiConverterCsafError{}, func(_uniffiStatus *C.RustCallStatus) RustBufferI { + return GoRustBuffer{ + inner: C.uniffi_csaf_ffi_fn_func_get_tests_in_preset(FfiConverterStringINSTANCE.Lower(version), FfiConverterStringINSTANCE.Lower(preset), _uniffiStatus), + } + }) + if _uniffiErr != nil { + var _uniffiDefaultValue []string + return _uniffiDefaultValue, _uniffiErr + } else { + return FfiConverterSequenceStringINSTANCE.Lift(_uniffiRV), nil + } +} + // Validate a CSAF document from a JSON string. // // Auto-detects the CSAF version from the document's `document.csaf_version` diff --git a/go/csaf_ffi/csaf_ffi.h b/go/csaf_ffi/csaf_ffi.h index 1acd682d..7ec54829 100644 --- a/go/csaf_ffi/csaf_ffi.h +++ b/go/csaf_ffi/csaf_ffi.h @@ -425,6 +425,16 @@ RustBuffer uniffi_csaf_ffi_fn_method_csafdocument_to_json(uint64_t ptr, RustCall RustBuffer uniffi_csaf_ffi_fn_method_csafdocument_validate(uint64_t ptr, RustBuffer preset, RustCallStatus *out_status ); #endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_FN_FUNC_GET_TESTS +#define UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_FN_FUNC_GET_TESTS +RustBuffer uniffi_csaf_ffi_fn_func_get_tests(RustBuffer version, RustCallStatus *out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_FN_FUNC_GET_TESTS_IN_PRESET +#define UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_FN_FUNC_GET_TESTS_IN_PRESET +RustBuffer uniffi_csaf_ffi_fn_func_get_tests_in_preset(RustBuffer version, RustBuffer preset, RustCallStatus *out_status +); +#endif #ifndef UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_FN_FUNC_VALIDATE_CSAF #define UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_FN_FUNC_VALIDATE_CSAF RustBuffer uniffi_csaf_ffi_fn_func_validate_csaf(RustBuffer json_str, RustBuffer preset, RustCallStatus *out_status @@ -713,6 +723,18 @@ void ffi_csaf_ffi_rust_future_free_void(uint64_t handle #ifndef UNIFFI_FFIDEF_FFI_CSAF_FFI_RUST_FUTURE_COMPLETE_VOID #define UNIFFI_FFIDEF_FFI_CSAF_FFI_RUST_FUTURE_COMPLETE_VOID void ffi_csaf_ffi_rust_future_complete_void(uint64_t handle, RustCallStatus *out_status +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_CHECKSUM_FUNC_GET_TESTS +#define UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_CHECKSUM_FUNC_GET_TESTS +uint16_t uniffi_csaf_ffi_checksum_func_get_tests(void + +); +#endif +#ifndef UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_CHECKSUM_FUNC_GET_TESTS_IN_PRESET +#define UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_CHECKSUM_FUNC_GET_TESTS_IN_PRESET +uint16_t uniffi_csaf_ffi_checksum_func_get_tests_in_preset(void + ); #endif #ifndef UNIFFI_FFIDEF_UNIFFI_CSAF_FFI_CHECKSUM_FUNC_VALIDATE_CSAF diff --git a/wasm/package-lock.json b/wasm/package-lock.json index 314efa44..ea5a86b3 100644 --- a/wasm/package-lock.json +++ b/wasm/package-lock.json @@ -1,12 +1,12 @@ { "name": "@csaf-rs/csaf-wasm", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@csaf-rs/csaf-wasm", - "version": "0.5.1", + "version": "0.5.2", "license": "Apache-2.0", "devDependencies": { "@types/node": "^25.6.2", diff --git a/wasm/package.json b/wasm/package.json index 53c0512e..247e5d00 100644 --- a/wasm/package.json +++ b/wasm/package.json @@ -1,6 +1,6 @@ { "name": "@csaf-rs/csaf-rs", - "version": "0.5.1", + "version": "0.5.2", "type": "module", "description": "CSAF validation via WASM (UniFFI-generated bindings)", "main": "csaf_ffi.ts",