From 4bc736df6d2222dc63a9d745e718a266dbfdd116 Mon Sep 17 00:00:00 2001 From: Patrick Elsen Date: Wed, 11 Oct 2023 15:31:18 +0200 Subject: [PATCH] Adds validation support to `buffrs`. For the time being, this will only be used to build informative lints. --- Cargo.lock | 346 ++++++++++++++++++++++++--- Cargo.toml | 15 +- src/command.rs | 14 ++ src/lib.rs | 3 + src/main.rs | 23 ++ src/package.rs | 23 +- src/validation.rs | 34 +++ src/validation/data.rs | 41 ++++ src/validation/data/entity.rs | 24 ++ src/validation/data/enum.rs | 64 +++++ src/validation/data/message.rs | 170 +++++++++++++ src/validation/data/package.rs | 109 +++++++++ src/validation/data/packages.rs | 74 ++++++ src/validation/data/service.rs | 9 + src/validation/diff.rs | 22 ++ src/validation/diff/entity.rs | 27 +++ src/validation/diff/package.rs | 60 +++++ src/validation/diff/packages.rs | 52 ++++ src/validation/parse.rs | 57 +++++ src/validation/rules.rs | 110 +++++++++ src/validation/rules/file_name.rs | 129 ++++++++++ src/validation/rules/ident_casing.rs | 35 +++ src/validation/rules/package_name.rs | 116 +++++++++ src/validation/serde.rs | 38 +++ src/validation/violation.rs | 144 +++++++++++ tests/data/parsing/addressbook.json | 56 +++++ tests/data/parsing/addressbook.proto | 29 +++ tests/data/parsing/books.json | 86 +++++++ tests/data/parsing/books.proto | 36 +++ tests/data/parsing/generate.sh | 5 + tests/validation.rs | 37 +++ 31 files changed, 1947 insertions(+), 41 deletions(-) create mode 100644 src/validation.rs create mode 100644 src/validation/data.rs create mode 100644 src/validation/data/entity.rs create mode 100644 src/validation/data/enum.rs create mode 100644 src/validation/data/message.rs create mode 100644 src/validation/data/package.rs create mode 100644 src/validation/data/packages.rs create mode 100644 src/validation/data/service.rs create mode 100644 src/validation/diff.rs create mode 100644 src/validation/diff/entity.rs create mode 100644 src/validation/diff/package.rs create mode 100644 src/validation/diff/packages.rs create mode 100644 src/validation/parse.rs create mode 100644 src/validation/rules.rs create mode 100644 src/validation/rules/file_name.rs create mode 100644 src/validation/rules/ident_casing.rs create mode 100644 src/validation/rules/package_name.rs create mode 100644 src/validation/serde.rs create mode 100644 src/validation/violation.rs create mode 100644 tests/data/parsing/addressbook.json create mode 100644 tests/data/parsing/addressbook.proto create mode 100644 tests/data/parsing/books.json create mode 100644 tests/data/parsing/books.proto create mode 100755 tests/data/parsing/generate.sh create mode 100644 tests/validation.rs diff --git a/Cargo.lock b/Cargo.lock index 46a6b64d..4aa703bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -79,7 +79,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca11d4be1bab0c8bc8734a9aa7bf4ee8316d462a08c6ac5052f888fef5b494b" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -89,7 +89,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0699d10d2f4d628a98ee7b57b289abbc98ff3bad977cb3152709d4bf2330628" dependencies = [ "anstyle", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -105,7 +105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88903cb14723e4d4003335bb7f8a14f27691649105346a0f0957466c096adfe6" dependencies = [ "anstyle", - "bstr", + "bstr 1.7.0", "doc-comment", "predicates", "predicates-core", @@ -292,6 +292,17 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bstr" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3569f383e8f1598449f1a423e72e99569137b47740b1da11ef19af3d5c3223" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata 0.1.10", +] + [[package]] name = "bstr" version = "1.7.0" @@ -299,7 +310,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c79ad7fb2dd38f3dabd76b09c6a5a20c038fc0213ef1e9afd30eb777f120f019" dependencies = [ "memchr", - "regex-automata", + "regex-automata 0.4.3", "serde", ] @@ -307,12 +318,15 @@ dependencies = [ name = "buffrs" version = "0.6.4" dependencies = [ + "anyhow", "assert_cmd", "assert_fs", "async-recursion", "async-trait", "bytes", "clap", + "derive_more", + "diff-struct", "flate2", "fs_extra", "futures", @@ -321,14 +335,19 @@ dependencies = [ "home", "human-panic", "miette", + "paste", "predicates", "pretty_assertions", + "protobuf", + "protobuf-parse", "protoc-bin-vendored", "reqwest", "ring", "semver", "serde", + "serde_json", "serde_typename", + "similar-asserts", "tar", "thiserror", "tokio", @@ -436,12 +455,30 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" +[[package]] +name = "console" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c926e00cc70edefdc64d3a5ff31cc65bb97a3460097762bd23afb4d8145fccf8" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "windows-sys 0.45.0", +] + [[package]] name = "const-oid" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28c122c3980598d243d63d9a704629a2d748d101f278052ff068be5a4423ab6f" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "core-foundation" version = "0.9.3" @@ -531,12 +568,47 @@ dependencies = [ "zeroize", ] +[[package]] +name = "derive_more" +version = "0.99.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 1.0.109", +] + [[package]] name = "diff" version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "diff-struct" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79aac083112b31f7cb768b24b893dc0c34c296a4b06b250c407bfd495e42075c" +dependencies = [ + "diff_derive", + "num", + "serde", +] + +[[package]] +name = "diff_derive" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe165e7ead196bbbf44c7ce11a7a21157b5c002ce46d7098ff9c556784a4912d" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "difflib" version = "0.4.0" @@ -576,6 +648,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.33" @@ -598,7 +676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3e13f66a2f95e32a39eaa81f6b95d42878ca0e1db0c7543723dfe12557e860" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -609,7 +687,7 @@ checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" dependencies = [ "cfg-if", "home", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -642,8 +720,8 @@ checksum = "d4029edd3e734da6fe05b6cd7bd2960760a616bd2ddd0d59a0124746d6272af0" dependencies = [ "cfg-if", "libc", - "redox_syscall", - "windows-sys", + "redox_syscall 0.3.5", + "windows-sys 0.48.0", ] [[package]] @@ -858,7 +936,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "759c97c1e17c55525b57192c06a267cda0ac5210b222d6b82189a2338fa1c13d" dependencies = [ "aho-corasick", - "bstr", + "bstr 1.7.0", "fnv", "log", "regex", @@ -964,7 +1042,7 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5444c27eef6923071f7ebcc33e3444508466a76f7a2b93da00ed6e19f30c1ddb" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1134,7 +1212,7 @@ checksum = "cb0889898416213fab133e1d33a0e5858a48177452750691bde3666d0fdbaf8b" dependencies = [ "hermit-abi", "rustix", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1256,9 +1334,9 @@ checksum = "da2479e8c062e40bf0066ffa0bc823de0a9368974af99c9f6df941d2c231e03f" [[package]] name = "lock_api" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1cc9717a20b1bb222f333e6a92fd32f7d8a18ddc5a3191a11af45dcbf4dcd16" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" dependencies = [ "autocfg", "scopeguard", @@ -1353,7 +1431,7 @@ checksum = "927a765cd3fc26206e66b296465fa9d3e5ab003e651c1b3c060e7956d96b19d2" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -1388,6 +1466,31 @@ dependencies = [ "winapi", ] +[[package]] +name = "num" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1405,6 +1508,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ba157ca0885411de85d6ca030ba7e2a83a28636056c7c699b07c8b6f7383214" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.45" @@ -1426,6 +1538,18 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.17" @@ -1514,15 +1638,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93f00c865fe7cabf650081affecd3871070f26767e7b2070a3ffae14c654b447" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.4.1", "smallvec", - "windows-targets", + "windows-targets 0.48.5", ] [[package]] @@ -1735,6 +1859,42 @@ dependencies = [ "prost", ] +[[package]] +name = "protobuf" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b65f4a8ec18723a734e5dc09c173e0abf9690432da5340285d536edcb4dac190" +dependencies = [ + "once_cell", + "protobuf-support", + "thiserror", +] + +[[package]] +name = "protobuf-parse" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77d6fbd6697c9e531873e81cec565a85e226b99a0f10e1acc079be057fe2fcba" +dependencies = [ + "anyhow", + "indexmap 1.9.3", + "log", + "protobuf", + "protobuf-support", + "tempfile", + "thiserror", + "which", +] + +[[package]] +name = "protobuf-support" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6872f4d4f4b98303239a2b5838f5bbbb77b01ffc892d627957f37a22d7cfe69c" +dependencies = [ + "thiserror", +] + [[package]] name = "protoc-bin-vendored" version = "3.0.0" @@ -1833,23 +1993,38 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "regex" -version = "1.10.1" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaac441002f822bc9705a681810a4dd2963094b9ca0ddc41cb963a4c189189ea" +checksum = "380b951a9c5e80ddfd6136919eef32310721aa4aacd4889a8d39124b026ab343" dependencies = [ "aho-corasick", "memchr", - "regex-automata", + "regex-automata 0.4.3", "regex-syntax", ] [[package]] name = "regex-automata" -version = "0.4.2" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + +[[package]] +name = "regex-automata" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5011c7e263a695dc8ca064cddb722af1be54e517a280b12a5356f98366899e5d" +checksum = "5f804c7828047e88b2d32e2d7fe5a105da8ee3264f01902f796c8e067dc2483f" dependencies = [ "aho-corasick", "memchr", @@ -1945,6 +2120,15 @@ version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" +[[package]] +name = "rustc_version" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa0f585226d2e68097d4f95d113b15b83a82e819ab25717ec0590d9584ef366" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.19" @@ -1955,7 +2139,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -2028,7 +2212,7 @@ version = "0.1.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c3733bf4cf7ea0880754e19cb5a462007c4a8c1914bff372ccc95b464f1df88" dependencies = [ - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -2190,6 +2374,26 @@ dependencies = [ "rand_core", ] +[[package]] +name = "similar" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2aeaf503862c419d66959f5d7ca015337d864e9c49485d771b732e2a20453597" +dependencies = [ + "bstr 0.2.17", + "unicode-segmentation", +] + +[[package]] +name = "similar-asserts" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e041bb827d1bfca18f213411d51b665309f1afb37a04a5d1464530e13779fc0f" +dependencies = [ + "console", + "similar", +] + [[package]] name = "slab" version = "0.4.9" @@ -2228,7 +2432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -2581,9 +2785,9 @@ checksum = "cb94d2f3cc536af71caac6b6fcebf65860b347e7ce0cc9ebe8f70d3e521054ef" dependencies = [ "cfg-if", "fastrand", - "redox_syscall", + "redox_syscall 0.3.5", "rustix", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -2675,7 +2879,7 @@ dependencies = [ "socket2 0.5.4", "tokio-macros", "tracing", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] @@ -3190,13 +3394,37 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", ] [[package]] @@ -3205,51 +3433,93 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3272,7 +3542,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" dependencies = [ "cfg-if", - "windows-sys", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7c46063b..8a88e210 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,8 +22,9 @@ path = "tests/lib.rs" test = true [features] -default = ["build", "git"] +default = ["build", "git", "validation"] build = ["tonic-build", "protoc-bin-vendored"] +validation = ["dep:protobuf", "dep:protobuf-parse", "dep:diff-struct"] git = ["git2"] [dependencies] @@ -52,6 +53,11 @@ walkdir = "2" async-recursion = "1.0.5" thiserror = "1.0.49" miette = { version = "5.10.0", features = ["fancy"] } +protobuf = { version = "3.3.0", optional = true } +protobuf-parse = { version = "3.3.0", optional = true } +diff-struct = { version = "0.5.3", optional = true } +derive_more = "0.99.17" +anyhow = "1.0.75" [dependencies.reqwest] version = "0.11" @@ -66,6 +72,13 @@ predicates = "3.0" pretty_assertions = "1.4" ring = "0.16.20" hex = "0.4.3" +similar-asserts = "1.5.0" +serde_json = { version = "1.0.107" } +paste = "1.0.14" + +[[test]] +name = "validation" +required-features = ["validation"] [workspace] members = [".", "registry"] diff --git a/src/command.rs b/src/command.rs index bb229871..5a1a6119 100644 --- a/src/command.rs +++ b/src/command.rs @@ -290,6 +290,20 @@ impl Context { Lockfile::from_iter(locked.into_iter()).write().await } + /// Parses current package and validates rules. + #[cfg(feature = "validation")] + pub async fn lint(self: Arc) -> miette::Result<()> { + let manifest = Manifest::read().await?; + let violations = self.store().validate(&manifest).await?; + + for violation in violations.into_iter() { + let report = miette::Report::new(violation); + println!("{report:?}"); + } + + Ok(()) + } + /// Uninstalls dependencies pub async fn uninstall(self: Arc) -> miette::Result<()> { PackageStore::current().await?.clear().await diff --git a/src/lib.rs b/src/lib.rs index a734d062..a57f55b1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -42,6 +42,9 @@ pub mod package; pub mod registry; /// Resolve package dependencies. pub mod resolver; +/// Validation for buffrs packages. +#[cfg(feature = "validation")] +pub mod validation; /// Cargo build integration for buffrs /// diff --git a/src/main.rs b/src/main.rs index eaabaa12..29e83701 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,6 +46,21 @@ enum Command { package: Option, }, + /// Check rule violations for this package. + Lint { + /// Allow these rules to be violated. + #[clap(long, short)] + allow: Vec, + + /// Treat these rule violations as errors. + #[clap(long, short)] + deny: Vec, + + /// Treat these rule violations as warnings. + #[clap(long, short)] + warn: Vec, + }, + /// Adds dependencies to a manifest file Add { /// Artifactory url (e.g. https:///artifactory) @@ -196,6 +211,14 @@ async fn main() -> miette::Result<()> { .publish(registry, repository, allow_dirty, dry_run) .await .wrap_err(miette!("publish command failed")), + Command::Lint { + allow: _, + warn: _, + deny: _, + } => context + .lint() + .await + .wrap_err(miette!("lint command failed")), Command::Install => context .install() .await diff --git a/src/package.rs b/src/package.rs index ce5fde24..365d4c5e 100644 --- a/src/package.rs +++ b/src/package.rs @@ -175,6 +175,24 @@ impl PackageStore { .wrap_err(miette!("failed to resolve package {package}")) } + /// Validate this package + #[cfg(feature = "validation")] + pub async fn validate( + &self, + manifest: &Manifest, + ) -> miette::Result { + let pkg_path = self.proto_path(); + let source_files = self.collect(&pkg_path).await; + + let mut parser = crate::validation::Parser::new(&pkg_path); + for file in &source_files { + parser.input(file); + } + let parsed = parser.parse().into_diagnostic()?; + let mut rule_set = crate::validation::rules::package_rules(&manifest.package.name); + Ok(parsed.check(&mut rule_set)) + } + /// Packages a release from the local file system state pub async fn release(&self, manifest: Manifest) -> miette::Result { ensure!( @@ -198,9 +216,10 @@ impl PackageStore { } let pkg_path = self.proto_path(); - let mut entries = BTreeMap::new(); + let source_files = self.collect(&pkg_path).await; - for entry in self.collect(&pkg_path).await { + let mut entries = BTreeMap::new(); + for entry in &source_files { let path = entry.strip_prefix(&pkg_path).into_diagnostic()?; let contents = tokio::fs::read(&entry).await.unwrap(); entries.insert(path.into(), contents.into()); diff --git a/src/validation.rs b/src/validation.rs new file mode 100644 index 00000000..6eb09664 --- /dev/null +++ b/src/validation.rs @@ -0,0 +1,34 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/// Parsed protocol buffer definitions. +pub mod data; + +/// Rules for protocol buffer definitions. +pub mod rules; + +mod parse; + +/// Serde utilities. +pub(crate) mod serde; + +mod violation; + +/// Rules for checking package differences. +pub mod diff; + +pub use self::{ + parse::{ParseError, Parser}, + violation::*, +}; diff --git a/src/validation/data.rs b/src/validation/data.rs new file mode 100644 index 00000000..d2713643 --- /dev/null +++ b/src/validation/data.rs @@ -0,0 +1,41 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::{ + collections::{btree_map::Entry, BTreeMap}, + path::PathBuf, +}; + +use diff::Diff; +use miette::Diagnostic; +use protobuf::descriptor::{ + field_descriptor_proto::{Label as FieldDescriptorLabel, Type as FieldDescriptorType}, + *, +}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::validation::{ + rules::{Rule, RuleSet}, + Violations, +}; + +mod entity; +mod r#enum; +mod message; +mod package; +mod packages; +mod service; + +pub use self::{entity::*, message::*, package::*, packages::*, r#enum::*, service::*}; diff --git a/src/validation/data/entity.rs b/src/validation/data/entity.rs new file mode 100644 index 00000000..c0d83f56 --- /dev/null +++ b/src/validation/data/entity.rs @@ -0,0 +1,24 @@ +use super::*; + +/// Entity that can be defined in a protocol buffer file. +#[derive(Serialize, Deserialize, Clone, Debug, derive_more::From, PartialEq, Eq, Diff)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub enum Entity { + /// Enumeration. + Enum(Enum), + /// Service definition. + Service(Service), + /// Message definition. + Message(Message), +} + +impl Entity { + /// Check [`Entity`] against [`RuleSet`] for [`Violations`]. + pub fn check(&self, _rules: &mut RuleSet) -> Violations { + Violations::default() + } +} diff --git a/src/validation/data/enum.rs b/src/validation/data/enum.rs new file mode 100644 index 00000000..a9bb5af7 --- /dev/null +++ b/src/validation/data/enum.rs @@ -0,0 +1,64 @@ +use super::*; + +/// Error parsing package. +#[derive(Error, Debug, Diagnostic)] +#[allow(missing_docs)] +pub enum EnumError { + #[error("missing value number")] + ValueNumberMissing, + + #[error("missing value name")] + ValueNameMissing, +} + +/// Enumeration definition. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct Enum { + /// Variants of this enum. + #[serde(deserialize_with = "crate::validation::serde::de_int_key")] + pub values: BTreeMap, +} + +impl Enum { + /// Attempt to create new from [`EnumDescriptorProto`]. + pub fn new(descriptor: &EnumDescriptorProto) -> Result { + let mut entity = Self::default(); + + for value in &descriptor.value { + entity.add(value)?; + } + + Ok(entity) + } + + /// Add an [`EnumValue`] to this enum definition. + pub fn add(&mut self, value: &EnumValueDescriptorProto) -> Result<(), EnumError> { + let number = value.number.ok_or(EnumError::ValueNumberMissing)?; + self.values.insert(number, EnumValue::new(value)?); + Ok(()) + } +} + +/// Single value for an [`Enum`]. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct EnumValue { + /// Name of this enum value. + pub name: String, +} + +impl EnumValue { + /// Attempt to create new from [`EnumValueDescriptorProto`]. + pub fn new(descriptor: &EnumValueDescriptorProto) -> Result { + Ok(Self { + name: descriptor.name.clone().ok_or(EnumError::ValueNameMissing)?, + }) + } +} diff --git a/src/validation/data/message.rs b/src/validation/data/message.rs new file mode 100644 index 00000000..4aa0324a --- /dev/null +++ b/src/validation/data/message.rs @@ -0,0 +1,170 @@ +use super::*; + +/// Error converting parsed protobuf fileset into custom representation. +#[derive(Error, Debug, Diagnostic)] +#[allow(missing_docs)] +pub enum MessageError { + #[error("field number missing")] + FieldNumberMissing, + + #[error("field name missing")] + FieldNameMissing, + + #[error("field type missing")] + FieldTypeMissing, +} + +/// Message definition. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct Message { + /// Fields defined in this message. + #[serde(deserialize_with = "crate::validation::serde::de_int_key")] + pub fields: BTreeMap, +} + +impl Message { + /// Try to create new [`Message`] from [`DescriptorProto`]. + pub fn new(descriptor: &DescriptorProto) -> Result { + let mut message = Message::default(); + + for field in &descriptor.field { + message.fields.insert( + field.number.ok_or(MessageError::FieldNumberMissing)?, + Field::new(field)?, + ); + } + + Ok(message) + } +} + +/// Field defined in this message. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct Field { + /// Name of field. + pub name: String, + /// Type of field. + pub type_: FieldType, + /// Label of field. + pub label: Option, + /// Default value. + pub default: Option, +} + +impl Field { + /// Try to create a new [`Field`] from a [`FieldDescriptorProto`]. + fn new(descriptor: &FieldDescriptorProto) -> Result { + Ok(Self { + name: descriptor + .name + .clone() + .ok_or(MessageError::FieldNameMissing)?, + type_: match descriptor + .type_ + .ok_or(MessageError::FieldTypeMissing)? + .enum_value() + { + Ok(value) => value.into(), + Err(number) => FieldType::Unknown(number), + }, + label: match descriptor.label.map(|label| label.enum_value()) { + None => None, + Some(Ok(label)) => Some(label.into()), + Some(Err(number)) => Some(FieldLabel::Unknown(number)), + }, + default: descriptor.default_value.clone(), + }) + } +} + +/// Built-in field types. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Diff)] +#[serde(rename_all = "snake_case")] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +#[allow(missing_docs)] +pub enum FieldType { + Double, + Float, + Int64, + Uint64, + Int32, + Fixed64, + Fixed32, + Bool, + String, + Group, + Message, + Bytes, + Uint32, + Enum, + Sfixed32, + Sfixed64, + Sint32, + Sint64, + Unknown(i32), +} + +impl From for FieldType { + fn from(type_: FieldDescriptorType) -> Self { + use FieldDescriptorType::*; + use FieldType::*; + match type_ { + TYPE_DOUBLE => Double, + TYPE_FLOAT => Float, + TYPE_INT64 => Int64, + TYPE_UINT64 => Uint64, + TYPE_INT32 => Int32, + TYPE_FIXED64 => Fixed64, + TYPE_FIXED32 => Fixed32, + TYPE_BOOL => Bool, + TYPE_STRING => String, + TYPE_GROUP => Group, + TYPE_MESSAGE => Message, + TYPE_BYTES => Bytes, + TYPE_UINT32 => Uint32, + TYPE_ENUM => Enum, + TYPE_SFIXED32 => Sfixed32, + TYPE_SFIXED64 => Sfixed64, + TYPE_SINT32 => Sint32, + TYPE_SINT64 => Sint64, + } + } +} + +/// Field label. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Diff)] +#[serde(rename_all = "snake_case")] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +#[allow(missing_docs)] +pub enum FieldLabel { + Optional, + Required, + Repeated, + Unknown(i32), +} + +impl From for FieldLabel { + fn from(label: FieldDescriptorLabel) -> Self { + use FieldDescriptorLabel::*; + use FieldLabel::*; + match label { + LABEL_OPTIONAL => Optional, + LABEL_REQUIRED => Required, + LABEL_REPEATED => Repeated, + } + } +} diff --git a/src/validation/data/package.rs b/src/validation/data/package.rs new file mode 100644 index 00000000..32a89b31 --- /dev/null +++ b/src/validation/data/package.rs @@ -0,0 +1,109 @@ +use super::*; + +/// Protocol buffer package. +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct Package { + /// Name of the package. + pub name: String, + /// File path where this package is defined. + pub file: PathBuf, + /// Entities defined in this package. + pub entities: BTreeMap, +} + +/// Error parsing package. +#[derive(Error, Debug, Diagnostic)] +#[allow(missing_docs)] +pub enum PackageError { + #[error("duplicate entity {entity} in file {file}")] + #[diagnostic( + help = "check to make sure your don't define two entities of the same name", + code = "duplicate_entity" + )] + DuplicateEntity { file: PathBuf, entity: String }, + + #[error("error parsing message {name}")] + Message { + name: String, + #[source] + #[diagnostic_source] + error: MessageError, + }, + + #[error("error parsing enum {name}")] + Enum { + name: String, + #[source] + #[diagnostic_source] + error: EnumError, + }, +} + +impl Package { + /// Try to create a new one from a [`FileDescriptorProto`]. + pub fn new(descriptor: &FileDescriptorProto) -> Result { + let mut package = Self { + file: descriptor.name().into(), + name: descriptor.package().to_string(), + entities: Default::default(), + }; + + for message in &descriptor.message_type { + package.add_entity( + message.name(), + Message::new(message).map_err(|error| PackageError::Message { + name: message.name().into(), + error, + })?, + )?; + } + + for entity in &descriptor.enum_type { + package.add_entity( + entity.name(), + Enum::new(entity).map_err(|error| PackageError::Enum { + name: entity.name().into(), + error, + })?, + )?; + } + + for entity in &descriptor.service { + package.add_entity(entity.name(), Service {})?; + } + + Ok(package) + } + + /// Try to add an entity. + fn add_entity>(&mut self, name: &str, entity: T) -> Result<(), PackageError> { + match self.entities.entry(name.into()) { + Entry::Vacant(entry) => { + entry.insert(entity.into()); + Ok(()) + } + Entry::Occupied(_entry) => Err(PackageError::DuplicateEntity { + file: self.file.clone(), + entity: name.into(), + }), + } + } + + /// Check this [`Package`] against a [`RuleSet`] for violations. + pub fn check(&self, rules: &mut RuleSet) -> Violations { + let mut violations = rules.check_package(self); + for (name, entity) in self.entities.iter() { + violations.append(&mut rules.check_entity(name, entity)); + violations.append(&mut entity.check(rules)); + } + for violation in &mut violations { + violation.location.file = Some(self.file.display().to_string()); + violation.location.package = Some(self.name.clone()); + } + violations + } +} diff --git a/src/validation/data/packages.rs b/src/validation/data/packages.rs new file mode 100644 index 00000000..e2e0e744 --- /dev/null +++ b/src/validation/data/packages.rs @@ -0,0 +1,74 @@ +use super::*; + +/// Packages that make up a protocol buffer package. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct Packages { + /// Packages defined in this protocol buffer package. + pub packages: BTreeMap, +} + +/// Error parsing packages. +#[derive(Error, Debug, Diagnostic)] +#[allow(missing_docs)] +pub enum PackagesError { + #[error("duplicate package {package}, defined in {previous} and {current}")] + #[diagnostic( + help = "check to make sure your files define different package names", + code = "duplicate_package" + )] + DuplicatePackage { + package: String, + current: PathBuf, + previous: PathBuf, + }, + + #[error("error parsing package {package} in {file}")] + Package { + package: String, + file: String, + #[source] + #[diagnostic_source] + error: PackageError, + }, +} + +impl Packages { + /// Add a package from a [`FileDescriptorProto`]. + pub fn add(&mut self, descriptor: &FileDescriptorProto) -> Result<(), PackagesError> { + let name = descriptor.package().to_string(); + let package = Package::new(descriptor).map_err(|error| PackagesError::Package { + package: descriptor.package().to_string(), + file: descriptor.name().to_string(), + error, + })?; + match self.packages.entry(name) { + Entry::Vacant(entry) => { + entry.insert(package); + Ok(()) + } + Entry::Occupied(entry) => Err(PackagesError::DuplicatePackage { + package: descriptor.package().to_string(), + previous: entry.get().file.clone(), + current: package.file.clone(), + }), + } + } + + /// Generate a diff between two parsed [`Packages`]. + pub fn diff(&self, other: &Self) -> ::Repr { + Diff::diff(self, other) + } + + /// Run checks against this. + pub fn check(&self, rules: &mut RuleSet) -> Violations { + let mut violations = rules.check_packages(self); + for package in self.packages.values() { + violations.append(&mut package.check(rules)); + } + violations + } +} diff --git a/src/validation/data/service.rs b/src/validation/data/service.rs new file mode 100644 index 00000000..c4611e50 --- /dev/null +++ b/src/validation/data/service.rs @@ -0,0 +1,9 @@ +use super::*; + +/// Service definition. +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq, Diff)] +#[diff(attr( + #[derive(Debug)] + #[allow(missing_docs)] +))] +pub struct Service {} diff --git a/src/validation/diff.rs b/src/validation/diff.rs new file mode 100644 index 00000000..99647f7c --- /dev/null +++ b/src/validation/diff.rs @@ -0,0 +1,22 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use crate::validation::data::*; +use thiserror::Error; + +mod entity; +mod package; +mod packages; + +pub use self::{entity::*, package::*, packages::*}; diff --git a/src/validation/diff/entity.rs b/src/validation/diff/entity.rs new file mode 100644 index 00000000..df4cac4f --- /dev/null +++ b/src/validation/diff/entity.rs @@ -0,0 +1,27 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Error in difference between package +#[derive(Error, Debug)] +#[allow(missing_docs)] +pub enum EntityDiffError {} + +impl EntityDiff { + /// Check entity difference for errors + pub fn check(&self) -> Vec { + vec![] + } +} diff --git a/src/validation/diff/package.rs b/src/validation/diff/package.rs new file mode 100644 index 00000000..af2fe679 --- /dev/null +++ b/src/validation/diff/package.rs @@ -0,0 +1,60 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; +use std::path::PathBuf; + +/// Error in difference between package +#[derive(Error, Debug)] +#[allow(missing_docs)] +pub enum PackageDiffError { + #[error("file path changed to {path:?}")] + PathChanged { path: PathBuf }, + + #[error("entity {name} removed")] + EntityRemoved { name: String }, + + #[error("error in entity {name}")] + Entity { + name: String, + #[source] + error: EntityDiffError, + }, +} + +impl PackageDiff { + /// Check package diff for errors + pub fn check(&self) -> Vec { + let mut errors = vec![]; + + if let Some(path) = &self.name { + errors.push(PackageDiffError::PathChanged { path: path.into() }); + } + + for name in self.entities.removed.iter() { + errors.push(PackageDiffError::EntityRemoved { name: name.into() }); + } + + for (name, entity) in self.entities.altered.iter() { + for error in entity.check().into_iter() { + errors.push(PackageDiffError::Entity { + name: name.into(), + error, + }); + } + } + + errors + } +} diff --git a/src/validation/diff/packages.rs b/src/validation/diff/packages.rs new file mode 100644 index 00000000..6c98b5c7 --- /dev/null +++ b/src/validation/diff/packages.rs @@ -0,0 +1,52 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Error in difference between packages. +#[derive(Error, Debug)] +#[allow(missing_docs)] +pub enum PackagesDiffError { + #[error("package {name} removed")] + Removed { name: String }, + + #[error("error in altered package {name}")] + Package { + name: String, + #[source] + error: PackageDiffError, + }, +} + +impl PackagesDiff { + /// Check packages diff for errors + pub fn check(&self) -> Vec { + let mut errors = vec![]; + + for name in self.packages.removed.iter() { + errors.push(PackagesDiffError::Removed { name: name.into() }); + } + + for (name, package) in self.packages.altered.iter() { + for error in package.check().into_iter() { + errors.push(PackagesDiffError::Package { + name: name.into(), + error, + }); + } + } + + errors + } +} diff --git a/src/validation/parse.rs b/src/validation/parse.rs new file mode 100644 index 00000000..0fbd13ae --- /dev/null +++ b/src/validation/parse.rs @@ -0,0 +1,57 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::path::Path; + +use crate::validation::data::{Packages, PackagesError}; + +/// Errors parsing `buffrs` packages. +#[derive(thiserror::Error, Debug)] +#[allow(missing_docs)] +pub enum ParseError { + #[error(transparent)] + Parse(#[from] anyhow::Error), + #[error(transparent)] + Adding(#[from] PackagesError), +} + +/// Parser for `buffrs` packages. +pub struct Parser { + parser: protobuf_parse::Parser, +} + +impl Parser { + /// Create new parser with a given root path. + pub fn new(root: &Path) -> Self { + let mut parser = protobuf_parse::Parser::new(); + parser.pure(); + parser.include(root); + Self { parser } + } + + /// Add file to be processed by this parser. + pub fn input(&mut self, file: &Path) { + self.parser.input(file); + } + + /// Parse into [`Packages`]. + pub fn parse(self) -> Result { + let fds = self.parser.file_descriptor_set()?; + let mut packages = Packages::default(); + for file in &fds.file { + packages.add(file)?; + } + Ok(packages) + } +} diff --git a/src/validation/rules.rs b/src/validation/rules.rs new file mode 100644 index 00000000..506baeef --- /dev/null +++ b/src/validation/rules.rs @@ -0,0 +1,110 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt::Debug; + +use crate::validation::{ + data::*, + violation::{self, *}, +}; + +mod file_name; +mod ident_casing; +mod package_name; + +pub use self::{file_name::*, ident_casing::*, package_name::*}; + +/// Collection of rules. +pub type RuleSet = Vec>; + +/// Rule to enforce for buffrs packages. +pub trait Rule: Debug { + /// Name of this rule. + /// + /// Defaults to the name of the type of this rule. + fn rule_name(&self) -> &'static str { + std::any::type_name::().split("::").last().unwrap() + } + + /// Help text for rule. + fn rule_info(&self) -> &'static str; + + /// Default severity [`Level`] of the rule. + fn rule_level(&self) -> Level { + Level::Error + } + + /// Turn a message into a violation. + fn to_violation(&self, message: violation::Message) -> Violation { + Violation { + rule: self.rule_name().into(), + level: self.rule_level(), + message, + location: Default::default(), + info: self.rule_info().into(), + } + } + + /// Check [`Packages`] for violations. + fn check_packages(&mut self, _packages: &Packages) -> Violations { + vec![] + } + + /// Check [`Package`] for violations. + fn check_package(&mut self, _package: &Package) -> Violations { + vec![] + } + + /// Check [`Entity`] for violations. + fn check_entity(&mut self, _name: &str, _entity: &Entity) -> Violations { + vec![] + } +} + +impl Rule for RuleSet { + fn rule_name(&self) -> &'static str { + "RuleSet" + } + + fn rule_info(&self) -> &'static str { + "RuleSet" + } + + fn check_packages(&mut self, packages: &Packages) -> Violations { + self.iter_mut() + .flat_map(|rule| rule.check_packages(packages)) + .collect() + } + + fn check_package(&mut self, package: &Package) -> Violations { + self.iter_mut() + .flat_map(|rule| rule.check_package(package)) + .collect() + } + + fn check_entity(&mut self, name: &str, entity: &Entity) -> Violations { + self.iter_mut() + .flat_map(|rule| rule.check_entity(name, entity)) + .collect() + } +} + +/// Get default rules for a given `buffrs` package name. +pub fn package_rules(name: &str) -> RuleSet { + vec![ + Box::new(PackageName::new(name)), + Box::new(FileName), + Box::new(IdentCasing), + ] +} diff --git a/src/validation/rules/file_name.rs b/src/validation/rules/file_name.rs new file mode 100644 index 00000000..de3af89d --- /dev/null +++ b/src/validation/rules/file_name.rs @@ -0,0 +1,129 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; +use std::path::PathBuf; + +/// Ensure that file names match package names. +/// +/// For example, a package named `physics` should be stored in `physics.proto`. A package named +/// `physics.rotation` should be stored in `physics/rotation.proto`. +#[derive(Debug, Clone, Default)] +pub struct FileName; + +fn file_name(package_name: &str) -> PathBuf { + format!("{}.proto", package_name.replace('.', "/")).into() +} + +impl Rule for FileName { + fn rule_info(&self) -> &'static str { + "making sure file names matches package names" + } + + fn check_package(&mut self, package: &Package) -> Violations { + let candidate = file_name(&package.name); + let correct = candidate == package.file; + if correct { + Violations::default() + } else { + let message = violation::Message { + message: format!( + "file name should be {candidate:?} but is {:?}", + package.file + ), + help: "Try to rename the file to align with the package name.".into(), + }; + vec![self.to_violation(message)] + } + } +} + +#[test] +fn file_name_package() { + assert_eq!(file_name("physics"), PathBuf::from("physics.proto")); + assert_eq!( + file_name("physics.rotation"), + PathBuf::from("physics/rotation.proto") + ); +} + +#[test] +fn correct_file_name() { + let package = Package { + name: "my_package".into(), + file: "my_package.proto".into(), + entities: Default::default(), + }; + let mut rule = FileName; + assert!(rule.check_package(&package).is_empty()); +} + +#[test] +fn correct_file_name_subpackage() { + let package = Package { + name: "my_package.subpackage".into(), + file: "my_package/subpackage.proto".into(), + entities: Default::default(), + }; + let mut rule = FileName; + assert!(rule.check_package(&package).is_empty()); +} + +#[test] +fn incorrect_file_name() { + let package = Package { + name: "my_package".into(), + file: "my_package_other.proto".into(), + entities: Default::default(), + }; + let mut rule = FileName; + assert_eq!( + rule.check_package(&package), + vec![Violation { + rule: "FileName".into(), + level: Level::Error, + location: Default::default(), + info: rule.rule_info().into(), + message: violation::Message { + message: + r#"file name should be "my_package.proto" but is "my_package_other.proto""# + .into(), + help: "Try to rename the file to align with the package name.".into(), + } + }] + ); +} + +#[test] +fn incorrect_file_name_subpackage() { + let package = Package { + name: "my_package.subpackage".into(), + file: "my_package/my_subpackage.proto".into(), + entities: Default::default(), + }; + let mut rule = FileName; + assert_eq!( + rule.check_package(&package), + vec![Violation { + rule: "FileName".into(), + level: Level::Error, + location: Default::default(), + info: rule.rule_info().into(), + message: violation::Message { + message: r#"file name should be "my_package/subpackage.proto" but is "my_package/my_subpackage.proto""#.into(), + help: "Try to rename the file to align with the package name.".into(), + } + }] + ); +} diff --git a/src/validation/rules/ident_casing.rs b/src/validation/rules/ident_casing.rs new file mode 100644 index 00000000..942d2b63 --- /dev/null +++ b/src/validation/rules/ident_casing.rs @@ -0,0 +1,35 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Ensure that file names match package names. +#[derive(Debug, Clone, Default)] +pub struct IdentCasing; + +impl Rule for IdentCasing { + fn rule_info(&self) -> &'static str { + "making sure entity names are correct" + } + + /// Default severity [`Level`] of the rule. + fn rule_level(&self) -> Level { + Level::Info + } + + /// Check [`Entity`] for violations. + fn check_entity(&mut self, _name: &str, _entity: &Entity) -> Violations { + vec![] + } +} diff --git a/src/validation/rules/package_name.rs b/src/validation/rules/package_name.rs new file mode 100644 index 00000000..7997da0b --- /dev/null +++ b/src/validation/rules/package_name.rs @@ -0,0 +1,116 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use super::*; + +/// Ensure that the protobuf package names match the buffrs package name. +#[derive(Debug, Clone)] +pub struct PackageName { + /// Package name to enforce. + name: String, +} + +impl PackageName { + /// Create new checker for this rule. + pub fn new(name: &str) -> Self { + Self { name: name.into() } + } +} + +fn is_prefix(prefix: &str, package: &str) -> bool { + prefix + .split('.') + .zip(package.split('.')) + .all(|(a, b)| a == b) +} + +impl Rule for PackageName { + fn rule_info(&self) -> &'static str { + "Make sure that the protobuf package name matches the buffer package name." + } + + fn check_package(&mut self, package: &Package) -> Violations { + if is_prefix(&self.name, &package.name) { + Violations::default() + } else { + let message = violation::Message { + message: format!("package name is {} but should have {} prefix", package.name, self.name), + help: "Make sure the file name matches the package. For example, a package with the name `package.subpackage` should be stored in `proto/package/subpackage.proto`.".into(), + }; + vec![self.to_violation(message)] + } + } +} + +#[test] +fn can_check_prefix() { + // any value is a prefix of itself + assert!(is_prefix("abc", "abc")); + + assert!(is_prefix("abc", "abc.def")); + assert!(is_prefix("abc", "abc.def.ghi")); +} + +#[test] +fn can_fail_wrong_prefix() { + assert!(!is_prefix("abc", "def")); + assert!(!is_prefix("abc", "abcdef")); + assert!(!is_prefix("abc", "")); + assert!(!is_prefix("abc", "ab")); +} + +#[test] +fn correct_package_name() { + let package = Package { + name: "my_package".into(), + file: "ignored.proto".into(), + entities: Default::default(), + }; + let mut rule = PackageName::new("my_package"); + assert!(rule.check_package(&package).is_empty()); +} + +#[test] +fn correct_package_name_submodule() { + let package = Package { + name: "my_package.submodule".into(), + file: "ignored.proto".into(), + entities: Default::default(), + }; + let mut rule = PackageName::new("my_package"); + assert!(rule.check_package(&package).is_empty()); +} + +#[test] +fn incorrect_package_name() { + let package = Package { + name: "my_package_other".into(), + file: "ignored.proto".into(), + entities: Default::default(), + }; + let mut rule = PackageName::new("my_package"); + assert_eq!( + rule.check_package(&package), + vec![Violation { + rule: "PackageName".into(), + level: Level::Error, + location: Default::default(), + info: rule.rule_info().into(), + message: violation::Message { + message: "package name is my_package_other but should have my_package prefix".into(), + help: "Make sure the file name matches the package. For example, a package with the name `package.subpackage` should be stored in `proto/package/subpackage.proto`.".into(), + } + }] + ); +} diff --git a/src/validation/serde.rs b/src/validation/serde.rs new file mode 100644 index 00000000..5b208f60 --- /dev/null +++ b/src/validation/serde.rs @@ -0,0 +1,38 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde::{de, Deserialize, Deserializer}; +use std::collections::BTreeMap; +use std::fmt::Display; +use std::str::FromStr; + +/// Allow deserializing an integer key from a string representation. +/// +/// This is needed because when serializing to JSON, serde will serialize maps with integer keys +/// as strings, but will not allow deserializing from this encoding. +pub(crate) fn de_int_key<'de, D, K, V>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + K: Eq + Ord + FromStr, + K::Err: Display, + V: Deserialize<'de>, +{ + let string_map = >::deserialize(deserializer)?; + let mut map = BTreeMap::default(); + for (s, v) in string_map { + let k = K::from_str(&s).map_err(de::Error::custom)?; + map.insert(k, v); + } + Ok(map) +} diff --git a/src/validation/violation.rs b/src/validation/violation.rs new file mode 100644 index 00000000..3e98d9ac --- /dev/null +++ b/src/validation/violation.rs @@ -0,0 +1,144 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use std::fmt::{self, Display, Formatter}; + +use miette::{ + Diagnostic, LabeledSpan, MietteError, MietteSpanContents, Severity, SourceCode, SourceSpan, + SpanContents, +}; + +/// Severity level of violation. +#[derive(Clone, Debug, Copy, PartialEq, Eq, PartialOrd, Ord)] +#[allow(missing_docs)] +pub enum Level { + Info, + Warning, + Error, +} + +/// Location of violation. +#[derive(Default, PartialEq, Clone, Eq, Debug)] +pub struct Location { + /// File that contains violation + pub file: Option, + /// Package name of file containing violation + pub package: Option, + /// Entity name containing the violation + pub entity: Option, +} + +/// Violation message. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Message { + /// Message describing violation + pub message: String, + /// Information on what went wrong + pub help: String, +} + +impl std::error::Error for Message {} + +impl Display for Message { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", &self.message) + } +} + +impl Diagnostic for Message {} + +/// Rule violation +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Violation { + /// Rule name that was violated + pub rule: String, + /// Level of violation + pub level: Level, + /// Message + pub message: Message, + /// Location where violation occured + pub location: Location, + /// Help text + pub info: String, +} + +/// Alias for list of [`Violation`]. +pub type Violations = Vec; + +impl std::error::Error for Violation {} + +impl Display for Violation { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!(f, "{}", &self.info) + } +} + +impl Diagnostic for Violation { + fn code<'a>(&'a self) -> Option> { + Some(Box::new(self.rule.split("::").last().unwrap_or(&self.rule))) + } + + fn url<'a>(&'a self) -> Option> { + Some(Box::new(format!( + "https://helsing-ai.github.io/buffrs/rules/{}", + self.rule + ))) + } + + fn severity(&self) -> Option { + let level = match self.level { + Level::Info => Severity::Advice, + Level::Warning => Severity::Warning, + Level::Error => Severity::Error, + }; + + Some(level) + } + + fn source_code(&self) -> Option<&dyn SourceCode> { + Some(&self.location) + } + + fn labels(&self) -> Option + '_>> { + Some(Box::new( + [LabeledSpan::new(Some("file".into()), 0, 0)].into_iter(), + )) + } + + fn diagnostic_source(&self) -> Option<&dyn Diagnostic> { + Some(&self.message) + } + + fn help<'a>(&'a self) -> Option> { + Some(Box::new(&self.message.help)) + } +} + +impl SourceCode for Location { + fn read_span<'a>( + &'a self, + span: &SourceSpan, + _context_lines_before: usize, + _context_lines_after: usize, + ) -> Result + 'a>, MietteError> { + Ok(Box::new(MietteSpanContents::new_named( + self.file.clone().unwrap_or_default(), + &[], + *span, + 0, + 0, + 0, + ))) + } +} diff --git a/tests/data/parsing/addressbook.json b/tests/data/parsing/addressbook.json new file mode 100644 index 00000000..836f9c0c --- /dev/null +++ b/tests/data/parsing/addressbook.json @@ -0,0 +1,56 @@ +{ + "packages": { + "tutorial": { + "name": "tutorial", + "file": "addressbook.proto", + "entities": { + "AddressBook": { + "kind": "message", + "fields": { + "1": { + "name": "people", + "type_": "message", + "label": "repeated", + "default": null + } + } + }, + "Person": { + "kind": "message", + "fields": { + "1": { + "name": "name", + "type_": "string", + "label": "optional", + "default": null + }, + "2": { + "name": "id", + "type_": "int32", + "label": "optional", + "default": null + }, + "3": { + "name": "email", + "type_": "string", + "label": "optional", + "default": null + }, + "4": { + "name": "phones", + "type_": "message", + "label": "repeated", + "default": null + }, + "5": { + "name": "last_updated", + "type_": "message", + "label": "optional", + "default": null + } + } + } + } + } + } +} diff --git a/tests/data/parsing/addressbook.proto b/tests/data/parsing/addressbook.proto new file mode 100644 index 00000000..e18393f5 --- /dev/null +++ b/tests/data/parsing/addressbook.proto @@ -0,0 +1,29 @@ +syntax = "proto3"; +package tutorial; + +import "google/protobuf/timestamp.proto"; + +message Person { + string name = 1; + int32 id = 2; + string email = 3; + + enum PhoneType { + MOBILE = 0; + HOME = 1; + WORK = 2; + } + + message PhoneNumber { + string number = 1; + PhoneType type = 2; + } + + repeated PhoneNumber phones = 4; + + google.protobuf.Timestamp last_updated = 5; +} + +message AddressBook { + repeated Person people = 1; +} diff --git a/tests/data/parsing/books.json b/tests/data/parsing/books.json new file mode 100644 index 00000000..7a85b029 --- /dev/null +++ b/tests/data/parsing/books.json @@ -0,0 +1,86 @@ +{ + "packages": { + "com.book": { + "name": "com.book", + "file": "books.proto", + "entities": { + "Book": { + "kind": "message", + "fields": { + "1": { + "name": "isbn", + "type_": "int64", + "label": "optional", + "default": null + }, + "2": { + "name": "title", + "type_": "string", + "label": "optional", + "default": null + }, + "3": { + "name": "author", + "type_": "string", + "label": "optional", + "default": null + } + } + }, + "BookService": { + "kind": "service" + }, + "BookStore": { + "kind": "message", + "fields": { + "1": { + "name": "name", + "type_": "string", + "label": "optional", + "default": null + }, + "2": { + "name": "books", + "type_": "message", + "label": "repeated", + "default": null + } + } + }, + "EnumSample": { + "kind": "enum", + "values": { + "0": { + "name": "UNKNOWN" + }, + "1": { + "name": "RUNNING" + } + } + }, + "GetBookRequest": { + "kind": "message", + "fields": { + "1": { + "name": "isbn", + "type_": "int64", + "label": "optional", + "default": null + } + } + }, + "GetBookViaAuthor": { + "kind": "message", + "fields": { + "1": { + "name": "author", + "type_": "string", + "label": "optional", + "default": null + } + } + } + } + } + } +} diff --git a/tests/data/parsing/books.proto b/tests/data/parsing/books.proto new file mode 100644 index 00000000..fc647b04 --- /dev/null +++ b/tests/data/parsing/books.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package com.book; + +message Book { + int64 isbn = 1; + string title = 2; + string author = 3; +} + +message GetBookRequest { + int64 isbn = 1; +} + +message GetBookViaAuthor { + string author = 1; +} + +service BookService { + rpc GetBook (GetBookRequest) returns (Book) {} + rpc GetBooksViaAuthor (GetBookViaAuthor) returns (stream Book) {} + rpc GetGreatestBook (stream GetBookRequest) returns (Book) {} + rpc GetBooks (stream GetBookRequest) returns (stream Book) {} +} + +message BookStore { + string name = 1; + map books = 2; +} + +enum EnumSample { + option allow_alias = true; + UNKNOWN = 0; + STARTED = 1; + RUNNING = 1; +} diff --git a/tests/data/parsing/generate.sh b/tests/data/parsing/generate.sh new file mode 100755 index 00000000..62ae9a3e --- /dev/null +++ b/tests/data/parsing/generate.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +for file in *.proto; do + cargo run --features tools --bin parse -- --format json "$file" | jq | tee $(basename "$file" .proto).json +done diff --git a/tests/validation.rs b/tests/validation.rs new file mode 100644 index 00000000..d6fe0010 --- /dev/null +++ b/tests/validation.rs @@ -0,0 +1,37 @@ +// Copyright 2023 Helsing GmbH +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use buffrs::validation::*; +use paste::paste; + +macro_rules! parse_test { + ($name:ident) => { + paste! { + #[test] + fn [< can_parse_ $name >]() { + use std::path::Path; + let mut parser = Parser::new(Path::new("tests/data/parsing")); + parser.input(std::path::Path::new(concat!("tests/data/parsing/", stringify!($name), ".proto"))); + let packages = parser.parse().unwrap(); + let parsed_file = concat!("tests/data/parsing/", stringify!($name), ".json"); + let expected = std::fs::read_to_string(parsed_file).unwrap(); + let expected = serde_json::from_str(&expected).unwrap(); + similar_asserts::assert_eq!(packages, expected); + } + } + }; +} + +parse_test!(books); +parse_test!(addressbook);