From d702f6a24fea7b7ee407d199d318b00f22f2a6eb Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Fri, 14 Aug 2026 11:50:19 +0200 Subject: [PATCH 01/10] feat: provide web service --- Cargo.lock | 640 +++++++++++++++++++++- Cargo.toml | 4 +- Dockerfile | 33 ++ README.md | 57 +- csaf-converter/Cargo.toml | 2 +- csaf-ffi/src/document.rs | 52 +- csaf-ffi/src/lib.rs | 77 ++- csaf-result-json/Cargo.toml | 2 +- csaf-result-json/src/main.rs | 4 +- csaf-rs/Cargo.toml | 5 +- csaf-rs/src/csaf/enums/csaf_version.rs | 42 +- csaf-rs/src/csaf/raw.rs | 16 +- csaf-rs/src/csaf2_0/validation.rs | 159 +++++- csaf-rs/src/csaf2_1/validation.rs | 210 +++++-- csaf-rs/src/macros/test_gen.rs | 15 +- csaf-rs/src/validation.rs | 67 ++- csaf-rs/src/validation_result.rs | 2 +- csaf-service/Cargo.toml | 32 ++ csaf-service/README.md | 16 + csaf-service/src/handlers/health.rs | 29 + csaf-service/src/handlers/mod.rs | 2 + csaf-service/src/handlers/v1/errors.rs | 28 + csaf-service/src/handlers/v1/get_tests.rs | 63 +++ csaf-service/src/handlers/v1/mod.rs | 3 + csaf-service/src/handlers/v1/validate.rs | 372 +++++++++++++ csaf-service/src/main.rs | 96 ++++ csaf-service/src/routes.rs | 3 + csaf-service/src/test_helpers.rs | 52 ++ csaf-validator/Cargo.toml | 2 +- csaf-validator/src/main.rs | 14 +- go/cmd/example/main.go | 58 -- go/cmd/webapi/main.go | 127 ----- go/csaf_ffi/csaf_ffi.go | 180 ++++++ go/csaf_ffi/csaf_ffi.h | 22 + wasm/package-lock.json | 4 +- wasm/package.json | 2 +- 36 files changed, 2116 insertions(+), 376 deletions(-) create mode 100644 Dockerfile create mode 100644 csaf-service/Cargo.toml create mode 100644 csaf-service/README.md create mode 100644 csaf-service/src/handlers/health.rs create mode 100644 csaf-service/src/handlers/mod.rs create mode 100644 csaf-service/src/handlers/v1/errors.rs create mode 100644 csaf-service/src/handlers/v1/get_tests.rs create mode 100644 csaf-service/src/handlers/v1/mod.rs create mode 100644 csaf-service/src/handlers/v1/validate.rs create mode 100644 csaf-service/src/main.rs create mode 100644 csaf-service/src/routes.rs create mode 100644 csaf-service/src/test_helpers.rs delete mode 100644 go/cmd/example/main.go delete mode 100644 go/cmd/webapi/main.go diff --git a/Cargo.lock b/Cargo.lock index 248e0a06..5ac09b41 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" @@ -388,6 +467,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 +554,7 @@ dependencies = [ [[package]] name = "csaf-converter" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anyhow", "clap", @@ -475,7 +563,7 @@ dependencies = [ [[package]] name = "csaf-ffi" -version = "0.5.1" +version = "0.5.2" dependencies = [ "csaf-rs", "serde_json", @@ -485,7 +573,7 @@ dependencies = [ [[package]] name = "csaf-result-json" -version = "0.5.1" +version = "0.5.2" dependencies = [ "anstream", "anstyle", @@ -498,7 +586,7 @@ dependencies = [ [[package]] name = "csaf-rs" -version = "0.5.1" +version = "0.5.2" dependencies = [ "chrono", "criterion", @@ -517,12 +605,33 @@ dependencies = [ "ssvc", "strum", "tempfile", + "thiserror 2.0.18", + "utoipa", "uuid", ] +[[package]] +name = "csaf-service" +version = "0.1.1" +dependencies = [ + "axum", + "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", @@ -549,6 +658,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", +] + [[package]] name = "digest" version = "0.10.7" @@ -578,9 +698,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" @@ -630,6 +750,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" @@ -653,6 +783,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" @@ -672,6 +811,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" @@ -697,9 +845,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" @@ -829,6 +977,92 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[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" @@ -1116,6 +1350,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" @@ -1127,6 +1376,20 @@ name = "micromap" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" +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" @@ -1134,6 +1397,27 @@ 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" @@ -1144,6 +1428,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" @@ -1622,6 +1915,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" @@ -1746,6 +2045,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" @@ -1767,6 +2077,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" @@ -1778,12 +2100,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" @@ -1808,6 +2155,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" @@ -1893,6 +2250,10 @@ dependencies = [ "quote", "unicode-ident", ] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" [[package]] name = "synstructure" @@ -1967,6 +2328,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" @@ -1987,6 +2357,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", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -2029,7 +2427,7 @@ dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.3", ] [[package]] @@ -2038,7 +2436,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]] @@ -2047,9 +2445,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", +] + +[[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", @@ -2120,6 +2624,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" @@ -2269,6 +2779,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" @@ -2281,6 +2803,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", +] + +[[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.0" @@ -2303,6 +2867,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" @@ -2325,6 +2895,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" @@ -2551,9 +3127,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", ] @@ -2703,9 +3279,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", ] @@ -2755,8 +3331,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..0cf8d6c3 --- /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_PORT=8082 +# Listen on all interfaces for container compatibility +ENV CSAF_SERVICE_HOST=0.0.0.0 +EXPOSE 8082 + +ENTRYPOINT ["csaf-service"] diff --git a/README.md b/README.md index 676c5535..25b90284 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. @@ -177,47 +178,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). @@ -232,6 +192,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 @@ -339,4 +313,3 @@ This creates TypeScript + WASM output in `wasm/`. | Test specification | 2.0 | 2.1 (experimental) | | --- |-------------------|--------------------| | 6.3.1 | | | - 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 04a2370a..04303ee3 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" @@ -37,6 +38,9 @@ spdx = "0.13.4" oxilangtag = "0.1.5" ssvc = "0.2.1" strum = { version = "0.28", features = ["derive"] } +ssvc = "0.1.0" +thiserror = { version = "2" } +utoipa = { version = "5", optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] uuid = { version = "1.17.0", features = ["v7", "serde", "js"] } @@ -53,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 fe33b7e6..dedf83ba 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::{TestResult, TestResultStatus, Validatable, ValidationError}; +use crate::validation::{CsafError, TestResult, TestResultStatus, Validatable, ValidationError}; #[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 77c79ed7..b8540537 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::{TestResult, TestResultStatus, Validatable}; +use crate::validation::{CsafError, TestResult, TestResultStatus, Validatable}; use crate::validations::test_schema::validate_schema_csaf_2_0; enum Severity { @@ -42,24 +46,147 @@ fn to_test_result( } } +const PRESET_NAME_SCHEMA: &str = "schema"; +const PRESET_NAME_MANDATORY: &str = "mandatory"; +const PRESET_NAME_OPTIONAL: &str = "optional"; +const PRESET_NAME_INFORMATIVE: &str = "informative"; +const PRESET_NAME_BASIC: &str = "basic"; +const PRESET_NAME_EXTENDED: &str = "extended"; +const PRESET_NAME_FULL: &str = "full"; + +#[derive(Clone, serde::Deserialize, serde::Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum Preset { + Schema, + Mandatory, + Optional, + Informative, + Basic, + Extended, + Full, +} + +impl Preset { + pub fn as_str(&self) -> &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 { let mandatory_result = to_test_result( test_id, @@ -185,7 +312,7 @@ impl Validatable for CommonSecurityAdvisoryFramework { impl RawValidatable for RawDocument { fn run_raw_test(&self, test_id: &str) -> TestResult { - if test_id == "schema" { + if test_id == PRESET_NAME_SCHEMA { return to_test_result(test_id, Severity::Error, Some(validate_schema_csaf_2_0(self))); } diff --git a/csaf-rs/src/csaf2_1/validation.rs b/csaf-rs/src/csaf2_1/validation.rs index e80b1c16..91bc105b 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::{TestResult, TestResultStatus, Validatable}; +use crate::validation::{CsafError, TestResult, TestResultStatus, Validatable}; use crate::validations::test_schema::validate_schema_csaf_2_1; enum Severity { Error, @@ -41,49 +45,185 @@ fn to_test_result( } } +const PRESET_NAME_SCHEMA: &str = "schema"; +const PRESET_NAME_MANDATORY: &str = "mandatory"; +const PRESET_NAME_RECOMMENDED: &str = "recommended"; +const PRESET_NAME_INFORMATIVE: &str = "informative"; +const PRESET_NAME_BASIC: &str = "basic"; +const PRESET_NAME_EXTENDED: &str = "extended"; +const PRESET_NAME_FULL: &str = "full"; +const PRESET_NAME_EXTERNAL_REQUEST_FREE: &str = "external-request-free"; +const PRESET_NAME_CONSISTENT_REVISION_HISTORY: &str = "consistent-revision-history"; +const PRESET_NAME_CONSISTENT_DATETIMES: &str = "consistent-date-times"; +const PRESET_NAME_SSVC: &str = "ssvc"; + +#[derive(Clone, serde::Deserialize, serde::Serialize, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "kebab-case")] +pub enum Preset { + Schema, + Mandatory, + Recommended, + Informative, + Basic, + Extended, + Full, + ExternalRequestFree, + ConsistentRevisionHistory, + ConsistentDateTimes, + Ssvc, +} + +impl Preset { + pub fn as_str(&self) -> &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 { let mandatory_result = to_test_result( test_id, @@ -292,7 +432,7 @@ impl Validatable for CommonSecurityAdvisoryFramework { impl RawValidatable for RawDocument { fn run_raw_test(&self, test_id: &str) -> TestResult { - if test_id == "schema" { + if test_id == Preset::Schema.as_str() { return to_test_result(test_id, Severity::Error, Some(validate_schema_csaf_2_1(self))); } diff --git a/csaf-rs/src/macros/test_gen.rs b/csaf-rs/src/macros/test_gen.rs index be8e05a4..f2e2c11b 100644 --- a/csaf-rs/src/macros/test_gen.rs +++ b/csaf-rs/src/macros/test_gen.rs @@ -215,19 +215,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 743a820b..454ff83b 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 ValidationError { pub message: String, @@ -23,6 +42,7 @@ pub trait IntoValidationError { /// 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 @@ -33,6 +53,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, @@ -68,6 +89,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) @@ -91,10 +113,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. @@ -103,8 +125,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; @@ -115,8 +149,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) } @@ -128,9 +167,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, @@ -161,10 +202,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-rs/src/validation_result.rs b/csaf-rs/src/validation_result.rs index a98a7155..f7791a48 100644 --- a/csaf-rs/src/validation_result.rs +++ b/csaf-rs/src/validation_result.rs @@ -80,7 +80,7 @@ pub enum TestResult { pub enum TestFinding { /// An information indicates a failure in an informative test, which does not necessarily mean the document is invalid, /// but may provide insights into common mistakes or bad practices. - Infomation(TestFindingData), + Information(TestFindingData), /// A warning indicates a failure in a recommended test, which does not necessarily mean the document is invalid. /// However, it may indicate potential issues or areas for improvement in the document. Warning(TestFindingData), diff --git a/csaf-service/Cargo.toml b/csaf-service/Cargo.toml new file mode 100644 index 00000000..a322b07b --- /dev/null +++ b/csaf-service/Cargo.toml @@ -0,0 +1,32 @@ +[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" +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..7b60bd40 --- /dev/null +++ b/csaf-service/README.md @@ -0,0 +1,16 @@ +# 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 +``` + +You can change the following settings via environment variables: +| Setting | Description | Default Value | +| --- | --- | --- | +| `CSAF_SERVICE_HOST` | The host the service listens on. Change to `0.0.0.0` to listen on all devices. | `127.0.0.1` | +| `CSAF_SERVICE_PORT` | The port the service listens on. | `8082` | +| `CSAF_SERVICE_PERMISSIVE_CORS` | Whether to use a permissive CORS policy (allow all origins). Allows `1` or `true` to activate. | `false` | +| `CSAF_SERVICE_BODY_LIMIT` | The maximum request body size in bytes. Values exceeding this limit are clamped to the maximum. Limited to 150 MB as requested by the standard. | `157286400` (150 MB) | 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..4643870b --- /dev/null +++ b/csaf-service/src/handlers/v1/validate.rs @@ -0,0 +1,372 @@ +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. + 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_", + ]; + 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": {"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": {"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": {"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) + }, + }; + + 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("mandatory_6_1_10"), "6.1.10"); + assert_eq!(from_potential_legacy_name("mandatory_6_1_1"), "6.1.1"); + assert_eq!(from_potential_legacy_name("optional_6_2_3"), "6.2.3"); + assert_eq!(from_potential_legacy_name("recommended_6_2_3"), "6.2.3"); + assert_eq!(from_potential_legacy_name("informative_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..cab076f8 --- /dev/null +++ b/csaf-service/src/main.rs @@ -0,0 +1,96 @@ +mod handlers; +mod routes; +mod test_helpers; + +use axum::Router; +use axum::extract::DefaultBodyLimit; +use axum::routing::{get, post}; +use tower_http::cors::CorsLayer; +use tower_http::trace::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::*; + +fn permissive_cors_enabled() -> bool { + std::env::var("CSAF_SERVICE_PERMISSIVE_CORS") + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +const MAX_BODY_SIZE: usize = 150 * 1024 * 1024; // 150 MB + +fn body_limit() -> usize { + std::env::var("CSAF_SERVICE_BODY_LIMIT") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(MAX_BODY_SIZE) + .min(MAX_BODY_SIZE) +} + +#[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.1", + description = "REST API for validating CSAF (Common Security Advisory Framework) documents against the OASIS CSAF standard. Uses csaf-rs under the hood." + ) +)] +struct ApiDoc; + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let port = std::env::var("CSAF_SERVICE_PORT").unwrap_or_else(|_| "8082".to_string()); + let host = std::env::var("CSAF_SERVICE_HOST").unwrap_or_else(|_| "localhost".to_string()); + let addr = format!("{host}:{port}"); + + let cors_layer = if permissive_cors_enabled() { + tracing::warn!("Permissive CORS is enabled — do not use in production"); + CorsLayer::permissive() + } else { + CorsLayer::new() + }; + // ToDo: Allow configuring CORS more granularly (e.g. allowed origins) via environment variables + // See https://docs.rs/tower-http/latest/tower_http/cors/struct.CorsLayer.html for details + + 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(body_limit())) + .layer(cors_layer) + .layer(TraceLayer::new_for_http()); + + 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/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", From a9e766946dd609184d9a44f997b08e08fab7d45f Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Thu, 23 Jul 2026 09:12:19 +0200 Subject: [PATCH 02/10] Update Cargo.lock with sync_wrapper package --- Cargo.lock | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5ac09b41..a5312bf9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -666,7 +666,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2250,6 +2250,8 @@ dependencies = [ "quote", "unicode-ident", ] + +[[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2382,7 +2384,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2509,7 +2511,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2824,7 +2826,7 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.117", ] [[package]] From cdeb53bef600e36624b0eec15ee3f50f9980a57a Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Thu, 23 Jul 2026 09:34:44 +0200 Subject: [PATCH 03/10] fix test for legacy compatibility --- Cargo.lock | 10 +++------- csaf-service/src/handlers/v1/validate.rs | 10 +++++----- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5312bf9..d0ee284d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -605,7 +605,7 @@ dependencies = [ "ssvc", "strum", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.19", "utoipa", "uuid", ] @@ -977,12 +977,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - [[package]] name = "http" version = "1.4.0" @@ -1376,6 +1370,8 @@ name = "micromap" 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" diff --git a/csaf-service/src/handlers/v1/validate.rs b/csaf-service/src/handlers/v1/validate.rs index 4643870b..d0cde5e8 100644 --- a/csaf-service/src/handlers/v1/validate.rs +++ b/csaf-service/src/handlers/v1/validate.rs @@ -257,11 +257,11 @@ mod tests { #[test] fn test_from_potential_legacy_name() { - assert_eq!(from_potential_legacy_name("mandatory_6_1_10"), "6.1.10"); - assert_eq!(from_potential_legacy_name("mandatory_6_1_1"), "6.1.1"); - assert_eq!(from_potential_legacy_name("optional_6_2_3"), "6.2.3"); - assert_eq!(from_potential_legacy_name("recommended_6_2_3"), "6.2.3"); - assert_eq!(from_potential_legacy_name("informative_6_3_1"), "6.3.1"); + 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"); } From 953629b5740075c5689ce246303c35f9a49904e9 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Mon, 27 Jul 2026 16:21:11 +0200 Subject: [PATCH 04/10] add toml config --- .gitignore | 1 + Cargo.lock | 36 +++++++++++- Dockerfile | 4 +- csaf-service/Cargo.toml | 1 + csaf-service/README.md | 26 ++++++--- csaf-service/config/default.toml | 25 +++++++++ csaf-service/config/local.example.toml | 10 ++++ csaf-service/src/main.rs | 66 +++++++++++++--------- csaf-service/src/settings.rs | 77 ++++++++++++++++++++++++++ 9 files changed, 210 insertions(+), 36 deletions(-) create mode 100644 csaf-service/config/default.toml create mode 100644 csaf-service/config/local.example.toml create mode 100644 csaf-service/src/settings.rs 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 d0ee284d..f1c3f137 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -452,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" @@ -615,6 +627,7 @@ name = "csaf-service" version = "0.1.1" dependencies = [ "axum", + "config", "csaf-rs", "http", "http-body-util", @@ -1588,6 +1601,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" @@ -2398,6 +2417,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" @@ -2680,7 +2712,7 @@ dependencies = [ "serde", "tempfile", "textwrap", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_internal_macros", "uniffi_meta", "uniffi_pipeline", @@ -2736,7 +2768,7 @@ dependencies = [ "quote", "serde", "syn 2.0.117", - "toml", + "toml 0.9.12+spec-1.1.0", "uniffi_meta", ] diff --git a/Dockerfile b/Dockerfile index 0cf8d6c3..b2a805dd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,9 +25,9 @@ RUN apk add --no-cache ca-certificates COPY --from=builder /app/target/release/csaf-service /usr/local/bin/csaf-service -ENV CSAF_SERVICE_PORT=8082 +ENV CSAF_SERVICE__SERVER__PORT=8082 # Listen on all interfaces for container compatibility -ENV CSAF_SERVICE_HOST=0.0.0.0 +ENV CSAF_SERVICE__SERVER__HOST=0.0.0.0 EXPOSE 8082 ENTRYPOINT ["csaf-service"] diff --git a/csaf-service/Cargo.toml b/csaf-service/Cargo.toml index a322b07b..9273bfbb 100644 --- a/csaf-service/Cargo.toml +++ b/csaf-service/Cargo.toml @@ -19,6 +19,7 @@ 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"] } diff --git a/csaf-service/README.md b/csaf-service/README.md index 7b60bd40..b18ff7a3 100644 --- a/csaf-service/README.md +++ b/csaf-service/README.md @@ -7,10 +7,22 @@ Run cargo run -p csaf-service --release ``` -You can change the following settings via environment variables: -| Setting | Description | Default Value | -| --- | --- | --- | -| `CSAF_SERVICE_HOST` | The host the service listens on. Change to `0.0.0.0` to listen on all devices. | `127.0.0.1` | -| `CSAF_SERVICE_PORT` | The port the service listens on. | `8082` | -| `CSAF_SERVICE_PERMISSIVE_CORS` | Whether to use a permissive CORS policy (allow all origins). Allows `1` or `true` to activate. | `false` | -| `CSAF_SERVICE_BODY_LIMIT` | The maximum request body size in bytes. Values exceeding this limit are clamped to the maximum. Limited to 150 MB as requested by the standard. | `157286400` (150 MB) | +## 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"]` | diff --git a/csaf-service/config/default.toml b/csaf-service/config/default.toml new file mode 100644 index 00000000..b36e9f60 --- /dev/null +++ b/csaf-service/config/default.toml @@ -0,0 +1,25 @@ +# 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 MB. Hard-capped at 150 MB as +# required 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"] diff --git a/csaf-service/config/local.example.toml b/csaf-service/config/local.example.toml new file mode 100644 index 00000000..c951c6cc --- /dev/null +++ b/csaf-service/config/local.example.toml @@ -0,0 +1,10 @@ +# 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 diff --git a/csaf-service/src/main.rs b/csaf-service/src/main.rs index cab076f8..4e44b3ef 100644 --- a/csaf-service/src/main.rs +++ b/csaf-service/src/main.rs @@ -1,9 +1,11 @@ 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::TraceLayer; @@ -14,21 +16,44 @@ 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 permissive_cors_enabled() -> bool { - std::env::var("CSAF_SERVICE_PERMISSIVE_CORS") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false) -} +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(); -const MAX_BODY_SIZE: usize = 150 * 1024 * 1024; // 150 MB + let methods: Vec = cors + .allowed_methods + .iter() + .filter_map(|method| { + method + .parse() + .inspect_err(|_| tracing::warn!("Ignoring invalid CORS method: {method}")) + .ok() + }) + .collect(); -fn body_limit() -> usize { - std::env::var("CSAF_SERVICE_BODY_LIMIT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(MAX_BODY_SIZE) - .min(MAX_BODY_SIZE) + 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)] @@ -66,25 +91,16 @@ async fn main() { .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) .init(); - let port = std::env::var("CSAF_SERVICE_PORT").unwrap_or_else(|_| "8082".to_string()); - let host = std::env::var("CSAF_SERVICE_HOST").unwrap_or_else(|_| "localhost".to_string()); - let addr = format!("{host}:{port}"); - - let cors_layer = if permissive_cors_enabled() { - tracing::warn!("Permissive CORS is enabled — do not use in production"); - CorsLayer::permissive() - } else { - CorsLayer::new() - }; - // ToDo: Allow configuring CORS more granularly (e.g. allowed origins) via environment variables - // See https://docs.rs/tower-http/latest/tower_http/cors/struct.CorsLayer.html for details + let settings = Settings::load().expect("Failed to load configuration"); + let addr = settings.addr(); + let cors_layer = build_cors_layer(&settings.cors); 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(body_limit())) + .layer(DefaultBodyLimit::max(settings.body_limit_bytes())) .layer(cors_layer) .layer(TraceLayer::new_for_http()); diff --git a/csaf-service/src/settings.rs b/csaf-service/src/settings.rs new file mode 100644 index 00000000..651f9120 --- /dev/null +++ b/csaf-service/src/settings.rs @@ -0,0 +1,77 @@ +use config::{Config, ConfigError, Environment, File, FileFormat}; +use serde::Deserialize; + +/// 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"); + +/// Hard safety ceiling for request body size, as required by the CSAF +/// standard. `server.body_limit_mb` is clamped to this value regardless of +/// what is configured. +pub const MAX_BODY_LIMIT_BYTES: usize = 150 * 1024 * 1024; // 150 MB + +#[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 Settings { + pub server: ServerSettings, + pub cors: CorsSettings, +} + +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) + .min(MAX_BODY_LIMIT_BYTES) + } + + pub fn addr(&self) -> String { + format!("{}:{}", self.server.host, self.server.port) + } +} From ac5c266d805f2db52e470eaa3fd56986594fa3d2 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Mon, 27 Jul 2026 16:57:19 +0200 Subject: [PATCH 05/10] add logging level to config --- csaf-service/README.md | 4 ++++ csaf-service/config/default.toml | 10 ++++++++++ csaf-service/config/local.example.toml | 3 +++ csaf-service/src/main.rs | 17 +++++++++++++---- csaf-service/src/settings.rs | 26 ++++++++++++++++++++++++++ 5 files changed, 56 insertions(+), 4 deletions(-) diff --git a/csaf-service/README.md b/csaf-service/README.md index b18ff7a3..84a9ca51 100644 --- a/csaf-service/README.md +++ b/csaf-service/README.md @@ -26,3 +26,7 @@ Settings are loaded, in increasing order of precedence, from: | 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 index b36e9f60..0538932c 100644 --- a/csaf-service/config/default.toml +++ b/csaf-service/config/default.toml @@ -23,3 +23,13 @@ permissive = false # 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 index c951c6cc..97253f4a 100644 --- a/csaf-service/config/local.example.toml +++ b/csaf-service/config/local.example.toml @@ -8,3 +8,6 @@ [cors] permissive = true + +[logging] +request_level = "info" diff --git a/csaf-service/src/main.rs b/csaf-service/src/main.rs index 4e44b3ef..350017bf 100644 --- a/csaf-service/src/main.rs +++ b/csaf-service/src/main.rs @@ -8,7 +8,7 @@ use axum::extract::DefaultBodyLimit; use axum::http::{HeaderValue, Method}; use axum::routing::{get, post}; use tower_http::cors::CorsLayer; -use tower_http::trace::TraceLayer; +use tower_http::trace::{DefaultMakeSpan, DefaultOnRequest, DefaultOnResponse, TraceLayer}; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; @@ -87,13 +87,22 @@ 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(|_| "info".into())) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| settings.logging.level.clone().into()), + ) .init(); - let settings = Settings::load().expect("Failed to load configuration"); 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)) @@ -102,7 +111,7 @@ async fn main() { .merge(SwaggerUi::new("/openapi").url("/api/openapi.json", ApiDoc::openapi())) .layer(DefaultBodyLimit::max(settings.body_limit_bytes())) .layer(cors_layer) - .layer(TraceLayer::new_for_http()); + .layer(trace_layer); tracing::info!("Starting CSAF Validation API on {addr}"); tracing::info!("Swagger UI available at http://{addr}/openapi/"); diff --git a/csaf-service/src/settings.rs b/csaf-service/src/settings.rs index 651f9120..5a5389fe 100644 --- a/csaf-service/src/settings.rs +++ b/csaf-service/src/settings.rs @@ -1,5 +1,6 @@ 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 @@ -28,10 +29,23 @@ pub struct CorsSettings { 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 { @@ -74,4 +88,16 @@ impl Settings { 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 + }) + } } From f2c71cddb547e1a9078bcb71d151a616fb3ea535 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Mon, 27 Jul 2026 17:31:33 +0200 Subject: [PATCH 06/10] allow missing tests for now --- csaf-rs/Cargo.toml | 1 - csaf-service/src/handlers/v1/validate.rs | 18 +++++++++++------- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/csaf-rs/Cargo.toml b/csaf-rs/Cargo.toml index 04303ee3..8c884789 100644 --- a/csaf-rs/Cargo.toml +++ b/csaf-rs/Cargo.toml @@ -38,7 +38,6 @@ spdx = "0.13.4" oxilangtag = "0.1.5" ssvc = "0.2.1" strum = { version = "0.28", features = ["derive"] } -ssvc = "0.1.0" thiserror = { version = "2" } utoipa = { version = "5", optional = true } diff --git a/csaf-service/src/handlers/v1/validate.rs b/csaf-service/src/handlers/v1/validate.rs index d0cde5e8..4fa56a2f 100644 --- a/csaf-service/src/handlers/v1/validate.rs +++ b/csaf-service/src/handlers/v1/validate.rs @@ -119,6 +119,9 @@ fn from_potential_legacy_name(name: &str) -> String { "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('_', "."); @@ -238,13 +241,14 @@ pub(crate) async fn validate( }, }; - 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:?}"), - )); - } + // 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))) } From 00c47393eb7f292d5aeb2ff64f85adae66a65183 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Fri, 28 Aug 2026 12:20:17 +0200 Subject: [PATCH 07/10] add explanation for legacy conversion and corrected examples for validate --- csaf-service/src/handlers/v1/validate.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/csaf-service/src/handlers/v1/validate.rs b/csaf-service/src/handlers/v1/validate.rs index 4fa56a2f..679d070f 100644 --- a/csaf-service/src/handlers/v1/validate.rs +++ b/csaf-service/src/handlers/v1/validate.rs @@ -61,6 +61,9 @@ fn to_legacy_response(result: ValidationResult) -> ValidateResponse { .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![]), @@ -153,15 +156,15 @@ fn from_potential_legacy_name(name: &str) -> String { examples( ("Validate with a single test" = ( // summary = "Validate with a single test", - value = json!({"tests": [{"type": "test", "name": "6.1.15"}], "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"}}}) + 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": {"category": "csaf_base", "csaf_version": "2.0"}}) + 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": {"category": "csaf_base", "csaf_version": "2.0"}}) + value = json!({"tests": [{"type": "preset", "name": "basic"}, {"type": "test", "name": "6.2.1"}], "document": {"document": {"category": "csaf_base", "csaf_version": "2.0"}}}) )) ) ), From 833069e213976f9fa1fab068fd1559173d257472 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Fri, 28 Aug 2026 16:55:15 +0200 Subject: [PATCH 08/10] pr comments --- csaf-rs/src/csaf2_0/validation.rs | 2 +- csaf-service/config/default.toml | 4 ++-- csaf-service/src/main.rs | 5 +++-- csaf-service/src/settings.rs | 10 +--------- 4 files changed, 7 insertions(+), 14 deletions(-) diff --git a/csaf-rs/src/csaf2_0/validation.rs b/csaf-rs/src/csaf2_0/validation.rs index b8540537..e16715dd 100644 --- a/csaf-rs/src/csaf2_0/validation.rs +++ b/csaf-rs/src/csaf2_0/validation.rs @@ -312,7 +312,7 @@ impl Validatable for CommonSecurityAdvisoryFramework { impl RawValidatable for RawDocument { fn run_raw_test(&self, test_id: &str) -> TestResult { - if test_id == PRESET_NAME_SCHEMA { + if test_id == Preset::Schema.as_str() { return to_test_result(test_id, Severity::Error, Some(validate_schema_csaf_2_0(self))); } diff --git a/csaf-service/config/default.toml b/csaf-service/config/default.toml index 0538932c..6df6bfb8 100644 --- a/csaf-service/config/default.toml +++ b/csaf-service/config/default.toml @@ -12,8 +12,8 @@ # 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 MB. Hard-capped at 150 MB as -# required by the CSAF standard, regardless of this setting. +# 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] diff --git a/csaf-service/src/main.rs b/csaf-service/src/main.rs index 350017bf..b55bea32 100644 --- a/csaf-service/src/main.rs +++ b/csaf-service/src/main.rs @@ -79,8 +79,9 @@ fn build_cors_layer(cors: &CorsSettings) -> CorsLayer { ), info( title = "CSAF Validation API", - version = "0.5.1", - description = "REST API for validating CSAF (Common Security Advisory Framework) documents against the OASIS CSAF standard. Uses csaf-rs under the hood." + 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; diff --git a/csaf-service/src/settings.rs b/csaf-service/src/settings.rs index 5a5389fe..6d525edf 100644 --- a/csaf-service/src/settings.rs +++ b/csaf-service/src/settings.rs @@ -8,11 +8,6 @@ use tracing::Level; /// source of truth for these values and is kept in sync via `include_str!`. const DEFAULT_CONFIG: &str = include_str!("../config/default.toml"); -/// Hard safety ceiling for request body size, as required by the CSAF -/// standard. `server.body_limit_mb` is clamped to this value regardless of -/// what is configured. -pub const MAX_BODY_LIMIT_BYTES: usize = 150 * 1024 * 1024; // 150 MB - #[derive(Debug, Deserialize, Clone)] pub struct ServerSettings { pub host: String, @@ -79,10 +74,7 @@ impl Settings { } pub fn body_limit_bytes(&self) -> usize { - self.server - .body_limit_mb - .saturating_mul(1024 * 1024) - .min(MAX_BODY_LIMIT_BYTES) + self.server.body_limit_mb.saturating_mul(1024 * 1024) } pub fn addr(&self) -> String { From a0bf1d9c1f7edcf2e244d7d746384d5aefff6888 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Fri, 28 Aug 2026 17:15:13 +0200 Subject: [PATCH 09/10] cleanup --- Cargo.lock | 24 ++++++++++++------------ csaf | 2 +- csaf-rs/src/csaf/raw.rs | 2 +- csaf-rs/src/csaf2_0/validation.rs | 4 ++-- csaf-rs/src/csaf2_1/validation.rs | 4 ++-- csaf-rs/src/validation.rs | 1 + 6 files changed, 19 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a834730..2277acc9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -618,7 +618,7 @@ dependencies = [ "strum", "symspell", "tempfile", - "thiserror 2.0.19", + "thiserror 2.0.20", "utoipa", "uuid", ] @@ -707,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" @@ -738,17 +749,6 @@ dependencies = [ "syn 2.0.117", ] -[[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 = "digest" version = "0.10.7" diff --git a/csaf b/csaf index 27af37f5..1a28d6b2 160000 --- a/csaf +++ b/csaf @@ -1 +1 @@ -Subproject commit 27af37f5c9a107abb166e855fa6505fc4ffaade8 +Subproject commit 1a28d6b2d29265f4a034e38422204b2c042d9ea8 diff --git a/csaf-rs/src/csaf/raw.rs b/csaf-rs/src/csaf/raw.rs index 491c9018..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, CsafError, TestResult, TestResultStatus, Validatable}; +use crate::validation::{CsafError, TestFinding, TestResult, TestResultStatus, Validatable}; #[derive(Clone, Debug, Eq, PartialEq)] pub struct RawDocument { diff --git a/csaf-rs/src/csaf2_0/validation.rs b/csaf-rs/src/csaf2_0/validation.rs index 7dea1ff3..09565a53 100644 --- a/csaf-rs/src/csaf2_0/validation.rs +++ b/csaf-rs/src/csaf2_0/validation.rs @@ -6,7 +6,7 @@ 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, CsafError, 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 { @@ -281,7 +281,7 @@ impl RawValidatable for RawDocument { to_test_result( test_id, match test_id { - Preset::Schema.as_str() => 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 8ef5663b..51dd80a0 100644 --- a/csaf-rs/src/csaf2_1/validation.rs +++ b/csaf-rs/src/csaf2_1/validation.rs @@ -6,7 +6,7 @@ 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, CsafError, 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 { @@ -403,7 +403,7 @@ impl RawValidatable for RawDocument { to_test_result( test_id, match test_id { - Preset::Schema.as_str() => 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/validation.rs b/csaf-rs/src/validation.rs index 3a436bb8..79c6cdbc 100644 --- a/csaf-rs/src/validation.rs +++ b/csaf-rs/src/validation.rs @@ -48,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, From 0b25e35d8bf9dba943fb1a2382cec7b0eba6d522 Mon Sep 17 00:00:00 2001 From: Thomas Ziemek Date: Fri, 28 Aug 2026 17:30:37 +0200 Subject: [PATCH 10/10] update csaf --- csaf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/csaf b/csaf index 1a28d6b2..27af37f5 160000 --- a/csaf +++ b/csaf @@ -1 +1 @@ -Subproject commit 1a28d6b2d29265f4a034e38422204b2c042d9ea8 +Subproject commit 27af37f5c9a107abb166e855fa6505fc4ffaade8