feluda-1.11.1/.cargo_vcs_info.json0000644000000001360000000000100123550ustar { "git": { "sha1": "f5c04d581ff491eb814cae7a2207a5c583177f64" }, "path_in_vcs": "" }feluda-1.11.1/Cargo.lock0000644000003242650000000000100103440ustar # This file is automatically @generated by Cargo. # It is not intended for manual editing. version = 4 [[package]] name = "addr2line" version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" dependencies = [ "gimli", ] [[package]] name = "adler2" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] [[package]] name = "allocator-api2" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" dependencies = [ "libc", ] [[package]] name = "anstream" version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", "anstyle-query", "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", ] [[package]] name = "anstyle" version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", "windows-sys 0.61.2", ] [[package]] name = "anyhow" version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] name = "atomic" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" dependencies = [ "bytemuck", ] [[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 = "aws-lc-rs" version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" dependencies = [ "aws-lc-sys", "zeroize", ] [[package]] name = "aws-lc-sys" version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" dependencies = [ "cc", "cmake", "dunce", "fs_extra", ] [[package]] name = "backtrace" version = "0.3.76" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", "windows-link", ] [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bit-set" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" [[package]] name = "block-buffer" version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" dependencies = [ "generic-array", ] [[package]] name = "bstr" version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ "memchr", "serde", ] [[package]] name = "bumpalo" version = "3.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" [[package]] name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "bytes" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" [[package]] name = "camino" version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" dependencies = [ "serde_core", ] [[package]] name = "cargo-platform" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" dependencies = [ "serde", "serde_core", ] [[package]] name = "cargo_metadata" version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" dependencies = [ "camino", "cargo-platform", "semver", "serde", "serde_json", "thiserror 2.0.17", ] [[package]] name = "castaway" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" dependencies = [ "rustversion", ] [[package]] name = "cc" version = "1.2.52" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" dependencies = [ "find-msvc-tools", "jobserver", "libc", "shlex", ] [[package]] name = "cesu8" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", "js-sys", "num-traits", "serde", "wasm-bindgen", "windows-link", ] [[package]] name = "clap" version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394" dependencies = [ "clap_builder", "clap_derive", ] [[package]] name = "clap_builder" version = "4.5.54" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00" dependencies = [ "anstream", "anstyle", "clap_lex", "strsim", ] [[package]] name = "clap_derive" version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "clap_lex" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "cmake" version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] [[package]] name = "color-eyre" version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" dependencies = [ "backtrace", "eyre", "indenter", "once_cell", "owo-colors", ] [[package]] name = "color-spantrace" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" dependencies = [ "once_cell", "owo-colors", "tracing-core", "tracing-error", ] [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" [[package]] name = "colored" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" dependencies = [ "windows-sys 0.59.0", ] [[package]] name = "combine" version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", "memchr", ] [[package]] name = "compact_str" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" dependencies = [ "castaway", "cfg-if", "itoa", "rustversion", "ryu", "static_assertions", ] [[package]] name = "convert_case" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] [[package]] name = "core-foundation" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "cpufeatures" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ "libc", ] [[package]] name = "crossbeam-deque" version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" dependencies = [ "crossbeam-epoch", "crossbeam-utils", ] [[package]] name = "crossbeam-epoch" version = "0.9.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ "bitflags 2.10.0", "crossterm_winapi", "derive_more", "document-features", "mio", "parking_lot", "rustix", "signal-hook", "signal-hook-mio", "winapi", ] [[package]] name = "crossterm_winapi" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" dependencies = [ "winapi", ] [[package]] name = "crypto-common" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "typenum", ] [[package]] name = "csscolorparser" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" dependencies = [ "lab", "phf 0.11.3", ] [[package]] name = "cssparser" version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" dependencies = [ "cssparser-macros", "dtoa-short", "itoa", "phf 0.13.1", "smallvec", ] [[package]] name = "cssparser-macros" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", "syn 2.0.114", ] [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", ] [[package]] name = "darling_core" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", "syn 2.0.114", ] [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", "syn 2.0.114", ] [[package]] name = "deltae" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" [[package]] name = "deranged" version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] [[package]] name = "derive_more" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case", "proc-macro2", "quote", "rustc_version", "syn 2.0.114", ] [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", ] [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ "dirs-sys", ] [[package]] name = "dirs-sys" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", "redox_users", "windows-sys 0.61.2", ] [[package]] name = "displaydoc" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "document-features" version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] [[package]] name = "downcast" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1435fa1053d8b2fbbe9be7e97eca7f33d37b28409959813daefc1446a14247f1" [[package]] name = "dtoa" version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dtoa-short" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" dependencies = [ "dtoa", ] [[package]] name = "dunce" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "ego-tree" version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2972feb8dffe7bc8c5463b1dacda1b0dfbed3710e50f977d965429692d74cd8" [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", "windows-sys 0.61.2", ] [[package]] name = "euclid" version = "0.22.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad9cdb4b747e485a12abb0e6566612956c7a1bafa3bdb8d682c5b6d403589e48" dependencies = [ "num-traits", ] [[package]] name = "eyre" version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" dependencies = [ "indenter", "once_cell", ] [[package]] name = "fancy-regex" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ "bit-set", "regex", ] [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" [[package]] name = "feluda" version = "1.11.1" dependencies = [ "backtrace", "cargo_metadata", "chrono", "clap", "color-eyre", "color-spantrace", "colored", "dirs", "figment", "git2", "http", "ignore", "libc", "mockall", "owo-colors", "ratatui", "rayon", "regex", "reqwest", "scraper", "semver", "serde", "serde_json", "serde_yaml", "serial_test", "spinners", "temp-env", "tempfile", "thiserror 2.0.17", "tokio", "toml 0.9.11+spec-1.1.0", "tracing", "tracing-subscriber", "unicode-width", "uuid", ] [[package]] name = "figment" version = "0.10.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" dependencies = [ "atomic", "pear", "serde", "toml 0.8.23", "uncased", "version_check", ] [[package]] name = "filedescriptor" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" dependencies = [ "libc", "thiserror 1.0.69", "winapi", ] [[package]] name = "find-msvc-tools" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" [[package]] name = "finl_unicode" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" [[package]] name = "fixedbitset" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" 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 = "fragile" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" [[package]] name = "fs_extra" version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futf" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" dependencies = [ "mac", "new_debug_unreachable", ] [[package]] name = "futures-channel" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", ] [[package]] name = "futures-core" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", "futures-util", ] [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" [[package]] name = "futures-sink" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" [[package]] name = "futures-util" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", "futures-io", "futures-sink", "futures-task", "memchr", "pin-project-lite", "pin-utils", "slab", ] [[package]] name = "generic-array" version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", ] [[package]] name = "getopts" version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" dependencies = [ "unicode-width", ] [[package]] name = "getrandom" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" dependencies = [ "cfg-if", "js-sys", "libc", "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", "wasip2", "wasm-bindgen", ] [[package]] name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "git2" version = "0.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2b37e2f62729cdada11f0e6b3b6fe383c69c29fc619e391223e12856af308c" dependencies = [ "bitflags 2.10.0", "libc", "libgit2-sys", "log", "openssl-probe 0.1.6", "openssl-sys", "url", ] [[package]] name = "globset" version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" dependencies = [ "aho-corasick", "bstr", "log", "regex-automata", "regex-syntax", ] [[package]] name = "h2" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", "http", "indexmap", "slab", "tokio", "tokio-util", "tracing", ] [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ "allocator-api2", "equivalent", "foldhash", ] [[package]] name = "heck" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "html5ever" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e" dependencies = [ "log", "markup5ever", ] [[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 = "hyper" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", "http", "http-body", "httparse", "itoa", "pin-project-lite", "pin-utils", "smallvec", "tokio", "want", ] [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ "http", "hyper", "hyper-util", "rustls", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] [[package]] name = "hyper-util" version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64", "bytes", "futures-channel", "futures-core", "futures-util", "http", "http-body", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", "tokio", "tower-service", "tracing", ] [[package]] name = "iana-time-zone" version = "0.1.64" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" dependencies = [ "android_system_properties", "core-foundation-sys", "iana-time-zone-haiku", "js-sys", "log", "wasm-bindgen", "windows-core", ] [[package]] name = "iana-time-zone-haiku" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" dependencies = [ "cc", ] [[package]] name = "icu_collections" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", "yoke", "zerofrom", "zerovec", ] [[package]] name = "icu_locale_core" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", "tinystr", "writeable", "zerovec", ] [[package]] name = "icu_normalizer" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", "zerovec", ] [[package]] name = "icu_normalizer_data" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", "writeable", "yoke", "zerofrom", "zerotrie", "zerovec", ] [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idna" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ "idna_adapter", "smallvec", "utf8_iter", ] [[package]] name = "idna_adapter" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] name = "ignore" version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" dependencies = [ "crossbeam-deque", "globset", "log", "memchr", "regex-automata", "same-file", "walkdir", "winapi-util", ] [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" [[package]] name = "indexmap" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", ] [[package]] name = "indoc" version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" dependencies = [ "rustversion", ] [[package]] name = "inlinable_string" version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" [[package]] name = "instability" version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ "darling", "indoc", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", ] [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" dependencies = [ "either", ] [[package]] name = "itertools" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itoa" version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jni" version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", "cfg-if", "combine", "jni-sys", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" [[package]] name = "jobserver" version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ "getrandom 0.3.4", "libc", ] [[package]] name = "js-sys" version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", ] [[package]] name = "kasuari" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fe90c1150662e858c7d5f945089b7517b0a80d8bf7ba4b1b5ffc984e7230a5b" dependencies = [ "hashbrown", "portable-atomic", "thiserror 2.0.17", ] [[package]] name = "lab" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libgit2-sys" version = "0.18.3+1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9b3acc4b91781bb0b3386669d325163746af5f6e4f73e6d2d630e09a35f3487" dependencies = [ "cc", "libc", "libssh2-sys", "libz-sys", "openssl-sys", "pkg-config", ] [[package]] name = "libredox" version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", ] [[package]] name = "libssh2-sys" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" dependencies = [ "cc", "libc", "libz-sys", "openssl-sys", "pkg-config", "vcpkg", ] [[package]] name = "libz-sys" version = "1.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" dependencies = [ "cc", "libc", "pkg-config", "vcpkg", ] [[package]] name = "line-clipping" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f4de44e98ddbf09375cbf4d17714d18f39195f4f4894e8524501726fd9a8a4a" dependencies = [ "bitflags 2.10.0", ] [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "lru" version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ "hashbrown", ] [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "mac" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mac_address" version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ "nix", "winapi", ] [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "markup5ever" version = "0.36.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c" dependencies = [ "log", "tendril", "web_atoms", ] [[package]] name = "matchers" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ "regex-automata", ] [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmem" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" [[package]] name = "memoffset" version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" dependencies = [ "autocfg", ] [[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", ] [[package]] name = "mio" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", "wasi", "windows-sys 0.61.2", ] [[package]] name = "mockall" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" dependencies = [ "cfg-if", "downcast", "fragile", "mockall_derive", "predicates", "predicates-tree", ] [[package]] name = "mockall_derive" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" dependencies = [ "cfg-if", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "new_debug_unreachable" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.10.0", "cfg-if", "cfg_aliases", "libc", "memoffset", ] [[package]] name = "nom" version = "7.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "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 0.61.2", ] [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" [[package]] name = "num-derive" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "num-traits" version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", ] [[package]] name = "num_threads" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" dependencies = [ "libc", ] [[package]] name = "object" version = "0.37.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" dependencies = [ "memchr", ] [[package]] name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "openssl-probe" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "openssl-probe" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f50d9b3dabb09ecd771ad0aa242ca6894994c130308ca3d7684634df8037391" [[package]] name = "openssl-src" version = "300.5.4+3.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" dependencies = [ "cc", ] [[package]] name = "openssl-sys" version = "0.9.111" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" dependencies = [ "cc", "libc", "openssl-src", "pkg-config", "vcpkg", ] [[package]] name = "option-ext" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" dependencies = [ "num-traits", ] [[package]] name = "owo-colors" version = "4.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" [[package]] name = "parking_lot" version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", ] [[package]] name = "parking_lot_core" version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", "windows-link", ] [[package]] name = "pear" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" dependencies = [ "inlinable_string", "pear_codegen", "yansi", ] [[package]] name = "pear_codegen" version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" dependencies = [ "proc-macro2", "proc-macro2-diagnostics", "quote", "syn 2.0.114", ] [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" dependencies = [ "memchr", "ucd-trie", ] [[package]] name = "pest_derive" version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" dependencies = [ "pest", "pest_generator", ] [[package]] name = "pest_generator" version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "pest_meta" version = "2.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" dependencies = [ "pest", "sha2", ] [[package]] name = "phf" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros 0.11.3", "phf_shared 0.11.3", ] [[package]] name = "phf" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] [[package]] name = "phf_codegen" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", ] [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ "phf_generator 0.13.1", "phf_shared 0.13.1", ] [[package]] name = "phf_generator" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", "rand 0.8.5", ] [[package]] name = "phf_generator" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ "fastrand", "phf_shared 0.13.1", ] [[package]] name = "phf_macros" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator 0.11.3", "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "phf_macros" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "phf_shared" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ "siphasher", ] [[package]] name = "phf_shared" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" dependencies = [ "siphasher", ] [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" [[package]] name = "pin-utils" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" [[package]] name = "pkg-config" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "portable-atomic" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950" [[package]] name = "potential_utf" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "ppv-lite86" version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" dependencies = [ "zerocopy", ] [[package]] name = "precomputed-hash" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] name = "predicates" version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" dependencies = [ "anstyle", "predicates-core", ] [[package]] name = "predicates-core" version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" [[package]] name = "predicates-tree" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" dependencies = [ "predicates-core", "termtree", ] [[package]] name = "proc-macro2" version = "1.0.105" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7" dependencies = [ "unicode-ident", ] [[package]] name = "proc-macro2-diagnostics" version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", "version_check", "yansi", ] [[package]] name = "quinn" version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", "rustc-hash", "rustls", "socket2", "thiserror 2.0.17", "tokio", "tracing", "web-time", ] [[package]] name = "quinn-proto" version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", "rand 0.9.2", "ring", "rustc-hash", "rustls", "rustls-pki-types", "slab", "thiserror 2.0.17", "tinyvec", "tracing", "web-time", ] [[package]] name = "quinn-udp" version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" version = "1.0.43" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" [[package]] name = "rand" version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ "rand_core 0.6.4", ] [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha", "rand_core 0.9.3", ] [[package]] name = "rand_chacha" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", "rand_core 0.9.3", ] [[package]] name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" [[package]] name = "rand_core" version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom 0.3.4", ] [[package]] name = "ratatui" version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" dependencies = [ "instability", "ratatui-core", "ratatui-crossterm", "ratatui-macros", "ratatui-termwiz", "ratatui-widgets", ] [[package]] name = "ratatui-core" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" dependencies = [ "bitflags 2.10.0", "compact_str", "hashbrown", "indoc", "itertools 0.14.0", "kasuari", "lru", "strum 0.27.2", "thiserror 2.0.17", "unicode-segmentation", "unicode-truncate", "unicode-width", ] [[package]] name = "ratatui-crossterm" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" dependencies = [ "cfg-if", "crossterm", "instability", "ratatui-core", ] [[package]] name = "ratatui-macros" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" dependencies = [ "ratatui-core", "ratatui-widgets", ] [[package]] name = "ratatui-termwiz" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" dependencies = [ "ratatui-core", "termwiz", ] [[package]] name = "ratatui-widgets" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" dependencies = [ "bitflags 2.10.0", "hashbrown", "indoc", "instability", "itertools 0.14.0", "line-clipping", "ratatui-core", "strum 0.27.2", "time", "unicode-segmentation", "unicode-width", ] [[package]] name = "rayon" version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" dependencies = [ "either", "rayon-core", ] [[package]] name = "rayon-core" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ "crossbeam-deque", "crossbeam-utils", ] [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ "bitflags 2.10.0", ] [[package]] name = "redox_users" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", "thiserror 2.0.17", ] [[package]] name = "regex" version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", "regex-automata", "regex-syntax", ] [[package]] name = "regex-automata" version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] [[package]] name = "regex-syntax" version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "reqwest" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" dependencies = [ "base64", "bytes", "futures-channel", "futures-core", "futures-util", "h2", "http", "http-body", "http-body-util", "hyper", "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", "tokio-rustls", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", ] [[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", "getrandom 0.2.16", "libc", "untrusted", "windows-sys 0.52.0", ] [[package]] name = "rustc-demangle" version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" [[package]] name = "rustc-hash" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] name = "rustc_version" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" dependencies = [ "semver", ] [[package]] name = "rustix" version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "once_cell", "rustls-pki-types", "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe 0.2.0", "rustls-pki-types", "schannel", "security-framework", ] [[package]] name = "rustls-pki-types" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", ] [[package]] name = "rustls-platform-verifier" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation", "core-foundation-sys", "jni", "log", "once_cell", "rustls", "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", "security-framework", "security-framework-sys", "webpki-root-certs", "windows-sys 0.61.2", ] [[package]] name = "rustls-platform-verifier-android" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", ] [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" [[package]] name = "same-file" version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" dependencies = [ "winapi-util", ] [[package]] name = "scc" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46e6f046b7fef48e2660c57ed794263155d713de679057f2d0c169bfc6e756cc" dependencies = [ "sdd", ] [[package]] name = "schannel" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scraper" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93cecd86d6259499c844440546d02f55f3e17bd286e529e48d1f9f67e92315cb" dependencies = [ "cssparser", "ego-tree", "getopts", "html5ever", "precomputed-hash", "selectors", "tendril", ] [[package]] name = "sdd" version = "3.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca" [[package]] name = "security-framework" version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", "security-framework-sys", ] [[package]] name = "security-framework-sys" version = "2.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" dependencies = [ "core-foundation-sys", "libc", ] [[package]] name = "selectors" version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "feef350c36147532e1b79ea5c1f3791373e61cbd9a6a2615413b3807bb164fb7" dependencies = [ "bitflags 2.10.0", "cssparser", "derive_more", "log", "new_debug_unreachable", "phf 0.13.1", "phf_codegen 0.13.1", "precomputed-hash", "rustc-hash", "servo_arc", "smallvec", ] [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" dependencies = [ "serde", "serde_core", ] [[package]] name = "serde" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", ] [[package]] name = "serde_core" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "serde_json" version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", "serde", "serde_core", "zmij", ] [[package]] name = "serde_spanned" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" dependencies = [ "serde", ] [[package]] name = "serde_spanned" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ "serde_core", ] [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ "indexmap", "itoa", "ryu", "serde", "unsafe-libyaml", ] [[package]] name = "serial_test" version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d0b343e184fc3b7bb44dff0705fffcf4b3756ba6aff420dddd8b24ca145e555" dependencies = [ "futures-executor", "futures-util", "log", "once_cell", "parking_lot", "scc", "serial_test_derive", ] [[package]] name = "serial_test_derive" version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f50427f258fb77356e4cd4aa0e87e2bd2c66dbcee41dc405282cae2bfc26c83" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "servo_arc" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" dependencies = [ "stable_deref_trait", ] [[package]] name = "sha2" version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", "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" version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" dependencies = [ "libc", "signal-hook-registry", ] [[package]] name = "signal-hook-mio" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", "mio", "signal-hook", ] [[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 = "siphasher" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", "windows-sys 0.60.2", ] [[package]] name = "spinners" version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0ef947f358b9c238923f764c72a4a9d42f2d637c46e059dbd319d6e7cfb4f82" dependencies = [ "lazy_static", "maplit", "strum 0.24.1", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "string_cache" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" dependencies = [ "new_debug_unreachable", "parking_lot", "phf_shared 0.13.1", "precomputed-hash", ] [[package]] name = "string_cache_codegen" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", ] [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "063e6045c0e62079840579a7e47a355ae92f60eb74daaf156fb1e84ba164e63f" dependencies = [ "strum_macros 0.24.3", ] [[package]] name = "strum" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" dependencies = [ "strum_macros 0.27.2", ] [[package]] name = "strum_macros" version = "0.24.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e385be0d24f186b4ce2f9982191e7101bb737312ad61c1f2f984f34bcf85d59" dependencies = [ "heck 0.4.1", "proc-macro2", "quote", "rustversion", "syn 1.0.109", ] [[package]] name = "strum_macros" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "syn" version = "2.0.114" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" dependencies = [ "proc-macro2", "quote", "unicode-ident", ] [[package]] name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" dependencies = [ "futures-core", ] [[package]] name = "synstructure" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "temp-env" version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" dependencies = [ "parking_lot", ] [[package]] name = "tempfile" version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ "fastrand", "getrandom 0.3.4", "once_cell", "rustix", "windows-sys 0.61.2", ] [[package]] name = "tendril" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" dependencies = [ "futf", "mac", "utf-8", ] [[package]] name = "terminfo" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" dependencies = [ "fnv", "nom", "phf 0.11.3", "phf_codegen 0.11.3", ] [[package]] name = "termios" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" dependencies = [ "libc", ] [[package]] name = "termtree" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "termwiz" version = "0.23.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", "base64", "bitflags 2.10.0", "fancy-regex", "filedescriptor", "finl_unicode", "fixedbitset", "hex", "lazy_static", "libc", "log", "memmem", "nix", "num-derive", "num-traits", "ordered-float", "pest", "pest_derive", "phf 0.11.3", "sha2", "signal-hook", "siphasher", "terminfo", "termios", "thiserror 1.0.69", "ucd-trie", "unicode-segmentation", "vtparse", "wezterm-bidi", "wezterm-blob-leases", "wezterm-color-types", "wezterm-dynamic", "wezterm-input-types", "winapi", ] [[package]] name = "thiserror" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl 1.0.69", ] [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ "thiserror-impl 2.0.17", ] [[package]] name = "thiserror-impl" version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "thiserror-impl" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[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 = "time" version = "0.3.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "libc", "num-conv", "num_threads", "powerfmt", "serde", "time-core", ] [[package]] name = "time-core" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" [[package]] name = "tinystr" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", ] [[package]] name = "tinyvec" version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" dependencies = [ "tinyvec_macros", ] [[package]] name = "tinyvec_macros" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", "mio", "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", ] [[package]] name = "tokio-util" version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", "futures-sink", "pin-project-lite", "tokio", ] [[package]] name = "toml" version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_edit", ] [[package]] name = "toml" version = "0.9.11+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3afc9a848309fe1aaffaed6e1546a7a14de1f935dc9d89d32afd9a44bab7c46" dependencies = [ "indexmap", "serde_core", "serde_spanned 1.0.4", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", "winnow", ] [[package]] name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" dependencies = [ "serde", ] [[package]] name = "toml_datetime" version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow", ] [[package]] name = "toml_parser" version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_write" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tower" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", "pin-project-lite", "sync_wrapper", "tokio", "tower-layer", "tower-service", ] [[package]] name = "tower-http" version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", "futures-util", "http", "http-body", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", ] [[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 = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] [[package]] name = "tracing-attributes" version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[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-error" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" dependencies = [ "tracing", "tracing-subscriber", ] [[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.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", ] [[package]] name = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "uncased" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" dependencies = [ "version_check", ] [[package]] name = "unicode-ident" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fbf03860ff438702f3910ca5f28f8dac63c1c11e7efb5012b8b175493606330" dependencies = [ "itertools 0.13.0", "unicode-segmentation", "unicode-width", ] [[package]] name = "unicode-width" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" [[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 = "utf-8" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ "atomic", "getrandom 0.3.4", "js-sys", "serde_core", "wasm-bindgen", ] [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "version_check" version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vtparse" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" dependencies = [ "utf8parse", ] [[package]] name = "walkdir" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ "same-file", "winapi-util", ] [[package]] name = "want" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" dependencies = [ "try-lock", ] [[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.1+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", "once_cell", "wasm-bindgen", "web-sys", ] [[package]] name = "wasm-bindgen-macro" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", ] [[package]] name = "wasm-bindgen-macro-support" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", "quote", "syn 2.0.114", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web-time" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", "wasm-bindgen", ] [[package]] name = "web_atoms" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30e588f10c7bc3465f5fc1ab087fc97877ec1064a7ec89fb685ac4ee998dac4a" dependencies = [ "phf 0.13.1", "phf_codegen 0.13.1", "string_cache", "string_cache_codegen", ] [[package]] name = "webpki-root-certs" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" dependencies = [ "rustls-pki-types", ] [[package]] name = "wezterm-bidi" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" dependencies = [ "log", "wezterm-dynamic", ] [[package]] name = "wezterm-blob-leases" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" dependencies = [ "getrandom 0.3.4", "mac_address", "sha2", "thiserror 1.0.69", "uuid", ] [[package]] name = "wezterm-color-types" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" dependencies = [ "csscolorparser", "deltae", "lazy_static", "wezterm-dynamic", ] [[package]] name = "wezterm-dynamic" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" dependencies = [ "log", "ordered-float", "strsim", "thiserror 1.0.69", "wezterm-dynamic-derive", ] [[package]] name = "wezterm-dynamic-derive" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" dependencies = [ "proc-macro2", "quote", "syn 1.0.109", ] [[package]] name = "wezterm-input-types" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" dependencies = [ "bitflags 1.3.2", "euclid", "lazy_static", "serde", "wezterm-dynamic", ] [[package]] name = "winapi" version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" dependencies = [ "winapi-i686-pc-windows-gnu", "winapi-x86_64-pc-windows-gnu", ] [[package]] name = "winapi-i686-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ "windows-sys 0.61.2", ] [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", "windows-link", "windows-result", "windows-strings", ] [[package]] name = "windows-implement" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "windows-interface" version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ "windows-link", ] [[package]] name = "windows-strings" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ "windows-link", ] [[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.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ "windows-targets 0.52.6", ] [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ "windows-targets 0.53.5", ] [[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ "windows-link", ] [[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]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] [[package]] name = "windows-targets" version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", "windows_i686_gnullvm 0.53.1", "windows_i686_msvc 0.53.1", "windows_x86_64_gnu 0.53.1", "windows_x86_64_gnullvm 0.53.1", "windows_x86_64_msvc 0.53.1", ] [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[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.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "yansi" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ "stable_deref_trait", "yoke-derive", "zerofrom", ] [[package]] name = "yoke-derive" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", "synstructure", ] [[package]] name = "zerocopy" version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" version = "0.8.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "zerofrom" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", "synstructure", ] [[package]] name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", "zerofrom", ] [[package]] name = "zerovec" version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", "zerovec-derive", ] [[package]] name = "zerovec-derive" version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", "syn 2.0.114", ] [[package]] name = "zmij" version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" feluda-1.11.1/Cargo.toml0000644000000065460000000000100103660ustar # THIS FILE IS AUTOMATICALLY GENERATED BY CARGO # # When uploading crates to the registry Cargo will automatically # "normalize" Cargo.toml files for maximal compatibility # with all versions of Cargo and also rewrite `path` dependencies # to registry (e.g., crates.io) dependencies. # # If you are reading this file be aware that the original Cargo.toml # will likely look very different (and much more reasonable). # See Cargo.toml.orig for the original contents. [package] edition = "2021" rust-version = "1.85.0" name = "feluda" version = "1.11.1" build = false include = [ "src/**", "config/**", "Cargo.toml", "README.md", "LICENSE", ] autolib = false autobins = false autoexamples = false autotests = false autobenches = false description = "A CLI tool to check dependency licenses." homepage = "https://github.com/anistark/feluda" documentation = "https://docs.rs/feluda" readme = "README.md" keywords = [ "cli", "license", "dependencies", "node", "check", ] categories = [ "command-line-utilities", "development-tools", ] license = "MIT" repository = "https://github.com/anistark/feluda" [package.metadata.rpm] package = "feluda" [package.metadata.rpm.cargo] buildflags = ["--release"] [package.metadata.rpm.targets.feluda] path = "/usr/bin/feluda" [features] advanced-debug = ["backtrace"] default = [] [[bin]] name = "feluda" path = "src/main.rs" [dependencies.backtrace] version = "0.3" optional = true [dependencies.cargo_metadata] version = "0.23" [dependencies.chrono] version = "0.4" features = ["serde"] [dependencies.clap] version = "4.5.54" features = [ "derive", "env", ] [dependencies.color-eyre] version = "0.6" default-features = false [dependencies.color-spantrace] version = "0.3" [dependencies.colored] version = "3.0" [dependencies.dirs] version = "6.0" [dependencies.figment] version = "0.10" features = [ "toml", "env", ] [dependencies.git2] version = "0.20" features = [ "vendored-libgit2", "vendored-openssl", ] [dependencies.ignore] version = "0.4" [dependencies.owo-colors] version = "4.2" [dependencies.ratatui] version = "0.30.0" [dependencies.rayon] version = "1.11" [dependencies.regex] version = "1.12" [dependencies.reqwest] version = "0.13.1" features = [ "json", "blocking", "rustls", "http2", ] default-features = false [dependencies.scraper] version = "0.25" [dependencies.semver] version = "1.0" [dependencies.serde] version = "1.0" features = ["derive"] [dependencies.serde_json] version = "1.0" [dependencies.serde_yaml] version = "0.9" [dependencies.spinners] version = "4.1" [dependencies.tempfile] version = "3.24" [dependencies.thiserror] version = "2.0" [dependencies.tokio] version = "1.49" features = ["full"] [dependencies.toml] version = "0.9.8" [dependencies.tracing] version = "0.1" features = ["attributes"] [dependencies.tracing-subscriber] version = "0.3" features = ["env-filter"] [dependencies.unicode-width] version = "0.2.2" [dependencies.uuid] version = "1.19" features = [ "v4", "serde", ] [dev-dependencies.http] version = "1.4" [dev-dependencies.mockall] version = "0.14" [dev-dependencies.serial_test] version = "3.3" [dev-dependencies.temp-env] version = "0.3" [dev-dependencies.tempfile] version = "3.24" [target."cfg(unix)".dependencies.libc] version = "0.2" [profile.release] opt-level = 3 lto = true codegen-units = 1 feluda-1.11.1/Cargo.toml.orig000064400000000000000000000042771046102023000140460ustar 00000000000000[package] name = "feluda" version = "1.11.1" edition = "2021" description = "A CLI tool to check dependency licenses." readme = "README.md" license = "MIT" repository = "https://github.com/anistark/feluda" homepage = "https://github.com/anistark/feluda" keywords = ["cli", "license", "dependencies", "node", "check"] categories = ["command-line-utilities", "development-tools"] include = ["src/**", "config/**", "Cargo.toml", "README.md", "LICENSE"] documentation = "https://docs.rs/feluda" rust-version = "1.85.0" [dependencies] cargo_metadata = "0.23" clap = { version = "4.5.54", features = ["derive", "env"] } serde = { version = "1.0", features = ["derive"] } reqwest = { version = "0.13.1", default-features = false, features = [ "json", "blocking", "rustls", "http2" ] } tokio = { version = "1.49", features = ["full"] } serde_json = "1.0" scraper = "0.25" owo-colors = "4.2" color-eyre = { version = "0.6", default-features = false } color-spantrace = "0.3" ratatui = "0.30.0" unicode-width = "0.2.2" spinners = "4.1" serde_yaml = "0.9" colored = "3.0" rayon = "1.11" figment = { version = "0.10", features = ["toml", "env"] } ignore = "0.4" uuid = { version = "1.19", features = ["v4", "serde"] } toml = "0.9.8" regex = "1.12" thiserror = "2.0" backtrace = { version = "0.3", optional = true } tracing = { version = "0.1", features = ["attributes"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] } chrono = { version = "0.4", features = ["serde"] } git2 = { version = "0.20", features = ["vendored-libgit2", "vendored-openssl"] } tempfile = "3.24" dirs = "6.0" semver = "1.0" [target.'cfg(unix)'.dependencies] libc = "0.2" [dev-dependencies] tempfile = "3.24" mockall = "0.14" http = "1.4" temp-env = "0.3" serial_test = "3.3" [features] default = [] advanced-debug = ["backtrace"] [[bin]] name = "feluda" # Example binaries for testing Feluda with different language ecosystems [[example]] name = "rust-example" path = "examples/rust-example/src/main.rs" [profile.release] lto = true codegen-units = 1 opt-level = 3 [package.metadata.rpm] package = "feluda" [package.metadata.rpm.cargo] buildflags = ["--release"] [package.metadata.rpm.targets] feluda = { path = "/usr/bin/feluda" } feluda-1.11.1/LICENSE000064400000000000000000000017551046102023000121620ustar 00000000000000MIT License Copyright (c) 2025 Kumar Anirudha Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. feluda-1.11.1/README.md000064400000000000000000000677761046102023000124530ustar 00000000000000# Feluda [![Crates.io Version](https://img.shields.io/crates/v/feluda) ](https://crates.io/crates/feluda) [![Crates.io Downloads](https://img.shields.io/crates/d/feluda)](https://crates.io/crates/feluda) [![Crates.io Downloads (latest version)](https://img.shields.io/crates/dv/feluda)](https://crates.io/crates/feluda) [![Open Source](https://img.shields.io/badge/open-source-brightgreen)](https://github.com/anistark/feluda) [![Contributors](https://img.shields.io/github/contributors/anistark/feluda)](https://github.com/anistark/feluda/graphs/contributors) ![maintenance-status](https://img.shields.io/badge/maintenance-actively--developed-brightgreen.svg) 🔎 **Feluda** is a Rust-based command-line tool that analyzes the dependencies of a project, notes down their licenses, and flags any permissions that restrict personal or commercial usage or are incompatible with your project's license. ![ss](https://github.com/user-attachments/assets/473908eb-43cb-4c4f-86aa-017de251afa8) > 👋 It's still highly experimental, but fast iterating. Welcoming contributors and support to help bring out this project even better! ## Features - Parse your project to identify dependencies and their licenses. - Classify licenses into permissive, restrictive, or unknown categories. - Check license compatibility between dependencies and your project's license. - Map licenses to OSI (Open Source Initiative) approval status and filter by OSI approval. - Flag dependencies with licenses that may restrict personal or commercial use. - Flag dependencies with licenses that may be incompatible with your project's license. - Generate compliance files (NOTICE and THIRD_PARTY_LICENSES) for legal requirements. - Generate Software Bill of Materials (SBOM) in SPDX format for security and compliance. - Output results in plain text, JSON or TUI formats. There's also a gist format which is available in restrictive mode to output a single line only. - CI/CD support for Github Actions and Jenkins. - Verbose mode gives an enhanced view of all licenses. ### Support Languages 1. ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) 2. ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![JavaScript](https://img.shields.io/badge/javascript-%23323330.svg?style=for-the-badge&logo=javascript&logoColor=%23F7DF1E) ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) 3. ![Go](https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white) 4. ![Python](https://img.shields.io/badge/python-3670A0?style=for-the-badge&logo=python&logoColor=ffdd54) 5. ![C](https://img.shields.io/badge/c-%2300599C.svg?style=for-the-badge&logo=c&logoColor=white) 6. ![C++](https://img.shields.io/badge/c++-%2300599C.svg?style=for-the-badge&logo=c%2B%2B&logoColor=white) 7. ![.NET](https://img.shields.io/badge/.NET-512BD4?style=for-the-badge&logo=dotnet&logoColor=white) ![C#](https://img.shields.io/badge/c%23-%23239120.svg?style=for-the-badge&logo=csharp&logoColor=white) ![F#](https://img.shields.io/badge/F%23-378BBA?style=for-the-badge&logo=fsharp&logoColor=white) 8. ![R](https://img.shields.io/badge/r-%23276DC3.svg?style=for-the-badge&logo=r&logoColor=white) Feluda supports analyzing dependencies across multiple languages simultaneously. ```sh feluda ``` You can also filter the analysis to a specific language using the `--language` flag. ## Installation ### Official Distribution 🎉:
Rust (Crate) ![Rust](https://img.shields.io/badge/rust-%23000000.svg?style=for-the-badge&logo=rust&logoColor=white) #### Prerequisites - [Rust](https://www.rust-lang.org/tools/install) installed on your system. If you already had it, make sure it's up-to-date and update if needed. (Optional) Set rust path if not set already. #### Install ```sh cargo install feluda ```
DEB Package (Debian/Ubuntu/Pop! OS) ![Ubuntu](https://img.shields.io/badge/Ubuntu-E95420?style=for-the-badge&logo=ubuntu&logoColor=white) ![Debian](https://img.shields.io/badge/Debian-D70A53?style=for-the-badge&logo=debian&logoColor=white) ![Pop!\_OS](https://img.shields.io/badge/Pop!_OS-48B9C7?style=for-the-badge&logo=Pop!_OS&logoColor=white) ![Linux Mint](https://img.shields.io/badge/Linux%20Mint-87CF3E?style=for-the-badge&logo=Linux%20Mint&logoColor=white) Feluda is available as a DEB package for Debian-based systems. 1. Download the latest `.deb` file from [GitHub Releases](https://github.com/anistark/feluda/releases) 2. Install the package: ```sh # Install the downloaded DEB package sudo dpkg -i feluda_*.deb # If there are dependency issues, fix them sudo apt install -f ```
RPM Package (RHEL/Fedora/CentOS) ![Fedora](https://img.shields.io/badge/Fedora-294172?style=for-the-badge&logo=fedora&logoColor=white) ![Red Hat](https://img.shields.io/badge/Red%20Hat-EE0000?style=for-the-badge&logo=redhat&logoColor=white) ![CentOS](https://img.shields.io/badge/cent%20os-002260?style=for-the-badge&logo=centos&logoColor=F0F0F0) Feluda is available as an RPM package for Red Hat-based systems. 1. Download the latest `.rpm` file from [GitHub Releases](https://github.com/anistark/feluda/releases) 2. Install the package: ```sh # Install the downloaded RPM package sudo rpm -ivh feluda_*.rpm # Or using dnf (Fedora/newer RHEL) sudo dnf install feluda_*.rpm # Or using yum (older RHEL/CentOS) sudo yum install feluda_*.rpm ```
### Community Maintained 🙌:
Homebrew (maintained by @chenrui333) ![macOS](https://img.shields.io/badge/mac%20os-000000?style=for-the-badge&logo=macos&logoColor=F0F0F0) [feluda](https://formulae.brew.sh/formula/feluda) is available in the [Homebrew](https://formulae.brew.sh/). You can install it using brew: ```sh brew install feluda ```
Arch Linux (maintained by @adamperkowski) ![Arch](https://img.shields.io/badge/Arch%20Linux-1793D1?logo=arch-linux&logoColor=fff&style=for-the-badge) [feluda](https://aur.archlinux.org/packages/feluda) is available in the [AUR](https://aur.archlinux.org/). You can install it using an AUR helper (e.g. paru): ```sh paru -S feluda ```
NetBSD (maintained by @0323pin) ![Linux](https://img.shields.io/badge/Linux-FCC624?style=for-the-badge&logo=linux&logoColor=black) On NetBSD a package is available from the [official repositories](https://pkgsrc.se/devel/feluda/). To install it, simply run: ```sh pkgin install feluda ```
### Package Managers 📦: [![Packaging status](https://repology.org/badge/vertical-allrepos/feluda.svg)](https://repology.org/project/feluda/versions) Track releases on [github releases](https://github.com/anistark/feluda/releases) or [via release feed](https://github.com/anistark/feluda/releases.atom).
Build from Source (advanced users) **Note:** This might have experimental features which might not work as intended. ### Clone and Build First, clone the repository: ```sh git clone https://github.com/anistark/feluda.git cd feluda ``` Then, build the project using Cargo: ```sh cargo build --release ``` Finally, to make `feluda` available globally, move the binary to a directory in your PATH. For example: ```sh sudo mv target/release/feluda /usr/local/bin/ ```
## Usage Feluda provides license analysis by default, with an additional command for generating compliance files. Analyze your project's dependencies and their licenses: ```sh # Basic usage feluda # Specify a path to your project directory feluda --path /path/to/project/ # Check with specific language feluda --language {rust|node|go|python|c|cpp|r} # Skip local file checks and force network lookup only feluda --no-local # Filter by OSI approval status feluda --osi approved # Show only OSI approved licenses feluda --osi not-approved # Show only non-OSI approved licenses feluda --osi unknown # Show licenses with unknown OSI status ``` ### Local License Detection By default, Feluda checks local files first for license information before making network requests: - **Node.js**: Checks `LICENSE` files in local `node_modules` (npm, pnpm, yarn, bun) - **Rust**: Checks `Cargo.toml` manifests for license field Use `--no-local` to skip local checks and force network-only license lookup. ### License File Generation Generate compliance files for legal requirements: ```sh # Interactive file generation feluda generate # Generate for specific language and license feluda generate --language rust --project-license MIT # Generate for specific path feluda generate --path /path/to/project/ ``` ![generate-ss](https://github.com/user-attachments/assets/a965843f-7d87-4ba8-a311-c982d717a4f8) ### SBOM Generation Generate Software Bill of Materials (SBOM) for your project: ```sh # Generate all supported SBOM formats (SPDX + CycloneDX) feluda sbom # Generate SPDX format SBOM only feluda sbom spdx # Generate SPDX format SBOM to file feluda sbom spdx --output sbom.json # Generate CycloneDX format SBOM only feluda sbom cyclonedx # Generate CycloneDX format SBOM to file feluda sbom cyclonedx --output sbom.json # Generate all formats with custom output feluda sbom --output sbom-output ``` **Supported SBOM Formats:** - **SPDX 2.3** - Software Package Data Exchange format (JSON) - **CycloneDX** - CycloneDX v1.5 format (JSON) **What's Included in SBOM:** - Package names and versions - License information - SPDX identifiers - License compatibility flags - Tool metadata and generation timestamp **Use Cases:** - 🔒 **Security compliance** - Track all dependencies for vulnerability management - 📋 **Supply chain transparency** - Document your software's components - 🏢 **Enterprise requirements** - Meet organizational SBOM mandates - 🔍 **Audit preparation** - Provide comprehensive dependency documentation ### SBOM Validation Validate SBOM files to ensure they conform to the SPDX or CycloneDX specifications: ```sh # Validate an SBOM file feluda sbom validate spdx.json # Validate and save the report to a file feluda sbom validate spdx.json --output validation-report.txt # Validate and output report in JSON format feluda sbom validate spdx.json --json # Validate and save JSON report to file feluda sbom validate spdx.json --json --output validation-report.json ``` ### Cache Management Feluda caches GitHub license data to improve performance on repeated runs: ```sh # View cache status (size, age, health) feluda cache # Clear the cache feluda cache --clear ``` **How Caching Works:** - Cache is stored at `.feluda/cache/github_licenses.json` - 30-day automatic expiration (cache is refreshed if older) - Only licenses successfully fetched from GitHub API are cached - Cache is automatically loaded on subsequent analysis runs - Reduces GitHub API calls and improves analysis speed ### GitHub API Authentication Feluda uses the GitHub API to fetch license information. Unauthenticated requests are limited to 60 requests/hour, which may be insufficient for large projects or frequent scans. **Increase rate limits** by providing a GitHub personal access token: ```sh # Via command-line flag feluda --github-token # Or via environment variable (recommended for CI/CD) export GITHUB_TOKEN= feluda ``` Authenticated requests get 5,000 requests/hour. No special scopes are required for the token—public repository access is sufficient. ### Run feluda on a github repo directly ```sh feluda --repo [--ssh-key ] [--ssh-passphrase ] [--token ] ``` ` : The URL of the Git repository to clone (e.g., git@github.com:user/repo.git or https://github.com/user/repo.git). ` ` --ssh-key : (Optional) Path to a private SSH key for authentication. ` ` --ssh-passphrase : (Optional) Passphrase for the SSH key. ` ` --token : (Optional) HTTPS token for authenticating with private repositories. ` --- _If you're using Feluda, feel free to grab a Scanned with Feluda badge for your project:_ [![Scanned with Feluda](https://img.shields.io/badge/Scanned%20with-Feluda-brightgreen)](https://github.com/anistark/feluda) ```md [![Scanned with Feluda](https://img.shields.io/badge/Scanned%20with-Feluda-brightgreen)](https://github.com/anistark/feluda) ``` Replace the repo name and username. Once you've the Feluda GitHub Action setup, this badge will be automatically updated. ## License Compliance Files Feluda can generate essential compliance files required for commercial software distribution and open source projects. ### NOTICE File A **NOTICE file** is a concise summary document that provides attribution for third-party components: - **Purpose**: Quick overview of all third-party components and their licenses - **Content**: Organized by license type, lists all dependencies with their versions - **Use Cases**: - Legal compliance documentation - Quick reference for license audits - Attribution requirements for many open source licenses ### THIRD_PARTY_LICENSES File A **THIRD_PARTY_LICENSES file** provides comprehensive license documentation: - **Purpose**: Complete legal documentation for all dependencies - **Content**: Full license texts, compatibility analysis, package URLs, and copyright information - **Use Cases**: - Commercial software distribution requirements - Legal compliance for enterprise applications - Due diligence for acquisitions and audits - App store submissions (iOS, Android, etc.) ### Why These Files Are Important **Legal Protection**: Many open source licenses require attribution when redistributing code. These files ensure compliance and protect your organization from legal issues. **Transparency**: Shows exactly what third-party code is included in your application, building trust with users and stakeholders. **Commercial Readiness**: Essential for commercial software, enterprise deployments, and app store submissions. **Audit Preparation**: Makes license audits faster and easier by providing all necessary documentation in standard formats. ### When You Need These Files - 📱 **Mobile app distribution** (iOS App Store, Google Play) - 🏢 **Enterprise software deployment** - 💼 **Commercial product releases** - 🔍 **Legal compliance audits** - 🤝 **Open source project attribution** - 📄 **Regulatory compliance** (GDPR, SOX, etc.) ## Output Format ### JSON - Default: Plain text. - JSON: Use the `--json` flag for JSON output. ```sh feluda --json ``` Sample Output for a sample cargo.toml file containing `serde` and `tokio` dependencies: ```json [ { "name": "serde", "version": "1.0.151", "license": "MIT", "is_restrictive": false, "compatibility": "Compatible", "osi_status": "Approved" }, { "name": "tokio", "version": "1.0.2", "license": "MIT", "is_restrictive": false, "compatibility": "Compatible", "osi_status": "Approved" } ] ``` ### YAML Use the `--yaml` flag for YAML output ```sh feluda --yaml ``` Sample Output for a sample cargo.toml file containing `serde` and `tokio` dependencies: ```yaml - name: serde version: 1.0.151 license: MIT is_restrictive: false compatibility: Compatible osi_status: Approved - name: tokio version: 1.0.2 license: MIT is_restrictive: false compatibility: Compatible osi_status: Approved ``` ### Gist Mode For a short summary, in case you don't want all that output covering your screen: ```sh feluda --gist ``` feluda-gist ### Verbose Mode For detailed information about each dependency: ```sh feluda --verbose ``` The verbose mode displays a table with an additional "OSI Status" column showing whether each license is approved by the Open Source Initiative (OSI). ### OSI Integration Feluda integrates with the Open Source Initiative (OSI) to provide license approval status information. This feature helps you identify whether the licenses used by your dependencies are officially approved by the OSI. #### OSI Status Values - **`approved`**: License is officially approved by the OSI - **`unknown`**: License status with OSI is unknown or the license is not OSI approved #### OSI Filtering Filter dependencies by their OSI approval status: ```sh # Show only OSI approved licenses feluda --osi approved --verbose # Show only non-approved or unknown OSI status licenses feluda --osi not-approved --verbose # Show licenses with unknown OSI status feluda --osi unknown --verbose # Combine with JSON output feluda --osi approved --json ``` **Note**: OSI status information is only displayed in `--verbose` mode, `--gui` mode, or when using structured output formats (JSON/YAML) to keep the default output clean. ### License Compatibility Feluda can check if dependency licenses are compatible with your project's license: ```sh feluda --project-license MIT ``` You can also filter for incompatible licenses only: ```sh feluda --incompatible ``` And fail CI builds if incompatible licenses are found: ```sh feluda --fail-on-incompatible ``` ### Restrictive Mode In case you need to see only the restrictive dependencies: ```sh feluda --restrictive ``` ### Terminal User Interface (TUI) Mode We've an awesome ✨ TUI mode available to browse through the dependencies in a visually appealing way as well: ```sh feluda --gui ``` ![ss-gui](https://github.com/user-attachments/assets/a799fe18-5700-4f2c-b6ac-4a401cdc4956) ## CI/CD Integration Feluda provides several options for CI integration: - `--ci-format `: Generate output compatible with the specified CI system - `--fail-on-restrictive`: Make the CI build fail when restrictive licenses are found - `--fail-on-incompatible`: Make the CI build fail when incompatible licenses are found - `--osi `: Filter by OSI license approval status - `--output-file `: Write the output to a file instead of stdout Feluda can be easily integrated into your CI/CD pipelines with built-in support for **GitHub Actions** and **Jenkins**. ### GitHub Actions To use Feluda with GitHub Actions, simply use the published action. For detailed documentation, see the [GitHub Action README](./ACTION-README.md). ```yaml name: License Check on: push: branches: [ main ] pull_request: branches: [ main ] jobs: license-check: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Scan licenses uses: anistark/feluda@v1 with: fail-on-restrictive: true fail-on-incompatible: true ``` **Advanced usage with compliance files:** ```yaml - name: Scan licenses uses: anistark/feluda@v1 with: fail-on-restrictive: true project-license: 'MIT' update-badge: true - name: Generate compliance files run: | echo "1" | feluda generate # Auto-select NOTICE file echo "2" | feluda generate # Auto-select THIRD_PARTY_LICENSES file - name: Generate SBOM run: | feluda sbom spdx --output sbom.spdx.json feluda sbom cyclonedx --output sbom.cyclonedx.json - name: Validate SBOM files run: | feluda sbom validate sbom.spdx.json --output sbom-spdx-validation.txt feluda sbom validate sbom.cyclonedx.json --output sbom-cyclonedx-validation.txt - name: Upload compliance artifacts uses: actions/upload-artifact@v4 with: name: license-compliance path: | NOTICE THIRD_PARTY_LICENSES.md sbom.spdx.json sbom.cyclonedx.json sbom-spdx-validation.txt sbom-cyclonedx-validation.txt ``` ### Jenkins To use Feluda with Jenkins, see the [CI examples](./examples/ci/) directory for a sample Jenkinsfile that demonstrates: - Installing Feluda via Cargo - Running license checks with Jenkins-compatible output format (JUnit XML) - Publishing results as JUnit test reports For more CI/CD integration examples, visit the [examples/ci](./examples/ci/) directory. Checkout [contributing guidelines](./CONTRIBUTING.md) if you are looking to contribute to this project. > Currently, using [choosealicense](https://choosealicense.com/) license directory for source of truth. ## Configuration (Optional) Feluda allows you to customize which licenses are considered restrictive and which licenses to ignore from analysis. This can be done in three ways, listed in order of precedence (highest to lowest): 1. Environment variables 2. `.feluda.toml` configuration file 3. Default values ### Default Restrictive Licenses By default, Feluda considers the following licenses as restrictive: - GPL-3.0 - AGPL-3.0 - LGPL-3.0 - MPL-2.0 - SEE LICENSE IN LICENSE - CC-BY-SA-4.0 - EPL-2.0 ### Configuration File Create a `.feluda.toml` file in your project root to customize restrictive licenses and ignore licenses: ```toml [licenses] # Override the default list of restrictive licenses restrictive = [ "GPL-3.0", # GNU General Public License v3.0 "AGPL-3.0", # GNU Affero General Public License v3.0 "Custom-1.0", # Your custom license identifier ] # Licenses to ignore from analysis ignore = [ "MIT", # MIT License "Apache-2.0", # Apache License 2.0 ] ``` ### Ignoring Licenses The `ignore` section allows you to exclude specific licenses from analysis. This is useful when: - You want to exclude certain permissive licenses from the output - You're only interested in restrictive or incompatible licenses - You want to focus on specific subsets of your dependencies Ignored licenses will be completely filtered out from the analysis results and won't appear in any reports. ```toml [licenses] ignore = [ "MIT", "Apache-2.0", "BSD-2-Clause", "BSD-3-Clause", "ISC", ] ``` ### Ignoring Dependencies The `[dependencies]` section allows you to exclude entire dependencies from license scanning, regardless of their license. This is useful when: - A dependency is internal to your organization and shares your project's license - You have a written agreement with the dependency author allowing its use - A dependency is only used in development/testing and not distributed Ignored dependencies will be completely filtered out during the scanning phase and won't appear in any reports. ```toml [[dependencies.ignore]] name = "github.com/anistark/wasmrun" version = "v1.0.0" reason = "This is within the same repo as the project, hence it shares the same license." [[dependencies.ignore]] name = "internal-library" version = "" # Leave empty to ignore all versions of this dependency reason = "We have a written acknowledgment from the author that we may use their code under our license." [[dependencies.ignore]] name = "dev-only-package" version = "" # Ignore all versions reason = "This package is only used for development and testing, not distributed." ``` **Note**: The `version` field is optional: - When specified (e.g., `"v1.0.0"`), only that version will be ignored - When left empty or omitted, **all versions** of that dependency will be ignored - The `reason` field documents why the dependency is being ignored for auditing purposes ### Environment Variables You can also override the configuration using environment variables: ```sh # Override restrictive licenses list export FELUDA_LICENSES_RESTRICTIVE='["GPL-3.0","AGPL-3.0","Custom-1.0"]' # Override ignore licenses list export FELUDA_LICENSES_IGNORE='["MIT","Apache-2.0","BSD-3-Clause"]' ``` The environment variables take precedence over both the configuration file and default values. ### Configuration Validation Feluda validates your configuration and will warn you if: **License Configuration:** - A license appears in both `restrictive` and `ignore` lists (the license will be ignored) - Empty license strings are found in either list (will cause an error) - Duplicate licenses are found in either list (will cause an error) - Invalid SPDX identifiers are used (warning only) **Dependency Configuration:** - Empty dependency names are provided (will cause an error) - Duplicate dependencies with the same name and version are found (will cause an error) - A dependency is missing a reason (warning only) ## License Compatibility Matrix Feluda uses a comprehensive license compatibility matrix to determine whether dependency licenses are compatible with your project's license. This matrix is maintained in an external TOML configuration file for easy updates and maintenance. ### How It Works When you use the `--project-license` flag or Feluda auto-detects your project license, it checks each dependency's license against a compatibility matrix to determine: - ✅ **Compatible**: Safe to use with your project license - ❌ **Incompatible**: May create legal issues or licensing conflicts - ❓ **Unknown**: License compatibility cannot be determined ### Compatibility Matrix Location The license compatibility rules are stored in: ```sh config/license_compatibility.toml ``` This file defines which dependency licenses are compatible with each project license type. For example: ```toml [MIT] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", # ... more permissive licenses ] [GPL-3.0] compatible_with = [ "MIT", "BSD-2-Clause", "Apache-2.0", "LGPL-2.1", "LGPL-3.0", "GPL-2.0", "GPL-3.0", # ... GPL-compatible licenses ] ``` ### Supported Project Licenses The matrix currently supports compatibility checking for: - **MIT** - Most permissive, allows only permissive dependency licenses - **Apache-2.0** - Permissive license compatible with most open source licenses - **GPL-3.0** - Copyleft license with broad compatibility including LGPL and other GPL versions - **GPL-2.0** - Stricter copyleft (cannot include Apache-2.0 dependencies) - **AGPL-3.0** - Network copyleft with GPL-3.0 compatibility plus AGPL - **LGPL-3.0 / LGPL-2.1** - Lesser GPL variants with limited compatibility - **MPL-2.0** - Mozilla Public License with moderate compatibility - **BSD-3-Clause / BSD-2-Clause** - BSD variants with permissive-only compatibility - **ISC, 0BSD, Unlicense, WTFPL** - Various permissive licenses ### Custom Compatibility Rules Advanced users can customize compatibility rules by: 1. **User-specific overrides**: Create `.feluda/license_compatibility.toml` in your home directory 2. **Project-specific rules**: The local `config/license_compatibility.toml` takes precedence **Important**: Modifying compatibility rules requires legal expertise. Consult legal counsel before making changes that could affect your project's compliance. ## ⚠️ Legal Disclaimer Feluda is provided as a helpful tool for license compliance analysis. However, it is **not a substitute for legal advice**, and users are responsible for their own compliance decisions: **Important Points:** - **Verification**: You must verify the accuracy of all license information provided by Feluda - **Your Responsibility**: Ensure compliance with all applicable license terms and regulations - **Legal Counsel**: Always consult qualified legal counsel for license compliance matters - **Official Sources**: Check official repositories for up-to-date and authoritative license information - **No Warranty**: Feluda and its contributors provide no warranties regarding accuracy or fitness for any purpose - **No Liability**: Feluda and its contributors are not liable for any legal issues arising from the use of this tool or information - **Complexity**: License compatibility can depend on specific use cases, distribution methods, and jurisdictions Feluda is in active development. While we strive to provide accurate information, **use at your own risk.** --- ![felu](https://github.com/user-attachments/assets/5f2bf6c4-3b70-4d2f-9990-c4005f56c5a9) [![MIT license](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) _Happy coding with Feluda!_ 🚀 feluda-1.11.1/config/license_compatibility.toml000064400000000000000000000051701046102023000176650ustar 00000000000000# License Compatibility Matrix Configuration # # This file defines which dependency licenses are compatible with each project license type. # Each section represents a project license, and the 'compatible_with' array lists # all dependency licenses that can be safely included in projects using that license. # # Location: This file should be placed in one of the following locations (in order of precedence): # 1. config/license_compatibility.toml (recommended - project-specific config directory) # 2. .feluda/license_compatibility.toml (user-specific config directory) # # This file is required for the application to function. [MIT] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", "0BSD", "Zlib", "Unlicense", "WTFPL", "CC0-1.0", ] [Apache-2_0] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "ISC", "0BSD", "Zlib", "Unlicense", "WTFPL", "CC0-1.0", ] [GPL-3_0] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "LGPL-2.1", "LGPL-3.0", "GPL-2.0", "GPL-3.0", "ISC", "0BSD", "Zlib", "Unlicense", "WTFPL", "CC0-1.0", ] [GPL-2_0] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "LGPL-2.1", "GPL-2.0", "ISC", "0BSD", "Zlib", "Unlicense", "WTFPL", "CC0-1.0", ] [AGPL-3_0] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "LGPL-2.1", "LGPL-3.0", "GPL-2.0", "GPL-3.0", "AGPL-3.0", "ISC", "0BSD", "Zlib", "Unlicense", "WTFPL", "CC0-1.0", ] [LGPL-3_0] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "Apache-2.0", "LGPL-2.1", "LGPL-3.0", "ISC", "0BSD", "CC0-1.0", ] [LGPL-2_1] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "LGPL-2.1", "ISC", "0BSD", "CC0-1.0", ] [MPL-2_0] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "MPL-2.0", "ISC", "0BSD", "CC0-1.0", ] [BSD-3-Clause] compatible_with = [ "MIT", "BSD-2-Clause", "BSD-3-Clause", "ISC", "0BSD", "CC0-1.0", ] [BSD-2-Clause] compatible_with = [ "MIT", "BSD-2-Clause", "ISC", "0BSD", "CC0-1.0", ] [ISC] compatible_with = [ "MIT", "ISC", "0BSD", "CC0-1.0", ] [_0BSD] compatible_with = [ "0BSD", "CC0-1.0", ] [Unlicense] compatible_with = [ "Unlicense", "0BSD", "CC0-1.0", ] [WTFPL] compatible_with = [ "WTFPL", "0BSD", "Unlicense", "CC0-1.0", ]feluda-1.11.1/examples/README.md000064400000000000000000000031311046102023000142400ustar 00000000000000# Feluda Example Projects This directory contains example projects for all supported languages in Feluda. Each example project is designed to test Feluda's license analysis capabilities with real-world dependencies that have transient (indirect) dependencies. ## Available Examples 1. Rust Example (`rust-example/`) 2. Node.js Example (`node-example/`) 3. Go Example (`go-example/`) 4. Python Example (`python-example/`) 5. C Example (`c-example/`) 6. C++ Example (`cpp-example/`) 7. R Example (`r-example/`) ## Using Just Commands The project includes `just` commands for easy testing: ```sh # Show available example commands just examples # Test Feluda on all example projects just test-examples ``` ## Testing Different Output Formats ```sh # JSON output feluda --path examples/rust-example --json # YAML output feluda --path examples/node-example --yaml # Verbose mode with OSI status feluda --path examples/go-example --verbose # TUI/GUI mode feluda --path examples/python-example --gui # Gist mode feluda --path examples/c-example --gist # License compatibility check feluda --path examples/cpp-example --project-license MIT # R project analysis feluda --path examples/r-example --verbose ``` ## Contributing When adding a new language support to Feluda: 1. Create a new example project in `examples/-example/` 2. Include dependencies with transient dependencies 3. Add a README.md explaining the dependencies 4. Update this README.md to list the new example 5. Update `justfile` to include the new example in `test-examples` 6. Test the example: `feluda --path examples/-example` feluda-1.11.1/examples/c-example/README.md000064400000000000000000000020071046102023000161140ustar 00000000000000# C Example Project This is a sample C project used for testing Feluda's license analysis capabilities. ## Dependencies This project uses system libraries that have transient (indirect) dependencies: - **openssl**: Cryptography library (has transient dependencies on libcrypto) - **libcurl**: HTTP client library (has transient dependencies on openssl, zlib, etc.) - **zlib**: Compression library (standalone but commonly used) ## Testing with Feluda Run Feluda on this project: ```sh feluda --path examples/c-example ``` Or from within the example directory: ```sh cd examples/c-example feluda ``` ## Setup (Optional) To actually build and run this example: ### Ubuntu/Debian ```sh sudo apt-get install libssl-dev libcurl4-openssl-dev zlib1g-dev make ./c_example ``` ### macOS ```sh brew install openssl curl zlib make ./c_example ``` ## Note C dependency detection relies on system package managers (apt, dnf, pacman) and pkg-config. The Makefile demonstrates typical C project structure with library dependencies. feluda-1.11.1/examples/ci/README.md000064400000000000000000000047241046102023000146440ustar 00000000000000# CI/CD Integration Examples This directory contains example configurations for integrating Feluda into various CI/CD systems. ## GitHub Actions ### Option 1: Using the Feluda Action (Recommended) The recommended way to use Feluda in GitHub Actions is to use the published action. This provides the best integration with automatic badge updates and configurable outputs. See the [GitHub Action README](../../ACTION-README.md) for complete documentation. ### Option 2: Standalone Workflow If you prefer a standalone workflow without using the published action, see [`license-check.yml`](./license-check.yml) for an example that: - Installs Feluda directly via Cargo - Runs license checks with GitHub-compatible output format - Updates README badge with results - Supports manual workflow dispatch To use this example, choose one of the following methods: **Option A: Copy the file manually** 1. Copy the contents of [`license-check.yml`](./license-check.yml) 2. Create `.github/workflows/license-check.yml` in your repository and paste the contents 3. Adjust the configuration as needed for your project **Option B: Download with wget** 1. Run the following command in your repository root: ```sh mkdir -p .github/workflows wget https://raw.githubusercontent.com/anistark/feluda/main/examples/ci/license-check.yml -O .github/workflows/license-check.yml ``` 2. Adjust the configuration as needed for your project ## Jenkins See [`Jenkinsfile`](./Jenkinsfile) for an example Jenkins pipeline that: - Checks out your code - Installs Feluda via Cargo - Runs license checks with Jenkins-compatible output format (JUnit XML) - Publishes results as JUnit test reports To use this example: 1. Copy `Jenkinsfile` to the root of your repository 2. Configure your Jenkins job to use the Jenkinsfile 3. Adjust the configuration as needed for your project ## General CI/CD Integration Feluda supports output formats for different CI systems: - `--ci-format github`: GitHub Actions Workflow Commands - `--ci-format jenkins`: JUnit XML format For other CI/CD systems, use the standard text or JSON output formats: - `--output plain` or no flag: Plain text output (default) - `--output json`: JSON format for programmatic parsing Key flags for CI integration: - `--fail-on-restrictive`: Exit with non-zero status if restrictive licenses are found - `--fail-on-incompatible`: Exit with non-zero status if incompatible licenses are found - `--output-file `: Write results to a file instead of stdout feluda-1.11.1/examples/cpp-example/README.md000064400000000000000000000024471046102023000164640ustar 00000000000000# C++ Example Project This is a sample C++ project used for testing Feluda's license analysis capabilities. ## Dependencies This project uses vcpkg for package management and includes dependencies with transient (indirect) dependencies: - **boost-system**: Boost system library (has transient dependencies on boost-config, etc.) - **fmt**: Modern C++ formatting library (standalone but good for testing) - **nlohmann-json**: JSON library for C++ (standalone but good for testing) - **spdlog**: Fast C++ logging library (may have transient dependencies on fmt) ## Testing with Feluda Run Feluda on this project: ```sh feluda --path examples/cpp-example ``` Or from within the example directory: ```sh cd examples/cpp-example feluda ``` ## Setup (Optional) To actually build and run this example, you'll need vcpkg: ### Install vcpkg ```sh git clone https://github.com/Microsoft/vcpkg.git ./vcpkg/bootstrap-vcpkg.sh ``` ### Build the project ```sh cmake -B build -S . -DCMAKE_TOOLCHAIN_FILE=/path/to/vcpkg/scripts/buildsystems/vcpkg.cmake cmake --build build ./build/cpp_example ``` ## Alternative: Conan Support You can also create a `conanfile.txt` for Conan package manager support: ```ini [requires] boost/1.83.0 fmt/10.1.1 nlohmann_json/3.11.2 spdlog/1.12.0 [generators] CMakeDeps CMakeToolchain ``` feluda-1.11.1/examples/dotnet-example/README.md000064400000000000000000000012101046102023000171620ustar 00000000000000# .NET Example Project This is a simple .NET console application used to test Feluda's .NET dependency analysis. ## Dependencies **Direct Dependencies:** - `Newtonsoft.Json` (13.0.3) - MIT License - `Serilog` (3.1.1) - Apache-2.0 License - `Microsoft.Extensions.Configuration` (8.0.0) - MIT License - `Dapper` (2.1.35) - Apache-2.0 License **Expected Transitive Dependencies:** - `Microsoft.Extensions.Configuration.Abstractions` - `Microsoft.Extensions.Primitives` - And more... ## Testing Feluda Run Feluda on this project: ```bash feluda -p examples/dotnet-example ``` With debug output: ```bash feluda -p examples/dotnet-example -d ``` feluda-1.11.1/examples/go-example/README.md000064400000000000000000000015141046102023000163010ustar 00000000000000# Go Example Project This is a sample Go project used for testing Feluda's license analysis capabilities. ## Dependencies This project includes dependencies with transient (indirect) dependencies: - **gin-gonic/gin**: Web framework (has transient dependencies like go-playground/validator, etc.) - **spf13/cobra**: CLI framework (has transient dependencies like spf13/pflag, etc.) - **stretchr/testify**: Testing toolkit (has transient dependencies like davecgh/go-spew, etc.) - **uber-go/zap**: Logging library (has transient dependencies like uber-go/multierr, etc.) ## Testing with Feluda Run Feluda on this project: ```sh feluda --path examples/go-example ``` Or from within the example directory: ```sh cd examples/go-example feluda ``` ## Setup (Optional) To actually run this example: ```sh go mod download go run main.go ``` feluda-1.11.1/examples/node-example/README.md000064400000000000000000000014351046102023000166230ustar 00000000000000# Node.js Example Project This is a sample Node.js project used for testing Feluda's license analysis capabilities. ## Dependencies This project includes dependencies with transient (indirect) dependencies: - **express**: Web framework (has transient dependencies like body-parser, cookie, etc.) - **axios**: HTTP client (has transient dependencies like follow-redirects, form-data, etc.) - **lodash**: Utility library (standalone, but good for testing) - **moment**: Date manipulation library (has transient dependencies) ## Testing with Feluda Run Feluda on this project: ```sh feluda --path examples/node-example ``` Or from within the example directory: ```sh cd examples/node-example feluda ``` ## Setup (Optional) To actually run this example: ```sh npm install node index.js ``` feluda-1.11.1/examples/python-example/README.md000064400000000000000000000015351046102023000172200ustar 00000000000000# Python Example Project This is a sample Python project used for testing Feluda's license analysis capabilities. ## Dependencies This project includes dependencies with transient (indirect) dependencies: - **flask**: Web framework (has transient dependencies like Werkzeug, Jinja2, click, etc.) - **requests**: HTTP library (has transient dependencies like urllib3, certifi, charset-normalizer, etc.) - **numpy**: Numerical computing library (has transient dependencies) - **pytest**: Testing framework (has transient dependencies like pluggy, iniconfig, etc.) ## Testing with Feluda Run Feluda on this project: ```sh feluda --path examples/python-example ``` Or from within the example directory: ```sh cd examples/python-example feluda ``` ## Setup (Optional) To actually run this example: ```sh pip install -r requirements.txt python main.py ``` feluda-1.11.1/examples/r-example/README.md000064400000000000000000000033551046102023000161420ustar 00000000000000# R Example Project for Feluda This is an example R package designed to test Feluda's license analysis capabilities for R projects. ## Dependencies This project includes common R packages with various licenses: - **dplyr** - Data manipulation package - **ggplot2** - Data visualization - **tidyr** - Data tidying tools - **readr** - Reading rectangular data ## Project Files - `DESCRIPTION` - Package metadata and dependencies (DCF format) - `renv.lock` - Lockfile with specific package versions (JSON format) - `R/example.R` - Sample R code using the dependencies ## Testing with Feluda Run Feluda on this example project: ```sh # From the repository root feluda --path examples/r-example # Verbose output feluda --path examples/r-example --verbose # JSON output feluda --path examples/r-example --json # Test with renv.lock file feluda --path examples/r-example ``` ## Expected Output Feluda should detect and analyze licenses for: - Direct dependencies (dplyr, ggplot2, tidyr, readr) - Transitive dependencies (via renv.lock) - License information fetched from R-universe API ## Notes This example demonstrates: - **DESCRIPTION file parsing** - Analyzes direct dependencies only - **renv.lock file support** - Includes all transitive dependencies (already resolved by renv) - **R-universe API integration** - Fetches license information for each package - **Multi-field dependency detection** - Handles Imports, Depends, Suggests, LinkingTo fields ### Transitive Dependencies - **renv.lock**: Contains the full dependency tree (direct + transitive). Feluda analyzes all packages listed. - **DESCRIPTION**: Contains only direct dependencies. For complete transitive dependency analysis, use `renv.lock` or run `renv::snapshot()` to generate a lockfile. feluda-1.11.1/src/cache.rs000064400000000000000000000167441046102023000133610ustar 00000000000000//! Caching functionality for license data //! //! Future considerations: //! - Per-package license cache (language:package:version keys) //! - Dependency manifest cache with mtime tracking for incremental analysis use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; use std::time::{Duration, SystemTime}; use crate::debug::{log, log_error, FeludaResult, LogLevel}; use crate::licenses::License; const CACHE_DIR: &str = ".feluda/cache"; const GITHUB_LICENSES_CACHE_FILE: &str = "github_licenses.json"; const CACHE_TTL_SECS: u64 = 30 * 24 * 60 * 60; // 30 days #[derive(serde::Serialize, serde::Deserialize, Debug)] struct CacheEntry { data: HashMap, timestamp: u64, } fn get_cache_dir() -> FeludaResult { let cache_dir = PathBuf::from(CACHE_DIR); if !cache_dir.exists() { fs::create_dir_all(&cache_dir) .inspect_err(|e| log_error("Failed to create cache directory", e))?; } Ok(cache_dir) } fn get_github_cache_path() -> FeludaResult { let cache_dir = get_cache_dir()?; Ok(cache_dir.join(GITHUB_LICENSES_CACHE_FILE)) } fn is_cache_fresh(path: &Path) -> bool { match path.metadata() { Ok(metadata) => match metadata.modified() { Ok(modified_time) => match SystemTime::now().duration_since(modified_time) { Ok(age) => { let is_fresh = age < Duration::from_secs(CACHE_TTL_SECS); log( LogLevel::Info, &format!( "Cache age: {:?} seconds (fresh: {})", age.as_secs(), is_fresh ), ); is_fresh } Err(_) => { log( LogLevel::Warn, "Could not determine cache age, treating as stale", ); false } }, Err(_) => { log( LogLevel::Warn, "Could not read cache modification time, treating as stale", ); false } }, Err(_) => false, } } pub fn load_github_licenses_from_cache() -> FeludaResult>> { let cache_path = get_github_cache_path()?; if !cache_path.exists() { log(LogLevel::Info, "No GitHub licenses cache found"); return Ok(None); } if !is_cache_fresh(&cache_path) { log( LogLevel::Info, "GitHub licenses cache is stale, will re-fetch", ); return Ok(None); } log(LogLevel::Info, "Loading GitHub licenses from cache"); match fs::read_to_string(&cache_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(entry) => { log( LogLevel::Info, &format!( "Successfully loaded {} licenses from cache", entry.data.len() ), ); Ok(Some(entry.data)) } Err(e) => { log_error("Failed to parse cache file", &e); log(LogLevel::Info, "Will re-fetch licenses from GitHub"); Ok(None) } }, Err(e) => { log_error("Failed to read cache file", &e); log(LogLevel::Info, "Will re-fetch licenses from GitHub"); Ok(None) } } } pub fn save_github_licenses_to_cache(licenses: &HashMap) -> FeludaResult<()> { let cache_path = get_github_cache_path()?; let timestamp = SystemTime::now() .duration_since(SystemTime::UNIX_EPOCH) .map(|d| d.as_secs()) .unwrap_or(0); let entry = CacheEntry { data: licenses.clone(), timestamp, }; let json = match serde_json::to_string_pretty(&entry) { Ok(json) => json, Err(e) => { log_error("Failed to serialize cache", &e); return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()).into()); } }; fs::write(&cache_path, json).inspect_err(|e| log_error("Failed to write cache file", e))?; log( LogLevel::Info, &format!( "Saved {} licenses to cache at {}", licenses.len(), cache_path.display() ), ); Ok(()) } pub fn clear_github_licenses_cache() -> FeludaResult<()> { let cache_path = get_github_cache_path()?; if cache_path.exists() { fs::remove_file(&cache_path).inspect_err(|e| log_error("Failed to clear cache", e))?; log(LogLevel::Info, "Cleared GitHub licenses cache"); } else { log(LogLevel::Info, "No cache to clear"); } Ok(()) } #[derive(Debug)] pub struct CacheStatus { pub exists: bool, pub path: PathBuf, pub size_bytes: u64, pub is_fresh: bool, pub age_secs: u64, pub license_count: usize, } impl CacheStatus { fn format_size(bytes: u64) -> String { const KB: u64 = 1024; const MB: u64 = KB * 1024; if bytes < KB { format!("{bytes} B") } else if bytes < MB { format!("{:.2} KB", bytes as f64 / KB as f64) } else { format!("{:.2} MB", bytes as f64 / MB as f64) } } fn format_age(secs: u64) -> String { const HOUR: u64 = 3600; const DAY: u64 = 24 * HOUR; if secs < HOUR { format!("{} minutes ago", secs / 60) } else if secs < DAY { format!("{} hours ago", secs / HOUR) } else { format!("{} days ago", secs / DAY) } } pub fn print_status(&self) { if !self.exists { println!("\n📦 Cache Status: EMPTY"); println!(" No cache found at: {}", self.path.display()); println!(" Cache will be created on next license analysis.\n"); return; } let health = if self.is_fresh { "✓ FRESH" } else { "✗ STALE" }; println!("\n📦 Cache Status: {health}"); println!(" Location: {}", self.path.display()); println!(" Size: {}", Self::format_size(self.size_bytes)); println!(" Age: {}", Self::format_age(self.age_secs)); println!(" Licenses cached: {}", self.license_count); println!(); } } pub fn get_cache_status() -> FeludaResult { let cache_path = get_github_cache_path()?; if !cache_path.exists() { return Ok(CacheStatus { exists: false, path: cache_path, size_bytes: 0, is_fresh: false, age_secs: 0, license_count: 0, }); } let metadata = fs::metadata(&cache_path)?; let size_bytes = metadata.len(); let is_fresh = is_cache_fresh(&cache_path); let age_secs = SystemTime::now() .duration_since(metadata.modified()?) .map(|d| d.as_secs()) .unwrap_or(0); let license_count = match fs::read_to_string(&cache_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(entry) => entry.data.len(), Err(_) => 0, }, Err(_) => 0, }; Ok(CacheStatus { exists: true, path: cache_path, size_bytes, is_fresh, age_secs, license_count, }) } feluda-1.11.1/src/cli.rs000064400000000000000000000556631046102023000130700ustar 00000000000000use clap::{ArgGroup, Parser, Subcommand, ValueEnum}; use colored::*; use std::env; use std::io::{self, Write}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; use std::thread; use std::time::Duration; // Import from the debug module instead of defining here use crate::debug::{is_debug_mode, log, LogLevel}; /// CI output format options #[derive(ValueEnum, Clone, Debug)] pub enum CiFormat { /// GitHub Actions compatible format Github, /// Jenkins compatible format (JUnit XML) Jenkins, } /// SBOM format options #[derive(ValueEnum, Clone, Debug, PartialEq)] pub enum SbomFormat { /// SPDX format Spdx, /// CycloneDX format Cyclonedx, /// Generate all supported formats All, } /// OSI filter options #[derive(ValueEnum, Clone, Debug)] pub enum OsiFilter { /// Show only OSI approved licenses Approved, /// Show only non-OSI approved licenses NotApproved, /// Show licenses with unknown OSI status Unknown, } /// SBOM Subcommands #[derive(Subcommand, Debug, Clone)] pub enum SbomCommand { /// Generate SPDX format SBOM Spdx { /// Path to the local project directory #[arg(short, long, default_value = "./")] path: String, /// Path to write the SBOM file #[arg(short, long)] output: Option, }, /// Generate CycloneDX format SBOM Cyclonedx { /// Path to the local project directory #[arg(short, long, default_value = "./")] path: String, /// Path to write the SBOM file #[arg(short, long)] output: Option, }, /// Validate SBOM file (JSON format) Validate { /// Path to the SBOM file to validate #[arg(value_name = "FILE")] sbom_file: String, /// Path to write the validation report #[arg(short, long)] output: Option, /// Output validation report in JSON format #[arg(long)] json: bool, }, } /// CLI Commands #[derive(Subcommand, Debug, Clone)] pub enum Commands { /// Generate license-related files Generate { /// Path to the local project directory #[arg(short, long, default_value = "./")] path: String, /// Specify the language to scan #[arg(long, short)] language: Option, /// Specify the project license explicitly #[arg(long)] project_license: Option, }, /// Generate Software Bill of Materials (SBOM) Sbom { /// Path to the local project directory #[arg(short, long, default_value = "./")] path: String, /// Path to write the SBOM files #[arg(short, long)] output: Option, /// SBOM format subcommand #[command(subcommand)] format: Option, }, /// Manage cache Cache { /// Clear the GitHub licenses cache #[arg(long)] clear: bool, }, } #[derive(Parser, Debug, Clone)] #[command(author, version)] #[command(about = env!("CARGO_PKG_DESCRIPTION"))] #[command( long_about = "Feluda is a CLI tool that analyzes the dependencies of a project, identifies their licenses, and flags any that may restrict personal or commercial usage." )] #[command(group(ArgGroup::new("output").args(["json"])))] #[command(group(ArgGroup::new("source").args(["path", "repo"]).multiple(false)))] // Mutually exclusive path and repo #[command(before_help = format_before_help())] pub struct Cli { /// Enable debug mode #[arg(long, short, global = true)] pub debug: bool, #[command(subcommand)] pub command: Option, /// Path to the local project directory #[arg(short, long, default_value = "./")] pub path: String, /// URL of the Git repository to analyze (HTTPS or SSH) #[arg(long)] pub repo: Option, // For HTTPS authentication #[arg(long, requires = "repo")] pub token: Option, // For custom SSH key path #[arg(long, requires = "repo")] pub ssh_key: Option, // For custom SSH key passphrase #[arg(long)] pub ssh_passphrase: Option, /// GitHub personal access token for API authentication (increases rate limits) #[arg(long, env = "GITHUB_TOKEN", global = true)] pub github_token: Option, /// Output in JSON format #[arg(long, short, group = "output")] /// This will override the default output format /// and will not show the TUI table. /// This is useful for CI/CD pipelines. pub json: bool, /// Output in YAML format #[arg(long, short, group = "output")] /// This will override the default output format /// and will not show the TUI table. /// This is useful for CI/CD pipelines. pub yaml: bool, /// Enable verbose output #[arg(long)] pub verbose: bool, /// Show only restrictive dependencies #[arg(long, short)] pub restrictive: bool, /// Enable TUI table #[arg(long, short)] pub gui: bool, /// Specify the language to scan #[arg(long, short)] pub language: Option, /// Output format for CI systems (github, jenkins) #[arg(long, value_enum)] pub ci_format: Option, /// Path to write the CI report file #[arg(long)] pub output_file: Option, /// Fail with non-zero exit code when restrictive licenses are found #[arg(long)] pub fail_on_restrictive: bool, /// Show only incompatible dependencies #[arg(long)] pub incompatible: bool, /// Fail with non-zero exit code when incompatible licenses are found #[arg(long)] pub fail_on_incompatible: bool, /// Specify the project license (overrides auto-detection) #[arg(long)] pub project_license: Option, // Show a concise gist summary #[arg(long, group = "output")] pub gist: bool, /// Filter by OSI license approval status #[arg(long, value_enum)] pub osi: Option, /// Enable strict mode for license parser #[arg(long)] pub strict: bool, /// Skip local license detection, force network lookup only #[arg(long)] pub no_local: bool, } impl Cli { /// Get the command arguments pub fn get_command_args(&self) -> Commands { match &self.command { Some(cmd) => cmd.clone(), None => { // No subcommand provided - default to license analysis Commands::Generate { path: "".to_string(), language: None, project_license: None, } } } } /// Check if this is the default behavior pub fn is_default_command(&self) -> bool { self.command.is_none() } } fn format_before_help() -> String { format!( "{}\n{}\n{}", "┌───────────────────────────────────────────┐".bright_cyan(), "│ FELUDA LICENSE CHECKER │" .bright_cyan() .bold(), "└───────────────────────────────────────────┘".bright_cyan() ) } // Function to print a customized version info pub fn print_version_info() { // Get version from Cargo.toml using env! let version = env!("CARGO_PKG_VERSION"); let title = format!("Feluda v{version}"); let width = title.len() + 4; let border = "─".repeat(width); println!("{}", format!("┌{border}┐").bright_red()); println!( "{}", format!("│ {} │", title.bright_white().bold()).bright_red() ); println!("{}", format!("└{border}┘").bright_red()); println!( "{}", "\nA dependency license checker written in Rust.".bright_yellow() ); println!( "{}", "Checks for permissive and restrictive licenses.".bright_yellow() ); println!( "{}", "\nFound Feluda useful? ✨ Star the repository:" .yellow() .bold() ); println!( "{}", "https://github.com/anistark/feluda".blue().underline() ); } /// A loading indicator that displays a spinner and progress updates /// without deleting the previous line pub struct LoadingIndicator { message: String, running: Arc, spinner_frames: Vec<&'static str>, handle: Option>, progress: Arc>>, } impl LoadingIndicator { pub fn new(message: &str) -> Self { Self { message: message.to_string(), running: Arc::new(AtomicBool::new(true)), spinner_frames: vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"], handle: None, progress: Arc::new(Mutex::new(None)), } } pub fn start(&mut self) { if is_debug_mode() { // In debug mode, just log the message without spinner log(LogLevel::Info, &format!("Operation: {}", self.message)); return; } let message = self.message.clone(); let running = self.running.clone(); let spinner_frames = self.spinner_frames.clone(); let progress = self.progress.clone(); // Clear the current line and move to beginning print!("\x1B[2K\r"); // Print initial message with spinner print!("{} {} ", spinner_frames[0].cyan(), message); io::stdout().flush().unwrap(); let handle = thread::spawn(move || { let mut frame_idx = 0; while running.load(Ordering::Relaxed) { frame_idx = (frame_idx + 1) % spinner_frames.len(); // Clear the current line and move to beginning print!("\x1B[2K\r"); // Print spinner and message let spinner_char = spinner_frames[frame_idx]; print!("{} {} ", spinner_char.cyan(), message); // Print progress info if available if let Some(ref progress_text) = *progress.lock().unwrap() { print!("({progress_text})"); } io::stdout().flush().unwrap(); thread::sleep(Duration::from_millis(80)); } // Clear line and print completion message print!("\x1B[2K\r"); print!("{} {} ", "✓".green().bold(), message); if let Some(ref progress_text) = *progress.lock().unwrap() { print!("({progress_text})"); } println!(" ✅"); io::stdout().flush().unwrap(); }); self.handle = Some(handle); } pub fn update_progress(&self, progress_text: &str) { if let Ok(mut guard) = self.progress.lock() { *guard = Some(progress_text.to_string()); } } pub fn stop(&mut self) { self.running.store(false, Ordering::Relaxed); if let Some(handle) = self.handle.take() { // Wait for spinner thread to finish its final update let _ = handle.join(); } } } /// Execute a function with a loading indicator /// /// This function provides a loading indicator with spinner while the provided /// function is running. The function is passed a reference to the loading /// indicator, which can be used to update the progress display. /// /// # Examples /// /// ``` /// let result = with_spinner("Processing data", |indicator| { /// // Initial work /// let data = prepare_data(); /// /// // Update progress /// indicator.update_progress(&format!("processed {} items", data.len())); /// /// // Continue processing /// process_data(data) /// }); /// ``` pub fn with_spinner(message: &str, f: F) -> T where F: FnOnce(&LoadingIndicator) -> T, { if is_debug_mode() { log(LogLevel::Info, &format!("Operation: {message}")); let start = std::time::Instant::now(); let indicator = LoadingIndicator::new(message); let result = f(&indicator); let duration = start.elapsed(); log(LogLevel::Info, &format!("Completed in {duration:?}")); result } else { let mut indicator = LoadingIndicator::new(message); indicator.start(); let result = f(&indicator); indicator.stop(); result } } #[cfg(test)] mod tests { use super::*; #[test] fn test_loading_indicator() { // This is a simple test to ensure the LoadingIndicator can be created and used let indicator = LoadingIndicator::new("Test operation"); // Removed 'mut' as it's unused indicator.update_progress("step 1"); indicator.update_progress("step 2"); // In a real test, we would start the indicator but that would create output // during tests, so we'll skip that part assert!(indicator.handle.is_none()); } #[test] fn test_with_spinner() { // Test using with_spinner for a simple operation let result = with_spinner("Test operation", |indicator| { indicator.update_progress("working"); // Return value directly instead of using an intermediate variable 42 }); assert_eq!(result, 42); } #[test] fn test_cli_default_values() { let cli = Cli { debug: false, command: None, path: "./".to_string(), repo: None, token: None, ssh_key: None, ssh_passphrase: None, github_token: None, json: false, yaml: false, verbose: false, restrictive: false, gui: false, language: None, ci_format: None, output_file: None, fail_on_restrictive: false, incompatible: false, fail_on_incompatible: false, project_license: None, gist: false, osi: None, strict: false, no_local: false, }; assert_eq!(cli.path, "./"); assert!(!cli.debug); assert!(!cli.json); assert!(!cli.restrictive); assert!(!cli.strict); assert!(!cli.no_local); assert!(cli.github_token.is_none()); assert!(cli.is_default_command()); } #[test] fn test_get_command_args_with_command() { let cli = Cli { debug: false, command: Some(Commands::Generate { path: "/test/path".to_string(), language: Some("rust".to_string()), project_license: Some("MIT".to_string()), }), path: "./".to_string(), repo: None, token: None, ssh_key: None, ssh_passphrase: None, github_token: None, json: false, yaml: false, verbose: false, restrictive: false, gui: false, language: None, ci_format: None, output_file: None, fail_on_restrictive: false, incompatible: false, fail_on_incompatible: false, project_license: None, gist: false, osi: None, strict: false, no_local: false, }; let cmd = cli.get_command_args(); match cmd { Commands::Generate { path, language, project_license, } => { assert_eq!(path, "/test/path"); assert_eq!(language, Some("rust".to_string())); assert_eq!(project_license, Some("MIT".to_string())); } Commands::Sbom { .. } => { panic!("Expected Generate command"); } Commands::Cache { .. } => { panic!("Expected Generate command"); } } assert!(!cli.is_default_command()); } #[test] fn test_get_command_args_default() { let cli = Cli { debug: false, command: None, path: "./test".to_string(), repo: None, token: None, ssh_key: None, ssh_passphrase: None, github_token: None, json: false, yaml: false, verbose: false, restrictive: false, gui: false, language: None, ci_format: None, output_file: None, fail_on_restrictive: false, incompatible: false, fail_on_incompatible: false, project_license: None, gist: false, osi: None, strict: false, no_local: false, }; let cmd = cli.get_command_args(); match cmd { Commands::Generate { path, language, project_license, } => { assert_eq!(path, ""); assert_eq!(language, None); assert_eq!(project_license, None); } Commands::Sbom { .. } => { panic!("Expected Generate command"); } Commands::Cache { .. } => { panic!("Expected Generate command"); } } } #[test] fn test_loading_indicator_new() { let indicator = LoadingIndicator::new("Test message"); assert_eq!(indicator.message, "Test message"); assert!(indicator.running.load(Ordering::Relaxed)); assert!(indicator.handle.is_none()); assert_eq!(indicator.spinner_frames.len(), 10); } #[test] fn test_loading_indicator_update_progress() { let indicator = LoadingIndicator::new("Test"); indicator.update_progress("step 1"); let progress = indicator.progress.lock().unwrap(); assert_eq!(*progress, Some("step 1".to_string())); drop(progress); indicator.update_progress("step 2"); let progress = indicator.progress.lock().unwrap(); assert_eq!(*progress, Some("step 2".to_string())); } #[test] fn test_with_spinner_execution() { let result = with_spinner("Test operation", |indicator| { indicator.update_progress("working"); 42 }); assert_eq!(result, 42); } #[test] fn test_with_spinner_with_error() { let result = std::panic::catch_unwind(|| { with_spinner("Test operation", |_indicator| { panic!("Test panic"); }) }); assert!(result.is_err()); } #[test] fn test_format_before_help() { let help_text = format_before_help(); assert!(help_text.contains("FELUDA LICENSE CHECKER")); assert!(help_text.contains("┌")); assert!(help_text.contains("└")); assert!(help_text.contains("│")); } #[test] fn test_print_version_info() { print_version_info(); } #[test] fn test_ci_format_enum() { let github = CiFormat::Github; let jenkins = CiFormat::Jenkins; assert_ne!(format!("{github:?}"), format!("{:?}", jenkins)); let github_clone = github.clone(); assert_eq!(format!("{github:?}"), format!("{:?}", github_clone)); } #[test] fn test_commands_enum_clone() { let generate_cmd = Commands::Generate { path: "./".to_string(), language: None, project_license: None, }; let cloned_cmd = generate_cmd.clone(); match (generate_cmd, cloned_cmd) { ( Commands::Generate { path: p1, language: l1, project_license: pl1, }, Commands::Generate { path: p2, language: l2, project_license: pl2, }, ) => { assert_eq!(p1, p2); assert_eq!(l1, l2); assert_eq!(pl1, pl2); } _ => { panic!("Expected both commands to be Generate"); } } } #[test] fn test_loading_indicator_multiple_progress_updates() { let indicator = LoadingIndicator::new("Multi-step test"); for i in 1..=5 { indicator.update_progress(&format!("step {i}")); let progress = indicator.progress.lock().unwrap(); assert_eq!(*progress, Some(format!("step {i}"))); drop(progress); } } #[test] fn test_sbom_command_default_all() { let sbom_cmd = Commands::Sbom { path: "./".to_string(), format: None, output: None, }; match sbom_cmd { Commands::Sbom { path, format, output, } => { assert_eq!(path, "./"); assert!(format.is_none()); assert!(output.is_none()); } _ => panic!("Expected Sbom command"), } } #[test] fn test_sbom_command_spdx() { let sbom_cmd = Commands::Sbom { path: "/project".to_string(), format: Some(SbomCommand::Spdx { path: "/project".to_string(), output: Some("sbom.json".to_string()), }), output: None, }; match sbom_cmd { Commands::Sbom { path, format, output, } => { assert_eq!(path, "/project"); assert!(format.is_some()); assert!(output.is_none()); match format.unwrap() { SbomCommand::Spdx { path: p, output: o } => { assert_eq!(p, "/project"); assert_eq!(o, Some("sbom.json".to_string())); } _ => panic!("Expected Spdx subcommand"), } } _ => panic!("Expected Sbom command"), } } #[test] fn test_sbom_command_cyclonedx() { let sbom_cmd = Commands::Sbom { path: "/project".to_string(), format: Some(SbomCommand::Cyclonedx { path: "/project".to_string(), output: Some("sbom.xml".to_string()), }), output: None, }; match sbom_cmd { Commands::Sbom { path, format, output, } => { assert_eq!(path, "/project"); assert!(format.is_some()); assert!(output.is_none()); match format.unwrap() { SbomCommand::Cyclonedx { path: p, output: o } => { assert_eq!(p, "/project"); assert_eq!(o, Some("sbom.xml".to_string())); } _ => panic!("Expected Cyclonedx subcommand"), } } _ => panic!("Expected Sbom command"), } } } feluda-1.11.1/src/config.rs000064400000000000000000001365471046102023000135670ustar 00000000000000//! Configuration handling for Feluda //! //! This module provides functionality to load and manage configuration settings. //! Configuration can be provided through: //! //! 1. Default values (built into the binary) //! 2. `.feluda.toml` file in the project root //! 3. Environment variables prefixed with `FELUDA_` //! //! # Configuration File Example //! //! ```toml //! [licenses] //! # Override the default list of restrictive licenses //! restrictive = [ //! "GPL-3.0", # GNU General Public License v3.0 //! "AGPL-3.0", # GNU Affero General Public License v3.0 //! "LGPL-3.0", # GNU Lesser General Public License v3.0 //! ] //! //! # Licenses to ignore from analysis //! ignore = [ //! "MIT", # MIT License //! "Apache-2.0", # Apache License 2.0 //! ] //! //! [[dependencies.ignore]] //! name = "github.com/opcotech/elemo-pre-mailer" //! version = "v1.0.0" //! reason = "This is within the same repo as the project, hence it shares the same license." //! //! [[dependencies.ignore]] //! name = "something-else" //! version = "" # Empty version means ignore all versions of this dependency //! reason = "We have a written acknowledgment from the author that we may use their code under our license." //! ``` //! //! # Environment Variables //! //! Configuration can be overridden using environment variables: //! //! ```sh //! # Override restrictive licenses list //! export FELUDA_LICENSES_RESTRICTIVE='["GPL-3.0","AGPL-3.0"]' //! # Override ignore licenses list //! export FELUDA_LICENSES_IGNORE='["MIT","Apache-2.0"]' //! ``` use figment::{ providers::{Env, Format, Serialized, Toml}, Figment, }; use serde::{Deserialize, Serialize}; use std::path::Path; use crate::debug::{log, log_debug, log_error, FeludaError, FeludaResult, LogLevel}; /// Main configuration structure for Feluda #[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct FeludaConfig { #[serde(default)] pub licenses: LicenseConfig, #[serde(default)] pub dependencies: DependencyConfig, #[serde(default)] pub strict: bool, } impl FeludaConfig { /// Validates the configuration for logical consistency and correctness pub fn validate(&self) -> FeludaResult<()> { self.licenses.validate()?; self.dependencies.validate()?; Ok(()) } } /// Configuration for license-related settings /// /// By default, the following licenses are considered restrictive: /// - GPL-3.0 /// - AGPL-3.0 /// - LGPL-3.0 /// - MPL-2.0 /// - SEE LICENSE IN LICENSE /// - CC-BY-SA-4.0 /// - EPL-2.0 /// /// This can be overridden via `.feluda.toml` or environment variables. #[derive(Debug, Deserialize, Serialize, Clone)] pub struct LicenseConfig { #[serde(default = "default_restrictive_licenses")] pub restrictive: Vec, #[serde(default)] pub ignore: Vec, } impl Default for LicenseConfig { fn default() -> Self { Self { restrictive: default_restrictive_licenses(), ignore: Vec::new(), } } } impl LicenseConfig { /// Validates the license configuration pub fn validate(&self) -> FeludaResult<()> { // Check for empty restrictive licenses list if self.restrictive.is_empty() { log( LogLevel::Warn, "No restrictive licenses configured - all licenses will be considered acceptable", ); } // Check for duplicate licenses in restrictive list let mut seen = std::collections::HashSet::new(); let mut duplicates = Vec::new(); for license in &self.restrictive { if license.trim().is_empty() { return Err(FeludaError::Config( "Empty license string found in restrictive licenses list".to_string(), )); } if !seen.insert(license) { duplicates.push(license.clone()); } } if !duplicates.is_empty() { return Err(FeludaError::Config(format!( "Duplicate licenses found in restrictive list: {}", duplicates.join(", ") ))); } // Validate license format for restrictive licenses (basic SPDX-like validation) for license in &self.restrictive { if !Self::is_valid_license_identifier(license) { log( LogLevel::Warn, &format!("License '{license}' may not be a valid SPDX identifier"), ); } } // Validate ignore licenses list let mut ignore_seen = std::collections::HashSet::new(); let mut ignore_duplicates = Vec::new(); for license in &self.ignore { if license.trim().is_empty() { return Err(FeludaError::Config( "Empty license string found in ignore licenses list".to_string(), )); } if !ignore_seen.insert(license) { ignore_duplicates.push(license.clone()); } } if !ignore_duplicates.is_empty() { return Err(FeludaError::Config(format!( "Duplicate licenses found in ignore list: {}", ignore_duplicates.join(", ") ))); } // Validate license format for ignore licenses for license in &self.ignore { if !Self::is_valid_license_identifier(license) { log( LogLevel::Warn, &format!( "License '{license}' in ignore list may not be a valid SPDX identifier" ), ); } } // Check for overlap between restrictive and ignore lists let restrictive_set: std::collections::HashSet<_> = self.restrictive.iter().collect(); let ignore_set: std::collections::HashSet<_> = self.ignore.iter().collect(); let overlap: Vec<_> = restrictive_set .intersection(&ignore_set) .map(|s| s.to_string()) .collect(); if !overlap.is_empty() { log( LogLevel::Warn, &format!( "Licenses found in both restrictive and ignore lists will be ignored: {}", overlap.join(", ") ), ); } log_debug("License configuration validation passed", &self.restrictive); log_debug("Ignore licenses configuration", &self.ignore); Ok(()) } /// Basic validation for license identifiers fn is_valid_license_identifier(license: &str) -> bool { let license = license.trim(); // Special cases that are valid but don't follow standard patterns if matches!( license, "SEE LICENSE IN LICENSE" | "UNLICENSED" | "NOASSERTION" ) { return true; } // Basic pattern: should contain only alphanumeric, dots, hyphens, plus, parentheses // TODO: Improve with a full SPDX regex license .chars() .all(|c| c.is_alphanumeric() || matches!(c, '.' | '-' | '+' | '(' | ')' | '_')) && !license.is_empty() && license.len() <= 100 } } /// Configuration for dependency-related settings #[derive(Debug, Deserialize, Serialize, Clone)] pub struct DependencyConfig { /// Maximum depth for transitive dependency resolution /// Default is 10 levels deep #[serde(default = "default_max_depth")] pub max_depth: u32, /// Dependencies to exclude from license scanning #[serde(default)] pub ignore: Vec, } /// Configuration for a dependency to ignore #[derive(Debug, Deserialize, Serialize, Clone)] pub struct IgnoreDependency { /// The name/identifier of the dependency (e.g., "github.com/opcotech/elemo-pre-mailer") pub name: String, /// The version of the dependency. Leave empty to ignore all versions. #[serde(default)] pub version: String, /// Reason for ignoring this dependency #[serde(default)] pub reason: String, } impl Default for DependencyConfig { fn default() -> Self { Self { max_depth: default_max_depth(), ignore: Vec::new(), } } } impl DependencyConfig { /// Validates the dependency configuration pub fn validate(&self) -> FeludaResult<()> { // Validate max_depth is within reasonable bounds if self.max_depth == 0 { return Err(FeludaError::Config( "max_depth must be greater than 0".to_string(), )); } if self.max_depth > 100 { return Err(FeludaError::Config( "max_depth must be 100 or less to prevent excessive resource usage".to_string(), )); } if self.max_depth > 50 { log( LogLevel::Warn, &format!( "max_depth of {} is quite high and may impact performance", self.max_depth ), ); } // Validate ignore dependencies for dep in self.ignore.iter() { if dep.name.trim().is_empty() { return Err(FeludaError::Config( "Empty dependency name found in ignore list".to_string(), )); } // Warn if reason is empty if dep.reason.trim().is_empty() { log( LogLevel::Warn, &format!( "Dependency '{}' in ignore list has no reason specified", dep.name ), ); } } // Check for duplicate dependencies in ignore list let mut seen = std::collections::HashSet::new(); let mut duplicates = Vec::new(); for dep in &self.ignore { let key = (dep.name.clone(), dep.version.clone()); if !seen.insert(key.clone()) { duplicates.push(format!("{}@{}", dep.name, dep.version)); } } if !duplicates.is_empty() { return Err(FeludaError::Config(format!( "Duplicate dependencies found in ignore list: {}", duplicates.join(", ") ))); } if !self.ignore.is_empty() { log_debug("Dependency ignore list", &self.ignore.len()); } log_debug( "Dependency configuration validation passed", &self.max_depth, ); Ok(()) } /// Check if a dependency should be ignored based on configuration /// Returns true if the dependency matches an ignore rule (name and optionally version) pub fn should_ignore_dependency(&self, name: &str, version: Option<&str>) -> bool { self.ignore.iter().any(|ignored| { // Match by name (case-sensitive) if ignored.name != name { return false; } // If version is specified in ignore rule, match exactly if !ignored.version.is_empty() { return version.is_some_and(|v| v == ignored.version); } // If version is empty in ignore rule, ignore all versions true }) } } /// Returns the default maximum depth for dependency resolution fn default_max_depth() -> u32 { 10 } /// Returns the default list of restrictive licenses fn default_restrictive_licenses() -> Vec { let licenses = vec![ "GPL-3.0", "AGPL-3.0", "LGPL-3.0", "MPL-2.0", "SEE LICENSE IN LICENSE", "CC-BY-SA-4.0", "EPL-2.0", ] .into_iter() .map(String::from) .collect(); log_debug("Default restrictive licenses", &licenses); licenses } /// Loads the configuration using the following providers (in order of precedence): /// /// 1. Environment variables prefixed with `FELUDA_` /// 2. `.feluda.toml` file in the project root /// 3. Default values /// /// # Environment Variables /// /// Environment variables are transformed by: /// 1. Removing the `FELUDA_` prefix /// 2. Converting to lowercase /// 3. Converting underscores to dots for nested keys /// /// For example: /// - `FELUDA_LICENSES_RESTRICTIVE` -> `licenses.restrictive` pub fn load_config() -> FeludaResult { log(LogLevel::Info, "Loading Feluda configuration"); // Start with default values let mut figment = Figment::new().merge(Serialized::defaults(FeludaConfig::default())); // Check if .feluda.toml exists and add it if it does let config_path = Path::new(".feluda.toml"); if config_path.exists() { log( LogLevel::Info, &format!("Found configuration file: {}", config_path.display()), ); figment = figment.merge(Toml::file(config_path)); } else { log(LogLevel::Info, "No .feluda.toml file found, using defaults"); } // Add environment variables figment = figment.merge(Env::prefixed("FELUDA_").split("_")); log(LogLevel::Info, "Checking for FELUDA_ environment variables"); // Extract the final configuration match figment.extract::() { Ok(config) => { log(LogLevel::Info, "Configuration loaded successfully"); log_debug("Loaded configuration", &config); // Validate the configuration if let Err(e) = config.validate() { log_error("Configuration validation failed", &e); return Err(e); } log(LogLevel::Info, "Configuration validation passed"); Ok(config) } Err(e) => { log_error("Failed to extract configuration", &e); Err(FeludaError::Config(format!( "Failed to extract configuration: {e}" ))) } } } // Remove the unused function // Keep it in the tests but commented out for reference // pub fn has_env_var(var_name: &str) -> bool { // std::env::var(format!("FELUDA_{}", var_name)).is_ok() // } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; fn setup() -> TempDir { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); dir } #[test] fn test_default_config() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 7); assert!(config.licenses.restrictive.contains(&"GPL-3.0".to_string())); }); } #[test] fn test_toml_config() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = ["TEST-1.0", "TEST-2.0"] [dependencies] max_depth = 5"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 2); assert!(config .licenses .restrictive .contains(&"TEST-1.0".to_string())); assert!(config .licenses .restrictive .contains(&"TEST-2.0".to_string())); assert_eq!(config.dependencies.max_depth, 5); }); } #[test] fn test_env_config() { temp_env::with_vars( vec![( "FELUDA_LICENSES_RESTRICTIVE", Some(r#"["ENV-1.0","ENV-2.0"]"#), )], || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 2); assert!(config.licenses.restrictive.contains(&"ENV-1.0".to_string())); assert!(config.licenses.restrictive.contains(&"ENV-2.0".to_string())); }, ); } #[test] fn test_env_overrides_toml() { temp_env::with_var( "FELUDA_LICENSES_RESTRICTIVE", Some(r#"["ENV-1.0"]"#), || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = ["TOML-1.0", "TOML-2.0"]"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 1); assert!(config.licenses.restrictive.contains(&"ENV-1.0".to_string())); }, ); } #[test] fn test_license_config_default() { let config = LicenseConfig::default(); assert_eq!(config.restrictive.len(), 7); assert!(config.restrictive.contains(&"GPL-3.0".to_string())); assert!(config.restrictive.contains(&"AGPL-3.0".to_string())); assert!(config.restrictive.contains(&"LGPL-3.0".to_string())); assert!(config.restrictive.contains(&"MPL-2.0".to_string())); assert!(config .restrictive .contains(&"SEE LICENSE IN LICENSE".to_string())); assert!(config.restrictive.contains(&"CC-BY-SA-4.0".to_string())); assert!(config.restrictive.contains(&"EPL-2.0".to_string())); } #[test] fn test_feluda_config_default() { let config = FeludaConfig::default(); assert_eq!(config.licenses.restrictive.len(), 7); } #[test] fn test_default_restrictive_licenses() { let licenses = default_restrictive_licenses(); assert_eq!(licenses.len(), 7); assert!(licenses.contains(&"GPL-3.0".to_string())); assert!(licenses.contains(&"AGPL-3.0".to_string())); assert!(licenses.contains(&"LGPL-3.0".to_string())); assert!(licenses.contains(&"MPL-2.0".to_string())); assert!(licenses.contains(&"SEE LICENSE IN LICENSE".to_string())); assert!(licenses.contains(&"CC-BY-SA-4.0".to_string())); assert!(licenses.contains(&"EPL-2.0".to_string())); } #[test] fn test_load_config_missing_file() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 7); assert!(config.licenses.restrictive.contains(&"GPL-3.0".to_string())); }); } #[test] fn test_load_config_invalid_toml() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write(".feluda.toml", "invalid toml content [[[").unwrap(); let result = load_config(); assert!(result.is_err()); }); } #[test] fn test_load_config_partial_toml() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"# This is a comment [other_section] some_field = "value" "#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 7); }); } #[test] fn test_load_config_empty_restrictive_list() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = []"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 0); }); } #[test] fn test_load_config_env_invalid_json() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", Some("invalid json"), || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); let result = load_config(); assert!(result.is_err()); }); } #[test] fn test_load_config_nested_env_variables() { temp_env::with_vars( vec![ ("FELUDA_LICENSES_RESTRICTIVE", Some(r#"["CUSTOM-1.0"]"#)), ("FELUDA_OTHER_SETTING", Some("value")), ], || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 1); assert!(config .licenses .restrictive .contains(&"CUSTOM-1.0".to_string())); }, ); } #[test] fn test_load_config_precedence_order() { // Test that environment variables override TOML config temp_env::with_var( "FELUDA_LICENSES_RESTRICTIVE", Some(r#"["ENV-LICENSE"]"#), || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); // Create TOML with different values fs::write( ".feluda.toml", r#"[licenses] restrictive = ["TOML-LICENSE-1", "TOML-LICENSE-2"]"#, ) .unwrap(); let config = load_config().unwrap(); // Should use environment variable value, not TOML assert_eq!(config.licenses.restrictive.len(), 1); assert!(config .licenses .restrictive .contains(&"ENV-LICENSE".to_string())); assert!(!config .licenses .restrictive .contains(&"TOML-LICENSE-1".to_string())); }, ); } #[test] fn test_config_serialization() { let config = FeludaConfig { strict: false, licenses: LicenseConfig { restrictive: vec!["TEST-1.0".to_string(), "TEST-2.0".to_string()], ignore: Vec::new(), }, dependencies: DependencyConfig { max_depth: 5, ignore: Vec::new(), }, }; // Test that config can be serialized and deserialized let serialized = toml::to_string(&config).unwrap(); assert!(serialized.contains("TEST-1.0")); assert!(serialized.contains("TEST-2.0")); let deserialized: FeludaConfig = toml::from_str(&serialized).unwrap(); assert_eq!(deserialized.licenses.restrictive.len(), 2); assert!(deserialized .licenses .restrictive .contains(&"TEST-1.0".to_string())); } #[test] fn test_config_debug_output() { let config = FeludaConfig::default(); let debug_str = format!("{config:?}"); assert!(debug_str.contains("FeludaConfig")); assert!(debug_str.contains("licenses")); assert!(debug_str.contains("restrictive")); } #[test] fn test_license_config_serde() { let config = LicenseConfig { restrictive: vec!["MIT".to_string(), "Apache-2.0".to_string()], ignore: Vec::new(), }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("MIT")); assert!(json.contains("Apache-2.0")); let deserialized: LicenseConfig = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.restrictive.len(), 2); } #[test] fn test_load_config_with_comments() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"# Feluda configuration file # This file configures license checking behavior [licenses] # List of licenses that are considered restrictive restrictive = [ "GPL-3.0", # GNU General Public License "CUSTOM-1.0", # Custom restrictive license ] "#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 2); assert!(config.licenses.restrictive.contains(&"GPL-3.0".to_string())); assert!(config .licenses .restrictive .contains(&"CUSTOM-1.0".to_string())); }); } #[test] fn test_load_config_env_array_format() { temp_env::with_var( "FELUDA_LICENSES_RESTRICTIVE", Some(r#"["License-1", "License-2", "License-3"]"#), || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 3); assert!(config .licenses .restrictive .contains(&"License-1".to_string())); assert!(config .licenses .restrictive .contains(&"License-2".to_string())); assert!(config .licenses .restrictive .contains(&"License-3".to_string())); }, ); } #[test] fn test_load_config_case_sensitivity() { temp_env::with_vars( vec![ ("FELUDA_LICENSES_RESTRICTIVE", None::<&str>), ("OTHER_LICENSES_RESTRICTIVE", Some(r#"["WRONG-PREFIX"]"#)), ], || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 7); assert!(config.licenses.restrictive.contains(&"GPL-3.0".to_string())); assert!(!config .licenses .restrictive .contains(&"WRONG-PREFIX".to_string())); }, ); } // Validation tests #[test] fn test_license_config_validation_empty_list() { let config = LicenseConfig { restrictive: vec![], ignore: Vec::new(), }; // Empty list should pass validation but generate a warning assert!(config.validate().is_ok()); } #[test] fn test_license_config_validation_empty_license() { let config = LicenseConfig { restrictive: vec!["MIT".to_string(), "".to_string(), "GPL-3.0".to_string()], ignore: Vec::new(), }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Empty license string")); } #[test] fn test_license_config_validation_duplicate_licenses() { let config = LicenseConfig { restrictive: vec![ "MIT".to_string(), "GPL-3.0".to_string(), "MIT".to_string(), "Apache-2.0".to_string(), ], ignore: Vec::new(), }; let result = config.validate(); assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("Duplicate licenses")); assert!(error_msg.contains("MIT")); } #[test] fn test_license_config_validation_valid_licenses() { let config = LicenseConfig { restrictive: vec![ "MIT".to_string(), "Apache-2.0".to_string(), "GPL-3.0".to_string(), "SEE LICENSE IN LICENSE".to_string(), ], ignore: Vec::new(), }; assert!(config.validate().is_ok()); } #[test] fn test_license_identifier_validation() { assert!(LicenseConfig::is_valid_license_identifier("MIT")); assert!(LicenseConfig::is_valid_license_identifier("Apache-2.0")); assert!(LicenseConfig::is_valid_license_identifier("GPL-3.0+")); assert!(LicenseConfig::is_valid_license_identifier( "SEE LICENSE IN LICENSE" )); assert!(LicenseConfig::is_valid_license_identifier("UNLICENSED")); assert!(LicenseConfig::is_valid_license_identifier("NOASSERTION")); assert!(!LicenseConfig::is_valid_license_identifier("")); assert!(!LicenseConfig::is_valid_license_identifier( &"x".repeat(101) )); // Too long } #[test] fn test_dependency_config_validation_zero_depth() { let config = DependencyConfig { max_depth: 0, ignore: Vec::new(), }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("must be greater than 0")); } #[test] fn test_dependency_config_validation_excessive_depth() { let config = DependencyConfig { max_depth: 150, ignore: Vec::new(), }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("must be 100 or less")); } #[test] fn test_dependency_config_validation_high_depth_warning() { let config = DependencyConfig { max_depth: 75, ignore: Vec::new(), }; // Should pass validation but generate a warning assert!(config.validate().is_ok()); } #[test] fn test_dependency_config_validation_valid_depth() { let config = DependencyConfig { max_depth: 10, ignore: Vec::new(), }; assert!(config.validate().is_ok()); } #[test] fn test_feluda_config_validation_success() { let config = FeludaConfig { strict: false, licenses: LicenseConfig { restrictive: vec!["MIT".to_string(), "GPL-3.0".to_string()], ignore: Vec::new(), }, dependencies: DependencyConfig { max_depth: 10, ignore: Vec::new(), }, }; assert!(config.validate().is_ok()); } #[test] fn test_feluda_config_validation_license_failure() { let config = FeludaConfig { strict: false, licenses: LicenseConfig { restrictive: vec!["".to_string()], // Invalid empty license ignore: Vec::new(), }, dependencies: DependencyConfig { max_depth: 10, ignore: Vec::new(), }, }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Empty license string")); } #[test] fn test_feluda_config_validation_dependency_failure() { let config = FeludaConfig { strict: false, licenses: LicenseConfig { restrictive: vec!["MIT".to_string()], ignore: Vec::new(), }, dependencies: DependencyConfig { max_depth: 0, ignore: Vec::new(), }, // Invalid zero depth }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("must be greater than 0")); } #[test] fn test_load_config_validation_integration() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = ["MIT", "GPL-3.0"] [dependencies] max_depth = 15"#, ) .unwrap(); // Should pass validation let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 2); assert_eq!(config.dependencies.max_depth, 15); }); } #[test] fn test_load_config_validation_failure() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = ["MIT", ""] [dependencies] max_depth = 5"#, ) .unwrap(); // Should fail validation due to empty license string let result = load_config(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Empty license string")); }); } #[test] fn test_load_config_correct_env_var() { temp_env::with_var( "FELUDA_LICENSES_RESTRICTIVE", Some(r#"["CUSTOM-LICENSE"]"#), || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 1); assert!(config .licenses .restrictive .contains(&"CUSTOM-LICENSE".to_string())); }, ); } // Tests for ignore licenses functionality #[test] fn test_toml_config_with_ignore() { temp_env::with_var("FELUDA_LICENSES_IGNORE", None::<&str>, || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = ["GPL-3.0"] ignore = ["MIT", "Apache-2.0"]"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.ignore.len(), 2); assert!(config.licenses.ignore.contains(&"MIT".to_string())); assert!(config.licenses.ignore.contains(&"Apache-2.0".to_string())); }); } #[test] fn test_env_config_with_ignore() { temp_env::with_var( "FELUDA_LICENSES_IGNORE", Some(r#"["MIT","BSD-3-Clause"]"#), || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.ignore.len(), 2); assert!(config.licenses.ignore.contains(&"MIT".to_string())); assert!(config.licenses.ignore.contains(&"BSD-3-Clause".to_string())); }, ); } #[test] fn test_env_ignore_overrides_toml() { temp_env::with_var("FELUDA_LICENSES_IGNORE", Some(r#"["ENV-IGNORE"]"#), || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] ignore = ["TOML-IGNORE-1", "TOML-IGNORE-2"]"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.ignore.len(), 1); assert!(config.licenses.ignore.contains(&"ENV-IGNORE".to_string())); }); } #[test] fn test_empty_ignore_list() { temp_env::with_var("FELUDA_LICENSES_IGNORE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] ignore = []"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.ignore.len(), 0); }); } #[test] fn test_license_config_validation_ignore_empty_license() { let config = LicenseConfig { restrictive: vec!["GPL-3.0".to_string()], ignore: vec!["MIT".to_string(), "".to_string(), "Apache-2.0".to_string()], }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Empty license string")); } #[test] fn test_license_config_validation_ignore_duplicate_licenses() { let config = LicenseConfig { restrictive: vec!["GPL-3.0".to_string()], ignore: vec![ "MIT".to_string(), "Apache-2.0".to_string(), "MIT".to_string(), ], }; let result = config.validate(); assert!(result.is_err()); let error_msg = result.unwrap_err().to_string(); assert!(error_msg.contains("Duplicate licenses")); assert!(error_msg.contains("ignore list")); } #[test] fn test_license_config_validation_ignore_overlap_with_restrictive() { let config = LicenseConfig { restrictive: vec!["GPL-3.0".to_string(), "MIT".to_string()], ignore: vec!["MIT".to_string(), "Apache-2.0".to_string()], }; // Should pass validation but generate a warning assert!(config.validate().is_ok()); } #[test] fn test_license_config_with_all_fields() { let config = LicenseConfig { restrictive: vec!["GPL-3.0".to_string(), "AGPL-3.0".to_string()], ignore: vec!["MIT".to_string(), "Apache-2.0".to_string()], }; assert!(config.validate().is_ok()); assert_eq!(config.restrictive.len(), 2); assert_eq!(config.ignore.len(), 2); } #[test] fn test_load_config_toml_with_comments() { temp_env::with_var("FELUDA_LICENSES_IGNORE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"# Feluda configuration file [licenses] # List of restrictive licenses restrictive = ["GPL-3.0"] # Licenses to ignore ignore = [ "MIT", # MIT License "Apache-2.0", # Apache License ]"#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.licenses.restrictive.len(), 1); assert_eq!(config.licenses.ignore.len(), 2); assert!(config.licenses.ignore.contains(&"MIT".to_string())); assert!(config.licenses.ignore.contains(&"Apache-2.0".to_string())); }); } #[test] fn test_default_ignore_list_is_empty() { let config = FeludaConfig::default(); assert!(config.licenses.ignore.is_empty()); } #[test] fn test_load_config_ignore_serde() { let config = LicenseConfig { restrictive: vec!["GPL-3.0".to_string()], ignore: vec!["MIT".to_string(), "Apache-2.0".to_string()], }; let json = serde_json::to_string(&config).unwrap(); assert!(json.contains("MIT")); assert!(json.contains("Apache-2.0")); let deserialized: LicenseConfig = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.ignore.len(), 2); assert!(deserialized.ignore.contains(&"MIT".to_string())); } // Tests for dependency ignore functionality #[test] fn test_dependency_config_ignore_basic() { let config = DependencyConfig { max_depth: 10, ignore: vec![IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "Test reason".to_string(), }], }; assert!(config.should_ignore_dependency("lodash", Some("4.17.21"))); assert!(!config.should_ignore_dependency("lodash", Some("4.17.20"))); assert!(!config.should_ignore_dependency("underscore", Some("4.17.21"))); } #[test] fn test_dependency_config_ignore_all_versions() { let config = DependencyConfig { max_depth: 10, ignore: vec![IgnoreDependency { name: "lodash".to_string(), version: "".to_string(), reason: "Ignore all versions".to_string(), }], }; assert!(config.should_ignore_dependency("lodash", Some("4.17.21"))); assert!(config.should_ignore_dependency("lodash", Some("4.17.20"))); assert!(config.should_ignore_dependency("lodash", None)); assert!(!config.should_ignore_dependency("underscore", Some("1.0.0"))); } #[test] fn test_dependency_config_should_ignore_dependency_multiple() { let config = DependencyConfig { max_depth: 10, ignore: vec![ IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "Specific version".to_string(), }, IgnoreDependency { name: "underscore".to_string(), version: "".to_string(), reason: "All versions".to_string(), }, ], }; assert!(config.should_ignore_dependency("lodash", Some("4.17.21"))); assert!(!config.should_ignore_dependency("lodash", Some("4.17.20"))); assert!(config.should_ignore_dependency("underscore", Some("1.0.0"))); assert!(config.should_ignore_dependency("underscore", None)); } #[test] fn test_dependency_config_validation_empty_ignore() { let config = DependencyConfig { max_depth: 10, ignore: Vec::new(), }; assert!(config.validate().is_ok()); } #[test] fn test_dependency_config_validation_empty_name() { let config = DependencyConfig { max_depth: 10, ignore: vec![IgnoreDependency { name: "".to_string(), version: "1.0.0".to_string(), reason: "Test".to_string(), }], }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Empty dependency name")); } #[test] fn test_dependency_config_validation_duplicate_dependencies() { let config = DependencyConfig { max_depth: 10, ignore: vec![ IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "First".to_string(), }, IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "Second".to_string(), }, ], }; let result = config.validate(); assert!(result.is_err()); assert!(result .unwrap_err() .to_string() .contains("Duplicate dependencies")); } #[test] fn test_dependency_config_validation_no_reason_warning() { let config = DependencyConfig { max_depth: 10, ignore: vec![IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "".to_string(), }], }; // Should pass validation but generate a warning assert!(config.validate().is_ok()); } #[test] fn test_toml_config_with_dependency_ignore() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = tempfile::tempdir().unwrap(); std::env::set_current_dir(dir.path()).unwrap(); fs::write( ".feluda.toml", r#"[licenses] restrictive = ["GPL-3.0"] [[dependencies.ignore]] name = "lodash" version = "4.17.21" reason = "Dependency within same repo" [[dependencies.ignore]] name = "underscore" version = "" reason = "All versions ignored" "#, ) .unwrap(); let config = load_config().unwrap(); assert_eq!(config.dependencies.ignore.len(), 2); assert!(config .dependencies .should_ignore_dependency("lodash", Some("4.17.21"))); assert!(!config .dependencies .should_ignore_dependency("lodash", Some("4.17.20"))); assert!(config .dependencies .should_ignore_dependency("underscore", Some("1.0.0"))); }); } #[test] fn test_feluda_config_with_dependency_ignore() { let config = FeludaConfig { strict: false, licenses: LicenseConfig { restrictive: vec!["GPL-3.0".to_string()], ignore: Vec::new(), }, dependencies: DependencyConfig { max_depth: 10, ignore: vec![IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "Test".to_string(), }], }, }; assert!(config.validate().is_ok()); assert!(config .dependencies .should_ignore_dependency("lodash", Some("4.17.21"))); } #[test] fn test_dependency_ignore_serialization() { let dep = IgnoreDependency { name: "lodash".to_string(), version: "4.17.21".to_string(), reason: "Test reason".to_string(), }; let json = serde_json::to_string(&dep).unwrap(); assert!(json.contains("lodash")); assert!(json.contains("4.17.21")); assert!(json.contains("Test reason")); let deserialized: IgnoreDependency = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.name, "lodash"); assert_eq!(deserialized.version, "4.17.21"); assert_eq!(deserialized.reason, "Test reason"); } #[test] fn test_default_dependency_config() { let config = DependencyConfig::default(); assert_eq!(config.max_depth, 10); assert!(config.ignore.is_empty()); } #[test] fn test_dependency_ignore_empty_version_field() { let config = DependencyConfig { max_depth: 10, ignore: vec![ IgnoreDependency { name: "package1".to_string(), version: "".to_string(), reason: "Ignore all versions".to_string(), }, IgnoreDependency { name: "package2".to_string(), version: "1.0.0".to_string(), reason: "Ignore specific version".to_string(), }, ], }; assert!(config.should_ignore_dependency("package1", Some("any-version"))); assert!(config.should_ignore_dependency("package1", None)); assert!(config.should_ignore_dependency("package2", Some("1.0.0"))); assert!(!config.should_ignore_dependency("package2", Some("2.0.0"))); } } feluda-1.11.1/src/debug.rs000064400000000000000000000235771046102023000134060ustar 00000000000000use std::sync::atomic::{AtomicBool, Ordering}; // Static atomic flag for debug mode pub static DEBUG_MODE: AtomicBool = AtomicBool::new(false); // Log levels for different types of debug information #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LogLevel { Info, Warn, Error, Trace, } impl LogLevel { // We're keeping this function since it's needed for the Debug implementation #[allow(dead_code)] fn as_str(&self) -> &'static str { match self { LogLevel::Info => "INFO", LogLevel::Warn => "WARN", LogLevel::Error => "ERROR", LogLevel::Trace => "TRACE", } } fn as_colored_str(&self) -> colored::ColoredString { use colored::*; match self { LogLevel::Info => "INFO".green(), LogLevel::Warn => "WARN".yellow(), LogLevel::Error => "ERROR".red(), LogLevel::Trace => "TRACE".blue(), } } } /// Set the debug mode flag pub fn set_debug_mode(debug: bool) { DEBUG_MODE.store(debug, Ordering::Relaxed); if debug { log(LogLevel::Info, "Debug mode enabled"); } } /// Check if debug mode is enabled pub fn is_debug_mode() -> bool { DEBUG_MODE.load(Ordering::Relaxed) } /// Log a message with the specified level if debug mode is enabled pub fn log(level: LogLevel, message: &str) { if is_debug_mode() { println!("[{}] {}", level.as_colored_str(), message); } } /// Log an error with context information if debug mode is enabled pub fn log_error(context: &str, error: &E) { if is_debug_mode() { println!( "[{}] {}: {}", LogLevel::Error.as_colored_str(), context, error ); } } /// Log detailed information about a value if debug mode is enabled pub fn log_debug(context: &str, value: &T) { if is_debug_mode() { println!( "[{}] {}: {:?}", LogLevel::Trace.as_colored_str(), context, value ); } } /// Conditionally execute a function and log the result if debug mode is enabled #[allow(dead_code)] pub fn with_debug(context: &str, f: F) -> T where F: FnOnce() -> T, T: std::fmt::Debug, { if is_debug_mode() { let start = std::time::Instant::now(); let result = f(); let duration = start.elapsed(); println!( "[{}] {} completed in {:?}", LogLevel::Info.as_colored_str(), context, duration ); log_debug(context, &result); result } else { f() } } /// Create a custom error type that includes debug information #[derive(Debug, thiserror::Error)] pub enum FeludaError { #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("HTTP error: {0}")] Http(#[from] reqwest::Error), #[error("Configuration error: {0}")] Config(String), #[error("License analysis error: {0}")] #[allow(dead_code)] License(String), #[error("Parser error: {0}")] Parser(String), #[error("Repository clone error: {0}")] RepositoryClone(String), #[error("Temporary directory error: {0}")] TempDir(String), #[error("TUI initialization error: {0}")] TuiInit(String), #[error("TUI runtime error: {0}")] TuiRuntime(String), #[error("Serialization error: {0}")] Serialization(String), #[error("File write error: {0}")] FileWrite(String), #[error("Invalid data: {0}")] InvalidData(String), #[error("Validation error: {0}")] Validation(String), #[error("Unknown error: {0}")] #[allow(dead_code)] Unknown(String), } impl FeludaError { pub fn log(&self) { log_error("Error occurred", self); } } /// Result type alias for Feluda operations pub type FeludaResult = Result; #[cfg(test)] mod tests { use super::*; #[test] fn test_debug_mode_toggle() { // Start with debug off set_debug_mode(false); assert!(!is_debug_mode()); // Turn on debug mode set_debug_mode(true); assert!(is_debug_mode()); // Turn off debug mode set_debug_mode(false); assert!(!is_debug_mode()); } #[test] fn test_log_level_as_str() { assert_eq!(LogLevel::Info.as_str(), "INFO"); assert_eq!(LogLevel::Warn.as_str(), "WARN"); assert_eq!(LogLevel::Error.as_str(), "ERROR"); assert_eq!(LogLevel::Trace.as_str(), "TRACE"); } #[test] fn test_log_level_equality() { assert_eq!(LogLevel::Info, LogLevel::Info); assert_eq!(LogLevel::Warn, LogLevel::Warn); assert_eq!(LogLevel::Error, LogLevel::Error); assert_eq!(LogLevel::Trace, LogLevel::Trace); assert_ne!(LogLevel::Info, LogLevel::Warn); assert_ne!(LogLevel::Error, LogLevel::Trace); } #[test] fn test_feluda_error_variants() { let io_error = FeludaError::Io(std::io::Error::new( std::io::ErrorKind::NotFound, "File not found", )); let config_error = FeludaError::Config("Invalid config".to_string()); let parser_error = FeludaError::Parser("Parse failed".to_string()); let license_error = FeludaError::License("License error".to_string()); let unknown_error = FeludaError::Unknown("Unknown issue".to_string()); assert!(io_error.to_string().contains("IO error")); assert!(config_error.to_string().contains("Configuration error")); assert!(parser_error.to_string().contains("Parser error")); assert!(license_error.to_string().contains("License analysis error")); assert!(unknown_error.to_string().contains("Unknown error")); } #[test] fn test_feluda_error_from_io() { let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Access denied"); let feluda_err: FeludaError = io_err.into(); match feluda_err { FeludaError::Io(_) => {} _ => panic!("Expected IO error variant"), } } #[test] fn test_feluda_error_from_reqwest() { let client = reqwest::blocking::Client::new(); let reqwest_err = client .get("http://invalid-url-that-does-not-exist.local") .send() .unwrap_err(); let feluda_err: FeludaError = reqwest_err.into(); match feluda_err { FeludaError::Http(_) => {} // Expected _ => panic!("Expected HTTP error variant"), } } #[test] fn test_with_debug_function() { set_debug_mode(true); let result = with_debug("Test operation", || { std::thread::sleep(std::time::Duration::from_millis(1)); "completed" }); assert_eq!(result, "completed"); set_debug_mode(false); } #[test] fn test_with_debug_function_disabled() { set_debug_mode(false); let result = with_debug("Test operation", || "completed without debug"); assert_eq!(result, "completed without debug"); } #[test] fn test_log_functions_when_debug_disabled() { set_debug_mode(false); log(LogLevel::Info, "Test message"); log(LogLevel::Warn, "Test warning"); log(LogLevel::Error, "Test error"); log(LogLevel::Trace, "Test trace"); log_error("Test context", &"Test error"); log_debug("Test context", &"Test value"); } #[test] fn test_log_functions_when_debug_enabled() { set_debug_mode(true); log(LogLevel::Info, "Test message"); log(LogLevel::Warn, "Test warning"); log(LogLevel::Error, "Test error"); log(LogLevel::Trace, "Test trace"); log_error("Test context", &"Test error"); log_debug("Test context", &vec![1, 2, 3]); set_debug_mode(false); } #[test] fn test_feluda_error_log() { set_debug_mode(true); let error = FeludaError::Config("Test error".to_string()); error.log(); let error2 = FeludaError::Unknown("Another test".to_string()); error2.log(); set_debug_mode(false); } #[test] fn test_feluda_result_alias() { fn test_function() -> FeludaResult { Ok("success".to_string()) } let result = test_function(); assert!(result.is_ok()); assert_eq!(result.unwrap(), "success"); } #[test] fn test_feluda_result_error() { fn test_function() -> FeludaResult { Err(FeludaError::Config("Test failure".to_string())) } let result = test_function(); assert!(result.is_err()); match result.unwrap_err() { FeludaError::Config(msg) => assert_eq!(msg, "Test failure"), _ => panic!("Expected Config error"), } } #[test] fn test_log_level_debug_format() { let info = LogLevel::Info; let debug_str = format!("{info:?}"); assert_eq!(debug_str, "Info"); } #[test] fn test_feluda_error_debug_format() { let error = FeludaError::Config("test config error".to_string()); let debug_str = format!("{error:?}"); assert!(debug_str.contains("Config")); assert!(debug_str.contains("test config error")); } #[test] fn test_multiple_debug_contexts() { set_debug_mode(true); let result1 = with_debug("First operation", || "result1"); let result2 = with_debug("Second operation", || "result2"); assert_eq!(result1, "result1"); assert_eq!(result2, "result2"); set_debug_mode(false); } #[test] fn test_log_with_special_characters() { set_debug_mode(true); log( LogLevel::Info, "Message with unicode: 🚀 and newlines\nand tabs\t", ); log_debug("Context with symbols", &"Special chars: !@#$%^&*()"); set_debug_mode(false); } } feluda-1.11.1/src/generate.rs000064400000000000000000001760511046102023000141060ustar 00000000000000use crate::cli::with_spinner; use crate::debug::{log, log_debug, LogLevel}; use crate::licenses::{ detect_project_license, is_license_compatible, LicenseCompatibility, LicenseInfo, }; use crate::parser::parse_root; use colored::*; use reqwest::blocking::Client; use std::fs; use std::io::{self, Write}; use std::io::{stdin, Read}; #[cfg(unix)] use std::os::unix::io::AsRawFd; use std::path::Path; use std::time::Duration; /// Key input handling for cross-platform compatibility #[derive(Debug, PartialEq)] enum KeyInput { Up, Down, Enter, Escape, Char(char), Unknown, } /// Enable raw terminal mode (For Unix) #[cfg(unix)] fn enable_raw_mode() -> std::io::Result<()> { let fd = stdin().as_raw_fd(); let mut termios = unsafe { std::mem::zeroed() }; unsafe { if libc::tcgetattr(fd, &mut termios) != 0 { return Err(std::io::Error::last_os_error()); } termios.c_lflag &= !(libc::ICANON | libc::ECHO); termios.c_cc[libc::VMIN] = 1; termios.c_cc[libc::VTIME] = 0; if libc::tcsetattr(fd, libc::TCSANOW, &termios) != 0 { return Err(std::io::Error::last_os_error()); } } Ok(()) } /// Disable raw terminal mode (For Unix) #[cfg(unix)] fn disable_raw_mode() -> std::io::Result<()> { let fd = stdin().as_raw_fd(); let mut termios = unsafe { std::mem::zeroed() }; unsafe { if libc::tcgetattr(fd, &mut termios) != 0 { return Err(std::io::Error::last_os_error()); } termios.c_lflag |= libc::ICANON | libc::ECHO; if libc::tcsetattr(fd, libc::TCSANOW, &termios) != 0 { return Err(std::io::Error::last_os_error()); } } Ok(()) } /// Windows raw mode #[cfg(windows)] fn enable_raw_mode() -> std::io::Result<()> { // TODO: Use Windows Console API Ok(()) } /// Disable raw mode for Windows #[cfg(windows)] fn disable_raw_mode() -> std::io::Result<()> { // TODO: Pending implementation Ok(()) } /// Read a key press fn read_key() -> std::io::Result { let mut buffer = [0; 4]; let mut stdin = stdin(); stdin.read_exact(&mut buffer[0..1])?; match buffer[0] { // Enter key b'\r' | b'\n' => Ok(KeyInput::Enter), // Escape key or escape sequence 27 => { let mut temp_buffer = [0; 2]; match stdin.read(&mut temp_buffer) { Ok(2) if temp_buffer[0] == b'[' => { // ANSI escape sequence match temp_buffer[1] { b'A' => Ok(KeyInput::Up), // Up arrow b'B' => Ok(KeyInput::Down), // Down arrow _ => Ok(KeyInput::Escape), } } _ => Ok(KeyInput::Escape), } } // Regular characters b'q' | b'Q' => Ok(KeyInput::Escape), // q for quit b'k' | b'K' => Ok(KeyInput::Up), // k for up (vim) b'j' | b'J' => Ok(KeyInput::Down), // j for down (vim) // Printable ASCII c if (32..=126).contains(&c) => Ok(KeyInput::Char(c as char)), _ => Ok(KeyInput::Unknown), } } /// Clear screen and move cursor to top fn clear_screen() { print!("\x1B[2J\x1B[H"); io::stdout().flush().unwrap(); } /// Hide the cursor fn hide_cursor() { print!("\x1B[?25l"); io::stdout().flush().unwrap(); } /// Show the cursor fn show_cursor() { print!("\x1B[?25h"); io::stdout().flush().unwrap(); } /// Generate options #[derive(Debug, Clone, Copy)] pub enum GenerateOption { Notice, ThirdPartyLicenses, } impl GenerateOption { /// Get the display name for the option pub fn display_name(&self) -> &'static str { match self { GenerateOption::Notice => "NOTICE file", GenerateOption::ThirdPartyLicenses => "THIRD_PARTY_LICENSES file", } } /// Get the filename pub fn filename(&self) -> &'static str { match self { GenerateOption::Notice => "NOTICE", GenerateOption::ThirdPartyLicenses => "THIRD_PARTY_LICENSES", } } /// Get the file extension pub fn extension(&self) -> &'static str { match self { GenerateOption::Notice => "", GenerateOption::ThirdPartyLicenses => ".md", } } /// Full filename with extension pub fn full_filename(&self) -> String { format!("{}{}", self.filename(), self.extension()) } } /// Check if a file exists for the given option pub fn file_exists(option: GenerateOption, path: &str) -> bool { let file_path = Path::new(path).join(option.full_filename()); let exists = file_path.exists(); log( LogLevel::Info, &format!( "Checking if {} exists at {}: {}", option.full_filename(), file_path.display(), exists ), ); exists } /// Display interactive menu with real arrow key navigation pub fn show_interactive_menu(path: &str) -> Option { let options = [GenerateOption::Notice, GenerateOption::ThirdPartyLicenses]; let mut selected_index = 0; let raw_mode_available = enable_raw_mode().is_ok(); if raw_mode_available { hide_cursor(); } let cleanup = || { if raw_mode_available { show_cursor(); let _ = disable_raw_mode(); } }; loop { if raw_mode_available { clear_screen(); } else { for _ in 0..3 { println!(); } } println!("{}", "📝 File Generation Options".bold().blue()); println!("{}", "─".repeat(50).blue()); println!(); for (index, option) in options.iter().enumerate() { let action = if file_exists(*option, path) { "Update".yellow() } else { "Generate".green() }; let indicator = if index == selected_index { "▶".bold().cyan() } else { " ".normal() }; let line_content = format!("{}. {} {}", index + 1, action, option.display_name()); if index == selected_index { println!("{} {}", indicator, line_content.bold().on_bright_black()); } else { println!("{indicator} {line_content}"); } } let cancel_content = format!("0. {}", "Cancel".red()); if selected_index == options.len() { println!( "{} {}", "▶".bold().cyan(), cancel_content.bold().on_bright_black() ); } else { println!(" {cancel_content}"); } println!(); if raw_mode_available { println!( "{}", "Use ↑/↓ arrows to navigate, Enter to select, q/Esc to cancel".dimmed() ); } else { println!("{}", "Type 1, 2, or 0 to select, or q to cancel:".dimmed()); print!("> "); io::stdout().flush().unwrap(); } // Read input if raw_mode_available { // key input match read_key() { Ok(KeyInput::Up) => { if selected_index > 0 { selected_index -= 1; } else { selected_index = options.len(); // to Cancel option } } Ok(KeyInput::Down) => { if selected_index < options.len() { selected_index += 1; } else { selected_index = 0; // to first option } } Ok(KeyInput::Enter) => { cleanup(); if selected_index < options.len() { log( LogLevel::Info, &format!("User selected option: {:?}", options[selected_index]), ); return Some(options[selected_index]); } else { println!("\n{}", "✋ Operation cancelled.".yellow()); return None; } } Ok(KeyInput::Escape) => { cleanup(); println!("\n{}", "✋ Operation cancelled.".yellow()); return None; } Ok(KeyInput::Char('1')) => { cleanup(); log(LogLevel::Info, "User selected option 1 (NOTICE)"); return Some(GenerateOption::Notice); } Ok(KeyInput::Char('2')) => { cleanup(); log( LogLevel::Info, "User selected option 2 (THIRD_PARTY_LICENSES)", ); return Some(GenerateOption::ThirdPartyLicenses); } Ok(KeyInput::Char('0')) => { cleanup(); println!("\n{}", "✋ Operation cancelled.".yellow()); return None; } Ok(KeyInput::Char('h') | KeyInput::Char('?')) => { // Show help clear_screen(); println!("\n{}", "📚 Help - Navigation Commands".bold().blue()); println!("{}", "─".repeat(40).blue()); println!(" {} Move selection up", "↑ Arrow or k".cyan()); println!(" {} Move selection down", "↓ Arrow or j".cyan()); println!(" {} Select current option", "Enter".green()); println!(" {} Quick select options", "1, 2, 0".yellow()); println!(" {} Cancel and exit", "q or Esc".red()); println!(" {} Show this help", "h or ?".blue()); println!("\nPress any key to continue..."); let _ = read_key(); } Ok(_) | Err(_) => { // Invalid key, continue loop continue; } } } else { // Fallback to line-based input let mut input = String::new(); match io::stdin().read_line(&mut input) { Ok(_) => { let choice = input.trim().to_lowercase(); log(LogLevel::Info, &format!("User input: '{choice}'")); match choice.as_str() { "0" => { println!("{}", "✋ Operation cancelled.".yellow()); return None; } "1" => { log(LogLevel::Info, "User selected option 1 (NOTICE)"); return Some(GenerateOption::Notice); } "2" => { log( LogLevel::Info, "User selected option 2 (THIRD_PARTY_LICENSES)", ); return Some(GenerateOption::ThirdPartyLicenses); } "q" | "quit" | "exit" => { println!("{}", "✋ Operation cancelled.".yellow()); return None; } _ => { println!("{} Invalid input. Please use 1, 2, 0, or q.", "❌".red()); println!("Press Enter to continue..."); let mut _dummy = String::new(); let _ = io::stdin().read_line(&mut _dummy); } } } Err(_) => { println!("{} Error reading input.", "❌".red()); let mut _dummy = String::new(); let _ = io::stdin().read_line(&mut _dummy); } } } } } /// Generate or update a NOTICE file pub fn generate_notice_file(license_data: &[LicenseInfo], path: &str) { let file_path = Path::new(path).join(GenerateOption::Notice.full_filename()); let exists = file_exists(GenerateOption::Notice, path); let action = if exists { "Updating" } else { "Generating" }; log( LogLevel::Info, &format!( "{} NOTICE file at {} with {} dependencies", action, file_path.display(), license_data.len() ), ); log_debug("License data for NOTICE file", &license_data); println!( "{} {} NOTICE file at {}...", "📄".bold(), action.green().bold(), file_path.display().to_string().blue() ); // Generate NOTICE content let notice_content = generate_notice_content(license_data); // Write to file match fs::write(&file_path, notice_content) { Ok(_) => { println!( "{} NOTICE file generated successfully!", "✅".green().bold() ); println!(" 📍 Location: {}", file_path.display().to_string().blue()); } Err(err) => { println!("{} Failed to write NOTICE file: {}", "❌".red().bold(), err); log( LogLevel::Error, &format!("Failed to write NOTICE file: {err}"), ); } } } /// Generate the content for a NOTICE file fn generate_notice_content(license_data: &[LicenseInfo]) -> String { let mut content = String::new(); // Header content.push_str("NOTICE\n"); content.push_str("======\n\n"); content.push_str("This project includes third-party software components that are subject to separate copyright notices and license terms.\n"); content.push_str("Your use of the source code for these components is subject to the terms and conditions of the following licenses.\n\n"); // Group dependencies by license let mut license_groups: std::collections::HashMap> = std::collections::HashMap::new(); for info in license_data { let license_key = info.get_license(); license_groups.entry(license_key).or_default().push(info); } // Sort license groups let mut sorted_licenses: Vec<_> = license_groups.iter().collect(); sorted_licenses.sort_by_key(|(license, _)| license.as_str()); for (license, dependencies) in sorted_licenses { content.push_str(&format!("## {license} Licensed Components\n\n")); // Sort dependencies within each license group let mut sorted_deps = dependencies.clone(); sorted_deps.sort_by_key(|dep| &dep.name); for dep in sorted_deps { content.push_str(&format!("* {} ({})\n", dep.name, dep.version)); } content.push('\n'); } // Footer content.push_str("---\n\n"); content.push_str(&format!( "This NOTICE file contains {} third-party dependencies.\n", license_data.len() )); content.push_str("For detailed license information, see the THIRD_PARTY_LICENSES file.\n"); content.push_str(&format!( "Generated at: {}\n", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") )); content.push_str("Generated by: Feluda (https://github.com/anistark/feluda)\n\n"); // Feluda disclaimer. Once Feluda is stable, this can be updated. // TODO: Get it reviewed by legal counsel content.push_str("DISCLAIMER:\n"); content.push_str("-----------\n"); content.push_str("Feluda is still in early stages!\n"); content.push_str("The license information may be incomplete, outdated, or incorrect. Users are responsible for:\n"); content.push_str("• Verifying the accuracy of all license information\n"); content.push_str("• Ensuring compliance with all applicable license terms\n"); content.push_str("• Consulting with legal counsel for license compliance matters\n"); content.push_str("• Checking the official package repositories for the most up-to-date license information\n\n"); content.push_str("Feluda and its contributors disclaim all warranties and are not liable for any legal issues\n"); content.push_str("arising from the use of this information. Use at your own risk.\n"); content } /// Generate or update a THIRD_PARTY_LICENSES file pub fn generate_third_party_licenses_file(license_data: &[LicenseInfo], path: &str) { let file_path = Path::new(path).join(GenerateOption::ThirdPartyLicenses.full_filename()); let exists = file_exists(GenerateOption::ThirdPartyLicenses, path); let action = if exists { "Updating" } else { "Generating" }; log( LogLevel::Info, &format!( "{} THIRD_PARTY_LICENSES file at {} with {} dependencies", action, file_path.display(), license_data.len() ), ); log_debug("License data for THIRD_PARTY_LICENSES file", &license_data); println!( "{} {} THIRD_PARTY_LICENSES file at {}...", "📜".bold(), action.green().bold(), file_path.display().to_string().blue() ); // Generate THIRD_PARTY_LICENSES content let (licenses_content, fetch_stats) = with_spinner( &format!( "Fetching license content for {} dependencies", license_data.len() ), |indicator| generate_third_party_licenses_content(license_data, indicator), ); // Write to file match fs::write(&file_path, licenses_content) { Ok(_) => { println!( "{} THIRD_PARTY_LICENSES file generated successfully!", "✅".green().bold() ); println!(" 📍 Location: {}", file_path.display().to_string().blue()); println!( " 📊 Dependencies: {}", license_data.len().to_string().cyan() ); // Display license fetching statistics let (successfully_fetched, failed_to_fetch) = fetch_stats; println!( " 📄 Actual license texts fetched: {} ({:.1}%)", successfully_fetched.to_string().green(), (successfully_fetched as f64 / license_data.len() as f64) * 100.0 ); if failed_to_fetch > 0 { println!( " ⚠️ License texts not fetched: {} ({:.1}%)", failed_to_fetch.to_string().yellow(), (failed_to_fetch as f64 / license_data.len() as f64) * 100.0 ); println!( " {}", "Templates or generic references used for these dependencies.".dimmed() ); } } Err(err) => { println!( "{} Failed to write THIRD_PARTY_LICENSES file: {}", "❌".red().bold(), err ); log( LogLevel::Error, &format!("Failed to write THIRD_PARTY_LICENSES file: {err}"), ); } } } /// HTTP client for API requests fn create_http_client() -> Option { Client::builder() .user_agent("feluda-license-checker/1.0") .timeout(Duration::from_secs(10)) .build() .ok() } /// Rate limit delay to avoid hitting API limits fn rate_limit_delay() { std::thread::sleep(Duration::from_millis(500)); } /// Fetch the actual license content for a dependency fn fetch_actual_license_content(name: &str, version: &str) -> Option { log( LogLevel::Info, &format!("Attempting to fetch actual license content for {name} v{version}"), ); // Fetch from crates.io for Rust packages if let Some(content) = fetch_license_from_crates_io(name, version) { return Some(content); } // Fetch from npm for Node.js packages if let Some(content) = fetch_license_from_npm(name, version) { return Some(content); } // Fetch from PyPI for Python packages if let Some(content) = fetch_license_from_pypi(name, version) { return Some(content); } // Fetch from Go proxy for Go modules if let Some(content) = fetch_license_from_go_proxy(name, version) { return Some(content); } // Fetch from GitHub if we can infer the repository if let Some(content) = fetch_license_from_github(name, version) { return Some(content); } log( LogLevel::Warn, &format!("Could not fetch actual license content for {name} v{version}"), ); None } /// Fetch license content from crates.io fn fetch_license_from_crates_io(name: &str, version: &str) -> Option { log( LogLevel::Info, &format!("Trying to fetch license from crates.io for {name} v{version}"), ); let client = create_http_client()?; rate_limit_delay(); let api_url = format!("https://crates.io/api/v1/crates/{name}"); let response = client.get(&api_url).send().ok()?; if !response.status().is_success() { log( LogLevel::Warn, &format!( "Failed to fetch crate info from crates.io: HTTP {}", response.status() ), ); return None; } let crate_info: serde_json::Value = response.json().ok()?; let repository = crate_info.get("crate")?.get("repository")?.as_str()?; log( LogLevel::Info, &format!("Found repository for {name}: {repository}"), ); if repository.contains("github.com") { return fetch_license_from_github_repo(repository); } None } /// Fetch license content from npm fn fetch_license_from_npm(name: &str, version: &str) -> Option { log( LogLevel::Info, &format!("Trying to fetch license from npm for {name} v{version}"), ); let client = create_http_client()?; rate_limit_delay(); let api_url = format!("https://registry.npmjs.org/{name}/{version}"); let response = client.get(&api_url).send().ok()?; if !response.status().is_success() { log( LogLevel::Warn, &format!( "Failed to fetch package info from npm: HTTP {}", response.status() ), ); return None; } let package_info: serde_json::Value = response.json().ok()?; if let Some(repository) = package_info.get("repository") { if let Some(url) = repository.get("url").and_then(|u| u.as_str()) { log( LogLevel::Info, &format!("Found repository for {name}: {url}"), ); let clean_url = url .trim_start_matches("git+") .trim_end_matches(".git") .replace("git://", "https://"); if clean_url.contains("github.com") { return fetch_license_from_github_repo(&clean_url); } } } None } /// Fetch license content from PyPI fn fetch_license_from_pypi(name: &str, version: &str) -> Option { log( LogLevel::Info, &format!("Trying to fetch license from PyPI for {name} v{version}"), ); let client = create_http_client()?; rate_limit_delay(); let api_url = format!("https://pypi.org/pypi/{name}/{version}/json"); let response = client.get(&api_url).send().ok()?; if !response.status().is_success() { log( LogLevel::Warn, &format!( "Failed to fetch package info from PyPI: HTTP {}", response.status() ), ); return None; } let package_info: serde_json::Value = response.json().ok()?; if let Some(project_urls) = package_info.get("info").and_then(|i| i.get("project_urls")) { if let Some(homepage) = project_urls.get("Homepage").and_then(|h| h.as_str()) { log( LogLevel::Info, &format!("Found homepage for {name}: {homepage}"), ); if homepage.contains("github.com") { return fetch_license_from_github_repo(homepage); } } } None } /// Fetch license content from Go proxy fn fetch_license_from_go_proxy(name: &str, version: &str) -> Option { log( LogLevel::Info, &format!("Trying to fetch license from Go proxy for {name} v{version}"), ); if name.starts_with("github.com/") { let repo_url = format!( "https://{}", name.split('/').take(3).collect::>().join("/") ); log( LogLevel::Info, &format!("Inferred GitHub repository: {repo_url}"), ); return fetch_license_from_github_repo(&repo_url); } None } /// Fetch license content from a GitHub repository fn fetch_license_from_github(name: &str, _version: &str) -> Option { log( LogLevel::Info, &format!("Trying to infer GitHub repository for {name}"), ); let possible_repos = vec![ format!("https://github.com/{}/{}", name, name), format!("https://github.com/{}/lib{}", name, name), format!("https://github.com/{}/{}-rs", name, name), format!("https://github.com/{}/{}.js", name, name), ]; for repo_url in possible_repos { log(LogLevel::Info, &format!("Trying repository: {repo_url}")); if let Some(content) = fetch_license_from_github_repo(&repo_url) { return Some(content); } } None } /// Fetch license content from GitHub repository fn fetch_license_from_github_repo(repo_url: &str) -> Option { log( LogLevel::Info, &format!("Fetching license from GitHub repo: {repo_url}"), ); let parts: Vec<&str> = repo_url.trim_end_matches('/').split('/').collect(); if parts.len() < 2 { log( LogLevel::Warn, &format!("Invalid GitHub URL format: {repo_url}"), ); return None; } let owner = parts[parts.len() - 2]; let repo = parts[parts.len() - 1]; let client = create_http_client()?; rate_limit_delay(); // Common license file names let license_files = [ "LICENSE", "LICENSE.txt", "LICENSE.md", "license", "license.txt", "license.md", "COPYING", "COPYING.txt", "COPYRIGHT", "COPYRIGHT.txt", ]; for license_file in &license_files { let api_url = format!("https://api.github.com/repos/{owner}/{repo}/contents/{license_file}"); log(LogLevel::Info, &format!("Trying to fetch: {api_url}")); match client.get(&api_url).send() { Ok(response) => { if response.status().is_success() { if let Ok(content_info) = response.json::() { if let Some(download_url) = content_info.get("download_url").and_then(|u| u.as_str()) { log( LogLevel::Info, &format!("Found license file, downloading from: {download_url}"), ); rate_limit_delay(); match client.get(download_url).send() { Ok(license_response) => { if license_response.status().is_success() { if let Ok(license_content) = license_response.text() { log(LogLevel::Info, &format!("Successfully fetched license content for {repo} from {license_file}")); return Some(license_content); } } } Err(err) => { log( LogLevel::Warn, &format!("Failed to download license file: {err}"), ); } } } } } else if response.status().as_u16() == 404 { continue; } else { log( LogLevel::Warn, &format!("GitHub API error: HTTP {}", response.status()), ); } } Err(err) => { log( LogLevel::Warn, &format!("Failed to fetch from GitHub API: {err}"), ); } } } log( LogLevel::Warn, &format!("No license file found in repository: {owner}/{repo}"), ); None } /// Generate the content for a THIRD_PARTY_LICENSES file fn generate_third_party_licenses_content( license_data: &[LicenseInfo], indicator: &crate::cli::LoadingIndicator, ) -> (String, (usize, usize)) { let mut content = String::new(); let mut successfully_fetched = 0; let mut failed_to_fetch = 0; // Header content.push_str("# Third-Party Licenses\n\n"); content.push_str("This project includes third-party libraries licensed under various open source licenses.\n"); content.push_str("Below is a list of all dependencies, their versions, and license types.\n\n"); content.push_str(&format!("**Total Dependencies:** {}\n", license_data.len())); content.push_str(&format!( "**Generated:** {}\n\n", chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC") )); content.push_str("---\n\n"); // Sort dependencies alphabetically let mut sorted_deps: Vec<_> = license_data.iter().collect(); sorted_deps.sort_by(|a, b| a.name.cmp(&b.name)); indicator.update_progress("processing dependencies"); for (index, dep) in sorted_deps.iter().enumerate() { indicator.update_progress(&format!("processing {}/{}", index + 1, sorted_deps.len())); content.push_str(&format!( "## {}. {} {}\n\n", index + 1, dep.name, dep.version )); // License information content.push_str(&format!("**License:** {}\n", dep.get_license())); // Add compatibility information if available match dep.compatibility { crate::licenses::LicenseCompatibility::Compatible => { content.push_str("**Compatibility:** ✅ Compatible\n"); } crate::licenses::LicenseCompatibility::Incompatible => { content.push_str("**Compatibility:** ⚠️ Potentially Incompatible\n"); } crate::licenses::LicenseCompatibility::Unknown => { content.push_str("**Compatibility:** ❓ Unknown\n"); } } // Add restrictive warning if applicable if dep.is_restrictive { content.push_str("**⚠️ Note:** This license may have restrictive terms\n"); } // Common package repository URLs based on the dependency type let repo_url = generate_package_url(&dep.name, &dep.version); if let Some(ref url) = repo_url { content.push_str(&format!("**Package URL:** {url}\n")); } // Copyright notice placeholder content.push_str(&format!( "**Copyright:** See {} package for copyright information\n", dep.name )); // License text content.push_str("\n### License Text\n\n"); // Try to fetch the actual license content match fetch_actual_license_content(&dep.name, &dep.version) { Some(actual_license_content) => { successfully_fetched += 1; log( LogLevel::Info, &format!("Using actual license content for {}", dep.name), ); content.push_str("*The following is the actual license text from the dependency's repository:*\n\n"); content.push_str("```\n"); content.push_str(&actual_license_content); content.push_str("\n```\n"); } None => { failed_to_fetch += 1; log( LogLevel::Warn, &format!( "Could not fetch actual license for {}, using fallback", dep.name ), ); match dep.get_license().as_str() { "MIT" => { content.push_str("*Note: Could not fetch actual license text. Below is the standard MIT license template:*\n\n"); content.push_str(get_mit_license_text(&dep.name)); } "Apache-2.0" => { content.push_str("*Note: Could not fetch actual license text. Below is the standard Apache 2.0 license template:*\n\n"); content.push_str(get_apache_license_text()); } "BSD-3-Clause" => { content.push_str("*Note: Could not fetch actual license text. Below is the standard BSD 3-Clause license template:*\n\n"); content.push_str(get_bsd_license_text(&dep.name)); } license if license.contains("MIT") => { content.push_str("*Note: Could not fetch actual license text. Below is the standard MIT license template:*\n\n"); content.push_str(get_mit_license_text(&dep.name)); } license if license.contains("Apache") => { content.push_str("*Note: Could not fetch actual license text. Below is the standard Apache 2.0 license template:*\n\n"); content.push_str(get_apache_license_text()); } _ => { content.push_str(&format!( "*Could not fetch the actual license text for {}.*\n\n", dep.name )); content.push_str(&format!( "For the full license text of {}, please refer to:\n", dep.get_license() )); content.push_str(&format!( "- The official {} license documentation\n", dep.get_license() )); if let Some(ref url) = repo_url { content.push_str(&format!("- The package repository: {url}\n")); } content.push_str("- The dependency's source code or package files\n\n"); } } } } content.push_str("\n---\n\n"); } indicator.update_progress("finalizing document"); content.push_str("## License Fetching Statistics\n\n"); content.push_str(&format!( "**Total Dependencies Processed:** {}\n", license_data.len() )); content.push_str(&format!( "**Actual License Texts Fetched:** {} ({:.1}%)\n", successfully_fetched, (successfully_fetched as f64 / license_data.len() as f64) * 100.0 )); content.push_str(&format!( "**License Texts Not Fetched:** {} ({:.1}%)\n", failed_to_fetch, (failed_to_fetch as f64 / license_data.len() as f64) * 100.0 )); if successfully_fetched > 0 { content.push_str(&format!( "\n✅ Successfully fetched actual license texts for {successfully_fetched} dependencies.\n" )); } if failed_to_fetch > 0 { content.push_str(&format!( "\n⚠️ Could not fetch actual license texts for {failed_to_fetch} dependencies. Using fallback templates or generic references.\n" )); content.push_str( "Consider manually verifying the license information for these dependencies.\n", ); } content.push('\n'); // Footer with Feluda legal disclaimer content.push_str("## Legal Notice & Disclaimer\n\n"); content.push_str("**IMPORTANT LEGAL DISCLAIMER:**\n\n"); content.push_str("This file was automatically generated by [Feluda](https://github.com/anistark/feluda). Feluda is still in early stages of development.\n"); content.push_str( "The license information contained herein may be incomplete, outdated, or incorrect.\n\n", ); content.push_str("**USER RESPONSIBILITIES:**\n"); content.push_str( "- **Verify Accuracy**: Users must independently verify all license information\n", ); content.push_str("- **Legal Compliance**: Ensure compliance with all applicable license terms and conditions\n"); content.push_str("- **Legal Counsel**: Consult with qualified legal counsel for license compliance matters\n"); content.push_str("- **Stay Updated**: Check official package repositories for the most current license information\n"); content.push_str( "- **Due Diligence**: Perform thorough license audits before commercial distribution\n\n", ); content.push_str("**LIMITATION OF LIABILITY:**\n"); content.push_str("Feluda, its contributors, and maintainers:\n"); content.push_str("- Disclaim all warranties, express or implied\n"); content.push_str("- Are not liable for any legal issues, damages, or losses arising from the use of this information\n"); content.push_str( "- Do not guarantee the accuracy, completeness, or reliability of license information\n", ); content.push_str( "- Are not responsible for license compliance decisions or their consequences\n\n", ); content.push_str("**USE AT YOUR OWN RISK**\n\n"); content.push_str("For the most up-to-date license information, please check the official package repositories.\n"); content.push_str("This tool is provided as-is without any warranties or guarantees.\n\n"); content.push_str("---\n\n"); content.push_str("*This file was generated using [Feluda](https://github.com/anistark/feluda), an open-source dependency license checker.*\n"); (content, (successfully_fetched, failed_to_fetch)) } /// Generate package repository URL fn generate_package_url(name: &str, version: &str) -> Option { if name.is_empty() { return None; } // Go modules: domain/path structure if name.contains('/') && name.contains('.') { return Some(format!("https://pkg.go.dev/{name}")); } // npm scoped packages: @scope/name if name.starts_with('@') && name.contains('/') { return Some(format!("https://www.npmjs.com/package/{name}")); } // Python packages: Common Python indicators if name.starts_with("python-") || name.starts_with("django-") || name.starts_with("flask-") || name.starts_with("pytest-") || name.starts_with("py-") || name == "requests" || name == "numpy" || name == "pandas" || name == "click" || name == "boto3" || (name.chars().all(|c| c.is_lowercase() || c == '_') && name.contains('_')) { return Some(format!("https://pypi.org/project/{name}/")); } // npm packages: Common JavaScript indicators if name.starts_with("react-") || name.starts_with("vue-") || name.starts_with("angular-") || name.starts_with("webpack-") || name.starts_with("babel-") || name.starts_with("eslint-") || name.starts_with("express-") || name.starts_with("node-") || name == "express" || name == "lodash" || name == "axios" || name == "moment" || // npm version patterns version.starts_with('^') || version.starts_with('~') || version == "latest" || version == "next" { return Some(format!("https://www.npmjs.com/package/{name}")); } // Rust crates: Version starts with digit if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some(format!("https://crates.io/crates/{name}")); } None } /// Get MIT license text template fn get_mit_license_text(_package_name: &str) -> &'static str { "MIT License Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. " } /// Get Apache 2.0 license text fn get_apache_license_text() -> &'static str { "Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. \"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. \"Licensor\" shall mean the copyright owner or entity granting the License. \"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. [License text continues - truncated for brevity] For the complete Apache 2.0 license text, visit: http://www.apache.org/licenses/LICENSE-2.0 " } /// Get BSD 3-Clause license text template fn get_bsd_license_text(_package_name: &str) -> &'static str { "BSD 3-Clause License Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. " } /// Main entry point for the generate command pub fn handle_generate_command( path: String, language: Option, project_license: Option, ) { log( LogLevel::Info, &format!( "Starting generate command with path: {path} language: {language:?} project_license: {project_license:?}" ), ); // Parse project dependencies first log( LogLevel::Info, &format!("Parsing dependencies for generate command in path: {path}"), ); // Import necessary modules for dependency parsing and license detection let mut resolved_project_license = project_license; // If no project license is provided via CLI, try to detect it match resolved_project_license { Some(ref license) => { log( LogLevel::Info, &format!("Using provided project license: {}", *license), ); } None => { log( LogLevel::Info, "No project license specified, attempting to detect", ); match detect_project_license(&path) { Ok(Some(detected)) => { log( LogLevel::Info, &format!("Detected project license: {detected}"), ); resolved_project_license = Some(detected); } Ok(None) => { log(LogLevel::Warn, "Could not detect project license"); } Err(e) => { log( LogLevel::Error, &format!("Error detecting project license: {e}"), ); } } } } // Parse and analyze dependencies let mut analyzed_data = match parse_root(&path, language.as_deref(), false, false) { Ok(data) => data, Err(e) => { println!("{} Failed to parse dependencies: {}", "❌".red().bold(), e); log( LogLevel::Error, &format!("Failed to parse dependencies: {e}"), ); return; } }; log_debug("Analyzed dependencies for generate command", &analyzed_data); // Update each dependency with compatibility information if project license is known if let Some(ref proj_license) = resolved_project_license { log( LogLevel::Info, &format!("Checking license compatibility against project license: {proj_license}"), ); for info in &mut analyzed_data { if let Some(ref dep_license) = info.license { info.compatibility = is_license_compatible(dep_license, proj_license, false); } else { info.compatibility = LicenseCompatibility::Unknown; } } } else { // If no project license is known, mark all as unknown compatibility for info in &mut analyzed_data { info.compatibility = LicenseCompatibility::Unknown; } } // Check if we have any dependencies to process if analyzed_data.is_empty() { println!( "{} {}", "⚠️".yellow().bold(), "No dependencies found. Cannot generate files without dependency data.".yellow() ); return; } println!( "\n{}", "🚀 Welcome to Feluda License File Generator!" .bold() .green() ); println!( "{}", format!("Found {} dependencies to process.", analyzed_data.len()).dimmed() ); match show_interactive_menu(&path) { Some(GenerateOption::Notice) => { generate_notice_file(&analyzed_data, &path); } Some(GenerateOption::ThirdPartyLicenses) => { generate_third_party_licenses_file(&analyzed_data, &path); } None => { log(LogLevel::Info, "User cancelled generate operation"); } } } #[cfg(test)] mod tests { use super::*; use crate::licenses::LicenseCompatibility; use tempfile::TempDir; fn get_test_license_data() -> Vec { vec![ LicenseInfo { name: "serde".to_string(), version: "1.0.151".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "tokio".to_string(), version: "1.0.2".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ] } #[test] fn test_generate_option_display_name() { assert_eq!(GenerateOption::Notice.display_name(), "NOTICE file"); assert_eq!( GenerateOption::ThirdPartyLicenses.display_name(), "THIRD_PARTY_LICENSES file" ); } #[test] fn test_generate_option_filename() { assert_eq!(GenerateOption::Notice.filename(), "NOTICE"); assert_eq!( GenerateOption::ThirdPartyLicenses.filename(), "THIRD_PARTY_LICENSES" ); } #[test] fn test_generate_option_full_filename() { assert_eq!(GenerateOption::Notice.full_filename(), "NOTICE"); assert_eq!( GenerateOption::ThirdPartyLicenses.full_filename(), "THIRD_PARTY_LICENSES.md" ); } #[test] fn test_file_exists_false() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); assert!(!file_exists(GenerateOption::Notice, path)); assert!(!file_exists(GenerateOption::ThirdPartyLicenses, path)); } #[test] fn test_file_exists_true() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); std::fs::write(temp_dir.path().join("NOTICE"), "test notice").unwrap(); std::fs::write( temp_dir.path().join("THIRD_PARTY_LICENSES.md"), "test licenses", ) .unwrap(); assert!(file_exists(GenerateOption::Notice, path)); assert!(file_exists(GenerateOption::ThirdPartyLicenses, path)); } #[test] fn test_generate_notice_file() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); let license_data = get_test_license_data(); generate_notice_file(&license_data, path); } #[test] fn test_generate_third_party_licenses_file() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); let license_data = get_test_license_data(); generate_third_party_licenses_file(&license_data, path); } #[test] fn test_handle_generate_command_empty_data() { let temp_dir = TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); handle_generate_command(path.to_string(), None, None); } #[test] fn test_generate_option_copy() { let option1 = GenerateOption::Notice; let option2 = option1; assert_eq!(option1.display_name(), option2.display_name()); } #[test] fn test_generate_option_debug() { let option = GenerateOption::ThirdPartyLicenses; let debug_str = format!("{option:?}"); assert!(debug_str.contains("ThirdPartyLicenses")); } #[test] fn test_generate_option_methods() { let notice = GenerateOption::Notice; let licenses = GenerateOption::ThirdPartyLicenses; assert_eq!(notice.display_name(), "NOTICE file"); assert_eq!(licenses.display_name(), "THIRD_PARTY_LICENSES file"); assert_eq!(notice.filename(), "NOTICE"); assert_eq!(licenses.filename(), "THIRD_PARTY_LICENSES"); assert_eq!(notice.extension(), ""); assert_eq!(licenses.extension(), ".md"); assert_eq!(notice.full_filename(), "NOTICE"); assert_eq!(licenses.full_filename(), "THIRD_PARTY_LICENSES.md"); } #[test] fn test_generate_option_copy_clone() { let notice1 = GenerateOption::Notice; let notice2 = notice1; assert_eq!(notice1.display_name(), notice2.display_name()); assert_eq!(notice1.full_filename(), notice2.full_filename()); } #[test] fn test_file_exists_with_different_paths() { let temp_dir = tempfile::TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); // Test non-existent files assert!(!file_exists(GenerateOption::Notice, path)); assert!(!file_exists(GenerateOption::ThirdPartyLicenses, path)); // Create NOTICE file std::fs::write(temp_dir.path().join("NOTICE"), "test notice").unwrap(); assert!(file_exists(GenerateOption::Notice, path)); assert!(!file_exists(GenerateOption::ThirdPartyLicenses, path)); // Create THIRD_PARTY_LICENSES.md file std::fs::write( temp_dir.path().join("THIRD_PARTY_LICENSES.md"), "test licenses", ) .unwrap(); assert!(file_exists(GenerateOption::Notice, path)); assert!(file_exists(GenerateOption::ThirdPartyLicenses, path)); } #[test] fn test_generate_package_url() { // Go modules assert_eq!( generate_package_url("github.com/gorilla/mux", "v1.8.0"), Some("https://pkg.go.dev/github.com/gorilla/mux".to_string()) ); // npm scoped packages assert_eq!( generate_package_url("@babel/core", "7.0.0"), Some("https://www.npmjs.com/package/@babel/core".to_string()) ); // Python packages assert_eq!( generate_package_url("python-dateutil", "v2.8.2"), Some("https://pypi.org/project/python-dateutil/".to_string()) ); assert_eq!( generate_package_url("requests", "2.28.1"), Some("https://pypi.org/project/requests/".to_string()) ); assert_eq!( generate_package_url("django-rest-framework", "3.14.0"), Some("https://pypi.org/project/django-rest-framework/".to_string()) ); // npm packages assert_eq!( generate_package_url("express", "4.18.0"), Some("https://www.npmjs.com/package/express".to_string()) ); assert_eq!( generate_package_url("react-router", "6.0.0"), Some("https://www.npmjs.com/package/react-router".to_string()) ); // npm packages assert_eq!( generate_package_url("some-package", "^4.18.0"), Some("https://www.npmjs.com/package/some-package".to_string()) ); assert_eq!( generate_package_url("another-pkg", "latest"), Some("https://www.npmjs.com/package/another-pkg".to_string()) ); // Rust crates assert_eq!( generate_package_url("serde", "1.0.0"), Some("https://crates.io/crates/serde".to_string()) ); assert_eq!( generate_package_url("tokio", "1.28.1"), Some("https://crates.io/crates/tokio".to_string()) ); // Unknown packages assert_eq!(generate_package_url("", "1.0.0"), None); assert_eq!(generate_package_url("UnknownPackage", "unknown"), None); } #[test] fn test_license_templates() { let mit_license = get_mit_license_text("test_package"); assert!(mit_license.contains("MIT License")); assert!(mit_license.contains("Permission is hereby granted")); assert!(mit_license.contains("free of charge")); assert!(mit_license.contains("THE SOFTWARE IS PROVIDED \"AS IS\"")); let apache_license = get_apache_license_text(); assert!(apache_license.contains("Apache License")); assert!(apache_license.contains("Version 2.0")); assert!(apache_license.contains("January 2004")); assert!(apache_license.contains("http://www.apache.org/licenses/")); let bsd_license = get_bsd_license_text("test_package"); assert!(bsd_license.contains("BSD 3-Clause License")); assert!(bsd_license.contains("Redistribution and use")); assert!(bsd_license.contains("Neither the name")); } #[test] fn test_generate_notice_content() { let test_data = vec![ LicenseInfo { name: "package1".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "2.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package3".to_string(), version: "1.5.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let content = generate_notice_content(&test_data); // Check header assert!(content.contains("NOTICE")); assert!(content.contains("======")); // Check license sections assert!(content.contains("MIT Licensed Components")); assert!(content.contains("Apache-2.0 Licensed Components")); // Check package listings assert!(content.contains("package1 (1.0.0)")); assert!(content.contains("package2 (2.0.0)")); assert!(content.contains("package3 (1.5.0)")); // Check footer assert!(content.contains("Generated by: Feluda")); assert!(content.contains("DISCLAIMER")); assert!(content.contains("Generated at:")); // Check dependency count assert!(content.contains("3 third-party dependencies")); } #[test] fn test_generate_notice_content_empty() { let test_data = vec![]; let content = generate_notice_content(&test_data); assert!(content.contains("NOTICE")); assert!(content.contains("0 third-party dependencies")); assert!(content.contains("Generated by: Feluda")); } #[test] fn test_generate_notice_content_no_license() { let test_data = vec![LicenseInfo { name: "unknown_package".to_string(), version: "1.0.0".to_string(), license: None, is_restrictive: true, compatibility: LicenseCompatibility::Unknown, osi_status: crate::licenses::OsiStatus::Unknown, }]; let content = generate_notice_content(&test_data); assert!(content.contains("No License Licensed Components")); assert!(content.contains("unknown_package (1.0.0)")); } #[test] fn test_create_http_client() { let client = create_http_client(); assert!(client.is_some()); if let Some(client) = client { let _ = client; } } #[test] fn test_rate_limit_delay() { let start = std::time::Instant::now(); rate_limit_delay(); let duration = start.elapsed(); // Should take at least 500ms assert!(duration >= std::time::Duration::from_millis(500)); } #[test] fn test_generate_notice_file_creation() { let temp_dir = tempfile::TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); let license_data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; generate_notice_file(&license_data, path); // Check that the file was created let notice_path = temp_dir.path().join("NOTICE"); assert!(notice_path.exists()); // Check file contents let content = std::fs::read_to_string(notice_path).unwrap(); assert!(content.contains("NOTICE")); assert!(content.contains("test_package")); assert!(content.contains("MIT")); } #[test] fn test_generate_notice_file_update() { let temp_dir = tempfile::TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); // Create existing NOTICE file let notice_path = temp_dir.path().join("NOTICE"); std::fs::write(¬ice_path, "Old notice content").unwrap(); let license_data = vec![LicenseInfo { name: "new_package".to_string(), version: "2.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; generate_notice_file(&license_data, path); // Check that the file was updated let content = std::fs::read_to_string(notice_path).unwrap(); assert!(content.contains("new_package")); assert!(content.contains("Apache-2.0")); assert!(!content.contains("Old notice content")); } #[test] fn test_generate_third_party_licenses_file_creation() { let temp_dir = tempfile::TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); let license_data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; generate_third_party_licenses_file(&license_data, path); // Check that the file was created let licenses_path = temp_dir.path().join("THIRD_PARTY_LICENSES.md"); assert!(licenses_path.exists()); // Check file contents let content = std::fs::read_to_string(licenses_path).unwrap(); assert!(content.contains("# Third-Party Licenses")); assert!(content.contains("test_package")); assert!(content.contains("MIT")); assert!(content.contains("Legal Notice & Disclaimer")); } #[test] fn test_handle_generate_command_with_empty_data() { let temp_dir = tempfile::TempDir::new().unwrap(); let path = temp_dir.path().to_str().unwrap(); handle_generate_command(path.to_string(), None, None); } #[test] #[cfg(windows)] fn test_enable_disable_raw_mode_windows() { // On Windows, these should return Ok(()) assert!(enable_raw_mode().is_ok()); assert!(disable_raw_mode().is_ok()); } #[test] #[cfg(unix)] fn test_enable_disable_raw_mode_unix() { // On Unix, these might fail in test environment, but should not panic let _ = enable_raw_mode(); let _ = disable_raw_mode(); } #[test] fn test_fetch_actual_license_content_invalid_package() { // This should return None for invalid packages let result = fetch_actual_license_content("definitely_nonexistent_package_12345", "1.0.0"); assert!(result.is_none()); } } feluda-1.11.1/src/languages/c.rs000064400000000000000000000507571046102023000145100ustar 00000000000000use regex::Regex; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; use std::process::Command; use crate::config::FeludaConfig; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; pub fn analyze_c_licenses(project_path: &str, config: &FeludaConfig) -> Vec { log( LogLevel::Info, &format!("Analyzing C dependencies from: {project_path}"), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; let direct_dependencies = detect_c_dependencies(project_path, config); log( LogLevel::Info, &format!("Found {} direct C dependencies", direct_dependencies.len()), ); log_debug("Direct C dependencies", &direct_dependencies); let max_depth = config.dependencies.max_depth; log( LogLevel::Info, &format!("Using max dependency depth: {max_depth}"), ); let all_deps = resolve_c_dependencies(project_path, &direct_dependencies, max_depth); log( LogLevel::Info, &format!( "Total C dependencies (including transitive): {}", all_deps.len() ), ); log_debug("All C dependencies", &all_deps); let dependencies = all_deps; dependencies .into_iter() .map(|(name, version)| { log( LogLevel::Info, &format!("Processing dependency: {name} ({version})"), ); let license_result = fetch_license_for_c_dependency(&name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, } }) .collect() } fn detect_c_dependencies(project_path: &str, config: &FeludaConfig) -> Vec<(String, String)> { let mut dependencies = Vec::new(); let project_dir = Path::new(project_path).parent().unwrap_or(Path::new(".")); if let Ok(autotools_deps) = parse_autotools_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} autotools dependencies", autotools_deps.len()), ); dependencies.extend(autotools_deps); } if dependencies.is_empty() { if let Ok(makefile_deps) = parse_makefile_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} makefile dependencies", makefile_deps.len()), ); dependencies.extend(makefile_deps); } } if dependencies.is_empty() { if let Ok(pkgconfig_deps) = parse_pkgconfig_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} pkg-config dependencies", pkgconfig_deps.len()), ); dependencies.extend(pkgconfig_deps); } } dependencies } fn resolve_c_dependencies( _project_path: &str, direct_deps: &[(String, String)], max_depth: u32, ) -> Vec<(String, String)> { log( LogLevel::Info, &format!("Resolving C dependencies (including transitive up to depth {max_depth})"), ); let mut all_dependencies = Vec::new(); let mut visited = HashSet::new(); let mut depth_stats = HashMap::new(); // Add direct dependencies first for (name, version) in direct_deps { all_dependencies.push((name.clone(), version.clone())); visited.insert(name.clone()); *depth_stats.entry(0u32).or_insert(0) += 1; } // Queue for BFS: (package_name, version, depth) let mut to_process: Vec<(String, String, u32)> = direct_deps .iter() .map(|(name, version)| (name.clone(), version.clone(), 0)) .collect(); while let Some((name, version, depth)) = to_process.pop() { if depth >= max_depth { log( LogLevel::Trace, &format!("Skipping {name} - exceeded max depth {max_depth}"), ); continue; } log( LogLevel::Trace, &format!("Resolving transitive dependencies for: {name} (depth {depth})"), ); if let Ok(transitive_deps) = resolve_c_transitive_deps(&name, &version) { log( LogLevel::Trace, &format!( "Found {} transitive dependencies for {} at depth {}", transitive_deps.len(), name, depth ), ); for (dep_name, dep_version) in transitive_deps { if !visited.contains(&dep_name) { visited.insert(dep_name.clone()); all_dependencies.push((dep_name.clone(), dep_version.clone())); to_process.push((dep_name, dep_version, depth + 1)); *depth_stats.entry(depth + 1).or_insert(0) += 1; } } } } // Log depth statistics for depth in 0..=max_depth { if let Some(count) = depth_stats.get(&depth) { log( LogLevel::Info, &format!("Depth {depth}: {count} dependencies"), ); } } log( LogLevel::Info, &format!( "C dependency resolution completed. Total dependencies: {} (explored up to depth {})", all_dependencies.len(), max_depth ), ); all_dependencies } fn resolve_c_transitive_deps( package_name: &str, version: &str, ) -> Result, String> { let mut dependencies = Vec::new(); // Try pkg-config for transitive dependencies if let Ok(pkg_deps) = get_pkgconfig_requires(package_name) { dependencies.extend(pkg_deps); } // Try parsing .pc file directly if let Ok(pc_deps) = parse_pc_file_requires(package_name) { dependencies.extend(pc_deps); } // Try system package dependencies if version == "system" { if let Ok(sys_deps) = get_system_package_dependencies(package_name) { dependencies.extend(sys_deps); } } Ok(dependencies) } fn get_pkgconfig_requires(package_name: &str) -> Result, String> { let output = Command::new("pkg-config") .args(["--print-requires", package_name]) .output() .map_err(|e| format!("Failed to run pkg-config --print-requires: {e}"))?; if !output.status.success() { return Ok(Vec::new()); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = Vec::new(); for line in stdout_str.lines() { let trimmed = line.trim(); if !trimmed.is_empty() { let parts: Vec<&str> = trimmed.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { let version = if parts.len() > 2 && (parts[1] == ">=" || parts[1] == "=" || parts[1] == ">") { parts[2].to_string() } else { "system".to_string() }; dependencies.push((pkg_name.to_string(), version)); } } } // Also check private requires let private_output = Command::new("pkg-config") .args(["--print-requires-private", package_name]) .output(); if let Ok(private_out) = private_output { if private_out.status.success() { let private_str = String::from_utf8_lossy(&private_out.stdout); for line in private_str.lines() { let trimmed = line.trim(); if !trimmed.is_empty() { let parts: Vec<&str> = trimmed.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { let version = if parts.len() > 2 && (parts[1] == ">=" || parts[1] == "=" || parts[1] == ">") { parts[2].to_string() } else { "system".to_string() }; dependencies.push((pkg_name.to_string(), version)); } } } } } Ok(dependencies) } fn parse_pc_file_requires(package_name: &str) -> Result, String> { let potential_paths = [ format!("/usr/lib/pkgconfig/{package_name}.pc"), format!("/usr/local/lib/pkgconfig/{package_name}.pc"), format!("/usr/share/pkgconfig/{package_name}.pc"), format!("/usr/local/share/pkgconfig/{package_name}.pc"), format!("/opt/homebrew/lib/pkgconfig/{package_name}.pc"), ]; for pc_path in &potential_paths { if let Ok(content) = fs::read_to_string(pc_path) { return parse_pc_content(&content); } } Ok(Vec::new()) } fn parse_pc_content(content: &str) -> Result, String> { let mut dependencies = Vec::new(); let requires_regex = Regex::new(r"^Requires(?:\.private)?\s*:\s*(.+)$") .map_err(|e| format!("Failed to compile requires regex: {e}"))?; for line in content.lines() { if let Some(cap) = requires_regex.captures(line) { if let Some(requires_str) = cap.get(1) { let requires = requires_str.as_str().trim(); for dep in requires.split(',') { let dep = dep.trim(); if !dep.is_empty() { let parts: Vec<&str> = dep.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { let version = if parts.len() > 2 && (parts[1] == ">=" || parts[1] == "=" || parts[1] == ">") { parts[2].to_string() } else { "system".to_string() }; dependencies.push((pkg_name.to_string(), version)); } } } } } } Ok(dependencies) } fn get_system_package_dependencies(package_name: &str) -> Result, String> { // Try dpkg (Debian/Ubuntu) if let Ok(output) = Command::new("dpkg-query") .args(["-W", "-f", "${Depends}\\n", package_name]) .output() { if output.status.success() { let depends_str = String::from_utf8_lossy(&output.stdout); return parse_debian_dependencies(&depends_str); } } // Try rpm (RedHat/CentOS/Fedora) if let Ok(output) = Command::new("rpm") .args(["-q", "--requires", package_name]) .output() { if output.status.success() { let requires_str = String::from_utf8_lossy(&output.stdout); return parse_rpm_dependencies(&requires_str); } } Ok(Vec::new()) } fn parse_debian_dependencies(depends_str: &str) -> Result, String> { let mut dependencies = Vec::new(); for dep in depends_str.split(',') { let dep = dep.trim(); if !dep.is_empty() && !dep.starts_with("${") { // Remove version constraints and alternatives let parts: Vec<&str> = dep.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { let clean_name = pkg_name.split('|').next().unwrap_or(pkg_name).trim(); if !clean_name.is_empty() { dependencies.push((clean_name.to_string(), "system".to_string())); } } } } Ok(dependencies) } fn parse_rpm_dependencies(requires_str: &str) -> Result, String> { let mut dependencies = Vec::new(); for line in requires_str.lines() { let line = line.trim(); if !line.is_empty() && !line.starts_with("rpmlib(") && !line.starts_with('/') { let parts: Vec<&str> = line.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { dependencies.push((pkg_name.to_string(), "system".to_string())); } } } Ok(dependencies) } fn parse_autotools_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let configure_ac = project_dir.join("configure.ac"); let configure_in = project_dir.join("configure.in"); let config_file = if configure_ac.exists() { configure_ac } else if configure_in.exists() { configure_in } else { return Err("No autotools configuration file found".to_string()); }; let content = fs::read_to_string(&config_file) .map_err(|e| format!("Failed to read autotools config: {e}"))?; let mut dependencies = Vec::new(); let pkg_check_regex = Regex::new(r"PKG_CHECK_MODULES\s*\(\s*\w+\s*,\s*([^,\)]+)") .map_err(|e| format!("Failed to compile PKG_CHECK_MODULES regex: {e}"))?; for cap in pkg_check_regex.captures_iter(&content) { if let Some(pkg_spec) = cap.get(1) { let spec = pkg_spec .as_str() .trim() .trim_matches('"') .trim_matches('\''); let parts: Vec<&str> = spec.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { let version = if parts.len() > 2 && (parts[1] == ">=" || parts[1] == "=" || parts[1] == ">") { parts[2].to_string() } else { "system".to_string() }; dependencies.push((pkg_name.to_string(), version)); } } } let ac_check_lib_regex = Regex::new(r"AC_CHECK_LIB\s*\(\s*([^,\)]+)") .map_err(|e| format!("Failed to compile AC_CHECK_LIB regex: {e}"))?; for cap in ac_check_lib_regex.captures_iter(&content) { if let Some(lib_name) = cap.get(1) { let name = lib_name .as_str() .trim() .trim_matches('"') .trim_matches('\''); dependencies.push((name.to_string(), "system".to_string())); } } Ok(dependencies) } fn parse_makefile_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let makefile_paths = ["Makefile", "makefile", "GNUmakefile"]; for &makefile_name in &makefile_paths { let makefile_path = project_dir.join(makefile_name); if makefile_path.exists() { let content = fs::read_to_string(&makefile_path) .map_err(|e| format!("Failed to read {makefile_name}: {e}"))?; return parse_makefile_content(&content); } } Err("No Makefile found".to_string()) } fn parse_makefile_content(content: &str) -> Result, String> { let mut dependencies = Vec::new(); let ldflags_regex = Regex::new(r"-l([a-zA-Z0-9_-]+)") .map_err(|e| format!("Failed to compile ldflags regex: {e}"))?; for cap in ldflags_regex.captures_iter(content) { if let Some(lib_name) = cap.get(1) { let name = lib_name.as_str(); if !name.is_empty() && name != "c" && name != "m" { dependencies.push((format!("lib{name}"), "system".to_string())); } } } let pkgconfig_regex = Regex::new(r"`pkg-config\s+--[^`]*\s+([a-zA-Z0-9_-]+)") .map_err(|e| format!("Failed to compile pkg-config regex: {e}"))?; for cap in pkgconfig_regex.captures_iter(content) { if let Some(pkg_name) = cap.get(1) { dependencies.push((pkg_name.as_str().to_string(), "system".to_string())); } } Ok(dependencies) } fn parse_pkgconfig_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let output = Command::new("pkg-config") .args(["--list-all"]) .current_dir(project_dir) .output() .map_err(|e| format!("Failed to run pkg-config: {e}"))?; if !output.status.success() { return Err("pkg-config command failed".to_string()); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = Vec::new(); for line in stdout_str.lines().take(10) { if let Some(space_pos) = line.find(' ') { let pkg_name = &line[..space_pos]; if !pkg_name.is_empty() { dependencies.push((pkg_name.to_string(), "system".to_string())); } } } Ok(dependencies) } fn fetch_license_for_c_dependency(name: &str, version: &str) -> String { if version == "system" { if let Ok(license) = get_system_package_license(name) { return license; } } format!("Unknown license for {name}: {version}") } fn get_system_package_license(package_name: &str) -> Result { if let Ok(output) = Command::new("dpkg-query") .args(["-f", "${Package} ${License}\n", "-W", package_name]) .output() { if output.status.success() { let stdout_str = String::from_utf8_lossy(&output.stdout); if let Some(line) = stdout_str.lines().next() { if let Some(space_pos) = line.find(' ') { return Ok(line[space_pos + 1..].to_string()); } } } } if let Ok(output) = Command::new("rpm") .args(["-q", "--qf", "%{LICENSE}\n", package_name]) .output() { if output.status.success() { let license = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !license.is_empty() && license != "(none)" { return Ok(license); } } } Err(format!("Could not determine license for {package_name}")) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_parse_autotools_dependencies() { let temp_dir = TempDir::new().unwrap(); let configure_ac = temp_dir.path().join("configure.ac"); fs::write( &configure_ac, r#"AC_INIT([test], [1.0]) PKG_CHECK_MODULES([GLIB], [glib-2.0 >= 2.40]) PKG_CHECK_MODULES([GTK], [gtk+-3.0]) AC_CHECK_LIB([z], [inflate]) AC_CHECK_LIB([ssl], [SSL_new]) "#, ) .unwrap(); let config = FeludaConfig::default(); let result = parse_autotools_dependencies(temp_dir.path(), &config); // Just verify it doesn't crash - dependency parsing is complex assert!(result.is_ok()); } #[test] fn test_parse_makefile_dependencies() { let makefile_content = r#" CFLAGS = -Wall -O2 LDFLAGS = -lz -lssl -lpthread LIBS = `pkg-config --libs gtk+-3.0` test: test.o $(CC) -o test test.o $(LDFLAGS) "#; let result = parse_makefile_content(makefile_content).unwrap(); assert!(!result.is_empty()); assert!(result.iter().any(|(name, _)| name == "libz")); assert!(result.iter().any(|(name, _)| name == "libssl")); assert!(result.iter().any(|(name, _)| name == "libpthread")); } #[test] fn test_analyze_c_licenses_empty() { let temp_dir = TempDir::new().unwrap(); let dummy_file = temp_dir.path().join("dummy"); fs::write(&dummy_file, "").unwrap(); let config = FeludaConfig::default(); let dependencies = detect_c_dependencies(dummy_file.to_str().unwrap(), &config); // Should be empty or small since no autotools/makefile files exist // pkg-config might return some system packages, so just check it doesn't crash assert!(dependencies.len() <= 20); } } feluda-1.11.1/src/languages/cpp.rs000064400000000000000000000625141046102023000150420ustar 00000000000000use regex::Regex; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::Path; use std::process::Command; use crate::config::FeludaConfig; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; #[derive(Debug, Clone)] enum CppPackageManager { Vcpkg, Conan, CMake, Bazel, Unknown, } pub fn analyze_cpp_licenses(project_path: &str, config: &FeludaConfig) -> Vec { log( LogLevel::Info, &format!("Analyzing C++ dependencies from: {project_path}"), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; let (direct_dependencies, package_manager) = detect_cpp_dependencies_with_type(project_path, config); log( LogLevel::Info, &format!( "Found {} direct C++ dependencies", direct_dependencies.len() ), ); log_debug("Direct C++ dependencies", &direct_dependencies); let max_depth = config.dependencies.max_depth; log( LogLevel::Info, &format!("Using max dependency depth: {max_depth}"), ); let all_deps = resolve_cpp_dependencies( project_path, &direct_dependencies, package_manager, max_depth, ); log( LogLevel::Info, &format!( "Total C++ dependencies (including transitive): {}", all_deps.len() ), ); log_debug("All C++ dependencies", &all_deps); let dependencies = all_deps; dependencies .into_iter() .map(|(name, version)| { log( LogLevel::Info, &format!("Processing dependency: {name} ({version})"), ); let license_result = fetch_license_for_cpp_dependency(&name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, } }) .collect() } fn detect_cpp_dependencies_with_type( project_path: &str, config: &FeludaConfig, ) -> (Vec<(String, String)>, CppPackageManager) { let project_dir = Path::new(project_path).parent().unwrap_or(Path::new(".")); if let Ok(vcpkg_deps) = parse_vcpkg_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} vcpkg dependencies", vcpkg_deps.len()), ); return (vcpkg_deps, CppPackageManager::Vcpkg); } if let Ok(conan_deps) = parse_conan_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} conan dependencies", conan_deps.len()), ); return (conan_deps, CppPackageManager::Conan); } if let Ok(cmake_deps) = parse_cmake_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} cmake dependencies", cmake_deps.len()), ); return (cmake_deps, CppPackageManager::CMake); } if let Ok(bazel_deps) = parse_bazel_dependencies(project_dir, config) { log( LogLevel::Info, &format!("Found {} bazel dependencies", bazel_deps.len()), ); return (bazel_deps, CppPackageManager::Bazel); } (Vec::new(), CppPackageManager::Unknown) } fn resolve_cpp_dependencies( _project_path: &str, direct_deps: &[(String, String)], package_manager: CppPackageManager, max_depth: u32, ) -> Vec<(String, String)> { log( LogLevel::Info, &format!("Resolving C++ dependencies (including transitive up to depth {max_depth})"), ); let mut all_dependencies = Vec::new(); let mut visited = HashSet::new(); let mut depth_stats = HashMap::new(); // Add direct dependencies first for (name, version) in direct_deps { all_dependencies.push((name.clone(), version.clone())); visited.insert(name.clone()); *depth_stats.entry(0u32).or_insert(0) += 1; } // Queue for BFS: (package_name, version, depth) let mut to_process: Vec<(String, String, u32)> = direct_deps .iter() .map(|(name, version)| (name.clone(), version.clone(), 0)) .collect(); while let Some((name, version, depth)) = to_process.pop() { if depth >= max_depth { log( LogLevel::Trace, &format!("Skipping {name} - exceeded max depth {max_depth}"), ); continue; } log( LogLevel::Trace, &format!("Resolving transitive dependencies for: {name} (depth {depth})"), ); if let Ok(transitive_deps) = resolve_cpp_transitive_deps(&name, &version, &package_manager) { log( LogLevel::Trace, &format!( "Found {} transitive dependencies for {} at depth {}", transitive_deps.len(), name, depth ), ); for (dep_name, dep_version) in transitive_deps { if !visited.contains(&dep_name) { visited.insert(dep_name.clone()); all_dependencies.push((dep_name.clone(), dep_version.clone())); to_process.push((dep_name, dep_version, depth + 1)); *depth_stats.entry(depth + 1).or_insert(0) += 1; } } } } // Log depth statistics for depth in 0..=max_depth { if let Some(count) = depth_stats.get(&depth) { log( LogLevel::Info, &format!("Depth {depth}: {count} dependencies"), ); } } log( LogLevel::Info, &format!( "C++ dependency resolution completed. Total dependencies: {} (explored up to depth {})", all_dependencies.len(), max_depth ), ); all_dependencies } fn resolve_cpp_transitive_deps( package_name: &str, version: &str, package_manager: &CppPackageManager, ) -> Result, String> { match package_manager { CppPackageManager::Vcpkg => resolve_vcpkg_transitive(package_name, version), CppPackageManager::Conan => resolve_conan_transitive(package_name, version), CppPackageManager::CMake => resolve_cmake_transitive(package_name, version), CppPackageManager::Bazel => resolve_bazel_transitive(package_name, version), CppPackageManager::Unknown => Ok(Vec::new()), } } fn resolve_vcpkg_transitive( package_name: &str, _version: &str, ) -> Result, String> { // Try to fetch dependencies from vcpkg registry let url = format!( "https://raw.githubusercontent.com/microsoft/vcpkg/master/ports/{package_name}/vcpkg.json" ); if let Ok(response) = reqwest::blocking::get(&url) { if response.status().is_success() { if let Ok(json) = response.json::() { let mut dependencies = Vec::new(); if let Some(deps) = json.get("dependencies").and_then(|d| d.as_array()) { for dep in deps { match dep { Value::String(name) => { dependencies.push((name.clone(), "latest".to_string())); } Value::Object(obj) => { if let Some(name) = obj.get("name").and_then(|n| n.as_str()) { let version = obj .get("version") .and_then(|v| v.as_str()) .unwrap_or("latest"); dependencies.push((name.to_string(), version.to_string())); } } _ => {} } } } return Ok(dependencies); } } } Ok(Vec::new()) } fn resolve_conan_transitive( package_name: &str, version: &str, ) -> Result, String> { // Try to fetch dependencies from Conan Center let url = format!("https://conan.io/center/api/packages/{package_name}/{version}"); if let Ok(response) = reqwest::blocking::get(&url) { if response.status().is_success() { if let Ok(json) = response.json::() { let mut dependencies = Vec::new(); if let Some(requires) = json.get("requires").and_then(|r| r.as_array()) { for req in requires { if let Some(req_str) = req.as_str() { if let Some(slash_pos) = req_str.find('/') { let name = &req_str[..slash_pos]; let version = &req_str[slash_pos + 1..]; let clean_version = version.split('@').next().unwrap_or(version); dependencies.push((name.to_string(), clean_version.to_string())); } } } } return Ok(dependencies); } } } Ok(Vec::new()) } fn resolve_cmake_transitive( package_name: &str, _version: &str, ) -> Result, String> { // For CMake, we could try to find installed CMake config files // This is complex as it depends on the system and CMake installation // Try pkg-config if the package has a .pc file if let Ok(output) = Command::new("pkg-config") .args(["--print-requires", package_name]) .output() { if output.status.success() { let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = Vec::new(); for line in stdout_str.lines() { let trimmed = line.trim(); if !trimmed.is_empty() { let parts: Vec<&str> = trimmed.split_whitespace().collect(); if let Some(pkg_name) = parts.first() { let version = if parts.len() > 2 && (parts[1] == ">=" || parts[1] == "=" || parts[1] == ">") { parts[2].to_string() } else { "system".to_string() }; dependencies.push((pkg_name.to_string(), version)); } } } return Ok(dependencies); } } Ok(Vec::new()) } fn resolve_bazel_transitive( package_name: &str, _version: &str, ) -> Result, String> { // For Bazel, we could try to query the build graph // This would require being in a Bazel workspace // Try to run bazel query for dependencies if let Ok(output) = Command::new("bazel") .args(["query", &format!("deps(@{package_name}//...)")]) .output() { if output.status.success() { let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = Vec::new(); for line in stdout_str.lines() { let trimmed = line.trim(); if trimmed.starts_with('@') && trimmed.contains("//") { if let Some(at_pos) = trimmed.find('@') { if let Some(slash_pos) = trimmed.find("//") { let dep_name = &trimmed[at_pos + 1..slash_pos]; if !dep_name.is_empty() && dep_name != package_name { dependencies.push((dep_name.to_string(), "bazel".to_string())); } } } } } return Ok(dependencies); } } Ok(Vec::new()) } fn parse_vcpkg_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let vcpkg_json = project_dir.join("vcpkg.json"); if !vcpkg_json.exists() { return Err("No vcpkg.json found".to_string()); } let content = fs::read_to_string(&vcpkg_json).map_err(|e| format!("Failed to read vcpkg.json: {e}"))?; let json: Value = serde_json::from_str(&content).map_err(|e| format!("Failed to parse vcpkg.json: {e}"))?; let mut dependencies = Vec::new(); if let Some(deps) = json.get("dependencies").and_then(|d| d.as_array()) { for dep in deps { match dep { Value::String(name) => { dependencies.push((name.clone(), "latest".to_string())); } Value::Object(obj) => { if let Some(name) = obj.get("name").and_then(|n| n.as_str()) { let version = obj .get("version") .and_then(|v| v.as_str()) .unwrap_or("latest"); dependencies.push((name.to_string(), version.to_string())); } } _ => {} } } } Ok(dependencies) } fn parse_conan_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let conanfile_txt = project_dir.join("conanfile.txt"); let conanfile_py = project_dir.join("conanfile.py"); if conanfile_txt.exists() { parse_conanfile_txt(&conanfile_txt) } else if conanfile_py.exists() { parse_conanfile_py(&conanfile_py) } else { Err("No conanfile found".to_string()) } } fn parse_conanfile_txt(conanfile_path: &Path) -> Result, String> { let content = fs::read_to_string(conanfile_path) .map_err(|e| format!("Failed to read conanfile.txt: {e}"))?; let mut dependencies = Vec::new(); let mut in_requires_section = false; for line in content.lines() { let trimmed = line.trim(); if trimmed == "[requires]" { in_requires_section = true; continue; } if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed != "[requires]" { in_requires_section = false; continue; } if in_requires_section && !trimmed.is_empty() && !trimmed.starts_with('#') { if let Some(slash_pos) = trimmed.find('/') { let name = &trimmed[..slash_pos]; let version = &trimmed[slash_pos + 1..]; let clean_version = version.split('@').next().unwrap_or(version); dependencies.push((name.to_string(), clean_version.to_string())); } } } Ok(dependencies) } fn parse_conanfile_py(conanfile_path: &Path) -> Result, String> { let content = fs::read_to_string(conanfile_path) .map_err(|e| format!("Failed to read conanfile.py: {e}"))?; let mut dependencies = Vec::new(); let requires_regex = Regex::new(r#"requires\s*=\s*\[(.*?)\]"#) .map_err(|e| format!("Failed to compile requires regex: {e}"))?; if let Some(cap) = requires_regex.captures(&content) { if let Some(requires_content) = cap.get(1) { let req_str = requires_content.as_str(); let dep_regex = Regex::new(r#""([^"]+)""#) .map_err(|e| format!("Failed to compile dependency regex: {e}"))?; for dep_cap in dep_regex.captures_iter(req_str) { if let Some(dep_str) = dep_cap.get(1) { let dep = dep_str.as_str(); if let Some(slash_pos) = dep.find('/') { let name = &dep[..slash_pos]; let version = &dep[slash_pos + 1..]; let clean_version = version.split('@').next().unwrap_or(version); dependencies.push((name.to_string(), clean_version.to_string())); } } } } } Ok(dependencies) } fn parse_cmake_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let cmake_file = project_dir.join("CMakeLists.txt"); if !cmake_file.exists() { return Err("No CMakeLists.txt found".to_string()); } let content = fs::read_to_string(&cmake_file) .map_err(|e| format!("Failed to read CMakeLists.txt: {e}"))?; let mut dependencies = Vec::new(); let fetchcontent_regex = Regex::new(r"FetchContent_Declare\s*\(\s*(\w+)") .map_err(|e| format!("Failed to compile FetchContent regex: {e}"))?; for cap in fetchcontent_regex.captures_iter(&content) { if let Some(dep_name) = cap.get(1) { dependencies.push((dep_name.as_str().to_string(), "git".to_string())); } } let find_package_regex = Regex::new(r"find_package\s*\(\s*(\w+)(?:\s+([^)]+))?\)") .map_err(|e| format!("Failed to compile find_package regex: {e}"))?; for cap in find_package_regex.captures_iter(&content) { if let Some(pkg_name) = cap.get(1) { let version = cap .get(2) .map(|v| v.as_str().trim()) .and_then(|v| { if v.starts_with("REQUIRED") || v.starts_with("COMPONENTS") { None } else { Some(v.split_whitespace().next().unwrap_or("system")) } }) .unwrap_or("system"); dependencies.push((pkg_name.as_str().to_string(), version.to_string())); } } Ok(dependencies) } fn parse_bazel_dependencies( project_dir: &Path, _config: &FeludaConfig, ) -> Result, String> { let module_bazel = project_dir.join("MODULE.bazel"); let workspace = project_dir.join("WORKSPACE"); if module_bazel.exists() { parse_module_bazel(&module_bazel) } else if workspace.exists() { parse_workspace_bazel(&workspace) } else { Err("No Bazel build files found".to_string()) } } fn parse_module_bazel(module_path: &Path) -> Result, String> { let content = fs::read_to_string(module_path).map_err(|e| format!("Failed to read MODULE.bazel: {e}"))?; let mut dependencies = Vec::new(); let bazel_dep_regex = Regex::new(r#"bazel_dep\s*\(\s*name\s*=\s*"([^"]+)"\s*,\s*version\s*=\s*"([^"]+)""#) .map_err(|e| format!("Failed to compile bazel_dep regex: {e}"))?; for cap in bazel_dep_regex.captures_iter(&content) { if let (Some(name), Some(version)) = (cap.get(1), cap.get(2)) { dependencies.push((name.as_str().to_string(), version.as_str().to_string())); } } Ok(dependencies) } fn parse_workspace_bazel(workspace_path: &Path) -> Result, String> { let content = fs::read_to_string(workspace_path).map_err(|e| format!("Failed to read WORKSPACE: {e}"))?; let mut dependencies = Vec::new(); let http_archive_regex = Regex::new(r#"http_archive\s*\(\s*name\s*=\s*"([^"]+)""#) .map_err(|e| format!("Failed to compile http_archive regex: {e}"))?; for cap in http_archive_regex.captures_iter(&content) { if let Some(name) = cap.get(1) { dependencies.push((name.as_str().to_string(), "archive".to_string())); } } Ok(dependencies) } fn fetch_license_for_cpp_dependency(name: &str, version: &str) -> String { match version { "latest" | "git" => fetch_license_from_vcpkg_registry(name), v if v.chars().next().unwrap_or('0').is_ascii_digit() => { fetch_license_from_conan_center(name, version) } "system" => fetch_license_from_system_package(name), _ => format!("Unknown license for {name}: {version}"), } } fn fetch_license_from_vcpkg_registry(package_name: &str) -> String { let url = format!( "https://raw.githubusercontent.com/microsoft/vcpkg/master/ports/{package_name}/vcpkg.json" ); if let Ok(response) = reqwest::blocking::get(&url) { if response.status().is_success() { if let Ok(json) = response.json::() { if let Some(license) = json.get("license").and_then(|l| l.as_str()) { return license.to_string(); } } } } format!("Unknown license (vcpkg: {package_name})") } fn fetch_license_from_conan_center(package_name: &str, version: &str) -> String { let url = format!("https://conan.io/center/api/packages/{package_name}/{version}"); if let Ok(response) = reqwest::blocking::get(&url) { if response.status().is_success() { if let Ok(json) = response.json::() { if let Some(license) = json.get("license").and_then(|l| l.as_str()) { return license.to_string(); } } } } format!("Unknown license (conan: {package_name})") } fn fetch_license_from_system_package(package_name: &str) -> String { if let Ok(output) = Command::new("pkg-config") .args(["--variable=license", package_name]) .output() { if output.status.success() { let license = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !license.is_empty() { return license; } } } format!("Unknown license (system: {package_name})") } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_parse_vcpkg_dependencies() { let temp_dir = TempDir::new().unwrap(); let vcpkg_json = temp_dir.path().join("vcpkg.json"); fs::write( &vcpkg_json, r#"{ "name": "test-project", "version": "1.0.0", "dependencies": [ "boost", { "name": "opencv", "version": "4.5.0" } ] }"#, ) .unwrap(); let config = FeludaConfig::default(); let result = parse_vcpkg_dependencies(temp_dir.path(), &config).unwrap(); assert_eq!(result.len(), 2); assert!(result.iter().any(|(name, _)| name == "boost")); assert!(result .iter() .any(|(name, version)| name == "opencv" && version == "4.5.0")); } #[test] fn test_parse_conanfile_txt() { let temp_dir = TempDir::new().unwrap(); let conanfile = temp_dir.path().join("conanfile.txt"); fs::write( &conanfile, r#"[requires] boost/1.75.0 openssl/1.1.1k@ zlib/1.2.11 [generators] cmake "#, ) .unwrap(); let result = parse_conanfile_txt(&conanfile).unwrap(); assert_eq!(result.len(), 3); assert!(result .iter() .any(|(name, version)| name == "boost" && version == "1.75.0")); assert!(result .iter() .any(|(name, version)| name == "openssl" && version == "1.1.1k")); assert!(result .iter() .any(|(name, version)| name == "zlib" && version == "1.2.11")); } #[test] fn test_parse_cmake_dependencies() { let temp_dir = TempDir::new().unwrap(); let cmake_file = temp_dir.path().join("CMakeLists.txt"); fs::write( &cmake_file, r#"cmake_minimum_required(VERSION 3.14) project(TestProject) include(FetchContent) FetchContent_Declare(json URL https://github.com/nlohmann/json/releases/download/v3.10.5/json.tar.xz) FetchContent_MakeAvailable(json) find_package(Boost 1.70 REQUIRED COMPONENTS system filesystem) find_package(OpenSSL REQUIRED) "#, ) .unwrap(); let config = FeludaConfig::default(); let result = parse_cmake_dependencies(temp_dir.path(), &config).unwrap(); assert!(!result.is_empty()); assert!(result.iter().any(|(name, _)| name == "json")); assert!(result .iter() .any(|(name, version)| name == "Boost" && version == "1.70")); assert!(result.iter().any(|(name, _)| name == "OpenSSL")); } #[test] fn test_analyze_cpp_licenses_empty() { let temp_dir = TempDir::new().unwrap(); let dummy_file = temp_dir.path().join("dummy"); fs::write(&dummy_file, "").unwrap(); let config = FeludaConfig::default(); let result = analyze_cpp_licenses(dummy_file.to_str().unwrap(), &config); // Should be empty since no build files exist assert!(result.is_empty()); } } feluda-1.11.1/src/languages/dotnet.rs000064400000000000000000000336711046102023000155570ustar 00000000000000use regex::Regex; use reqwest::blocking::Client; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use crate::config::FeludaConfig; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; #[derive(Debug, Clone)] pub struct NuGetPackage { pub name: String, pub version: String, } #[derive(Deserialize, Serialize, Debug)] struct PackagesLockJson { version: i32, dependencies: Option>>, } #[derive(Deserialize, Serialize, Debug)] struct PackageLockInfo { #[serde(rename = "type")] package_type: Option, resolved: Option, #[serde(rename = "contentHash")] content_hash: Option, dependencies: Option>, } pub fn analyze_dotnet_licenses(project_path: &str, config: &FeludaConfig) -> Vec { log( LogLevel::Info, &format!("Analyzing .NET dependencies from: {project_path}"), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; let direct_deps = match detect_and_parse_project(project_path) { Ok(deps) => deps, Err(err) => { log_error("Failed to parse .NET project", &err); return Vec::new(); } }; log( LogLevel::Info, &format!("Found {} direct .NET dependencies", direct_deps.len()), ); log_debug("Direct .NET dependencies", &direct_deps); let max_depth = config.dependencies.max_depth; log( LogLevel::Info, &format!("Using max dependency depth: {max_depth}"), ); let all_deps = resolve_dotnet_dependencies(project_path, &direct_deps, max_depth); let mut licenses = Vec::new(); for (name, version) in all_deps { log( LogLevel::Info, &format!("Processing dependency: {name} ({version})"), ); let license_result = fetch_license_for_nuget_package(&name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } licenses.push(LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, }); } log( LogLevel::Info, &format!("Found {} .NET dependencies with licenses", licenses.len()), ); licenses } fn detect_and_parse_project(project_path: &str) -> Result, String> { let path = Path::new(project_path); if path.extension().and_then(|s| s.to_str()) == Some("slnx") { parse_slnx_solution(project_path) } else if let Some(ext) = path.extension().and_then(|s| s.to_str()) { if ext == "csproj" || ext == "fsproj" || ext == "vbproj" { parse_csproj_file(project_path) } else { Err(format!("Unsupported .NET file type: {ext}")) } } else { let parent_dir = path.parent().unwrap_or(path); if let Ok(lock_path) = find_file_in_dir(parent_dir, "packages.lock.json") { parse_packages_lock_json(&lock_path) } else { parse_csproj_file(project_path) } } } fn parse_slnx_solution(slnx_path: &str) -> Result, String> { log(LogLevel::Info, &format!("Parsing .slnx file: {slnx_path}")); let content = fs::read_to_string(slnx_path).map_err(|e| format!("Failed to read .slnx file: {e}"))?; let re = Regex::new(r#" { for pkg in packages { let key = format!("{}@{}", pkg.name, pkg.version); if seen.insert(key) { all_packages.push(pkg); } } } Err(e) => log_error( &format!("Failed to parse project: {}", project_path.display()), &e, ), } } } log( LogLevel::Info, &format!("Found {} total packages from solution", all_packages.len()), ); Ok(all_packages) } fn parse_csproj_file(csproj_path: &str) -> Result, String> { log( LogLevel::Info, &format!("Parsing .csproj file: {csproj_path}"), ); let content = fs::read_to_string(csproj_path).map_err(|e| format!("Failed to read .csproj file: {e}"))?; let mut packages = Vec::new(); let pkg_re = Regex::new(r#""#) .map_err(|e| format!("Failed to compile regex: {e}"))?; for cap in pkg_re.captures_iter(&content) { let name = cap[1].to_string(); let version = cap[2].to_string(); packages.push(NuGetPackage { name, version }); } let csproj_dir = Path::new(csproj_path) .parent() .ok_or("Failed to get parent directory")?; if let Ok(project_refs) = parse_project_references(&content, csproj_dir) { for ref_path in project_refs { match parse_csproj_file(&ref_path) { Ok(ref_packages) => packages.extend(ref_packages), Err(e) => log_error( &format!("Failed to parse project reference: {ref_path}"), &e, ), } } } log( LogLevel::Info, &format!("Found {} packages in .csproj", packages.len()), ); Ok(packages) } fn parse_project_references(content: &str, base_dir: &Path) -> Result, String> { let re = Regex::new(r#""#) .map_err(|e| format!("Failed to compile regex: {e}"))?; let mut references = Vec::new(); for cap in re.captures_iter(content) { let rel_path = &cap[1]; let normalized_path = rel_path.replace('\\', "/"); let abs_path = base_dir.join(normalized_path); if let Some(path_str) = abs_path.to_str() { references.push(path_str.to_string()); } } Ok(references) } fn parse_packages_lock_json(lock_path: &str) -> Result, String> { log( LogLevel::Info, &format!("Parsing packages.lock.json: {lock_path}"), ); let content = fs::read_to_string(lock_path) .map_err(|e| format!("Failed to read packages.lock.json: {e}"))?; let lock_data: PackagesLockJson = serde_json::from_str(&content) .map_err(|e| format!("Failed to parse packages.lock.json: {e}"))?; let mut packages = Vec::new(); if let Some(dependencies) = lock_data.dependencies { for (_framework, packages_map) in dependencies { for (name, info) in packages_map { if let Some(resolved) = &info.resolved { packages.push(NuGetPackage { name: name.clone(), version: resolved.clone(), }); } } } } log( LogLevel::Info, &format!("Found {} packages in lock file", packages.len()), ); Ok(packages) } fn resolve_dotnet_dependencies( project_path: &str, direct_deps: &[NuGetPackage], max_depth: u32, ) -> Vec<(String, String)> { if max_depth == 0 { return direct_deps .iter() .map(|p| (p.name.clone(), p.version.clone())) .collect(); } match resolve_with_dotnet_list(project_path) { Ok(deps) => deps, Err(e) => { log_error( "Failed to resolve with dotnet list, using direct dependencies", &e, ); direct_deps .iter() .map(|p| (p.name.clone(), p.version.clone())) .collect() } } } fn resolve_with_dotnet_list(project_path: &str) -> Result, String> { log( LogLevel::Info, "Attempting to resolve dependencies with dotnet list package", ); let path = Path::new(project_path); let work_dir = if path.is_dir() { path } else { path.parent().ok_or("Failed to get parent directory")? }; let output = Command::new("dotnet") .args(["list", "package", "--include-transitive"]) .current_dir(work_dir) .output() .map_err(|e| format!("Failed to execute dotnet command: {e}"))?; if !output.status.success() { return Err(format!( "dotnet list package failed: {}", String::from_utf8_lossy(&output.stderr) )); } let stdout = String::from_utf8_lossy(&output.stdout); parse_dotnet_list_output(&stdout) } fn parse_dotnet_list_output(output: &str) -> Result, String> { let re = Regex::new(r">\s+(\S+)\s+(\S+)").map_err(|e| format!("Failed to compile regex: {e}"))?; let mut packages = Vec::new(); for cap in re.captures_iter(output) { let name = cap[1].to_string(); let version = cap[2].to_string(); packages.push((name, version)); } log( LogLevel::Info, &format!("Parsed {} packages from dotnet list output", packages.len()), ); Ok(packages) } fn fetch_license_for_nuget_package(name: &str, version: &str) -> String { if let Ok(license) = fetch_from_local_nuget_cache(name, version) { return license; } if let Ok(license) = fetch_from_nuget_api(name, version) { return license; } log( LogLevel::Warn, &format!("Could not find license for {name} {version}"), ); "Unknown".to_string() } fn fetch_from_local_nuget_cache(name: &str, version: &str) -> Result { let home = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .map_err(|_| "Cannot determine home directory")?; let nuget_cache = PathBuf::from(home) .join(".nuget") .join("packages") .join(name.to_lowercase()) .join(version) .join(format!("{}.nuspec", name.to_lowercase())); if nuget_cache.exists() { let content = fs::read_to_string(&nuget_cache).map_err(|e| format!("Failed to read nuspec: {e}"))?; return parse_license_from_nuspec(&content); } Err("Not found in local cache".to_string()) } fn fetch_from_nuget_api(name: &str, version: &str) -> Result { let client = Client::builder() .timeout(std::time::Duration::from_secs(10)) .build() .map_err(|e| format!("Failed to create HTTP client: {e}"))?; let nuspec_url = format!( "https://api.nuget.org/v3-flatcontainer/{}/{}/{}.nuspec", name.to_lowercase(), version.to_lowercase(), name.to_lowercase() ); log( LogLevel::Info, &format!("Fetching from NuGet: {nuspec_url}"), ); let response = client .get(&nuspec_url) .send() .map_err(|e| format!("Failed to fetch nuspec: {e}"))?; if !response.status().is_success() { return Err(format!("NuGet API returned status: {}", response.status())); } let content = response .text() .map_err(|e| format!("Failed to read response: {e}"))?; parse_license_from_nuspec(&content) } fn parse_license_from_nuspec(content: &str) -> Result { if let Ok(re) = Regex::new(r"]*>([^<]+)") { if let Some(cap) = re.captures(content) { return Ok(cap[1].trim().to_string()); } } if let Ok(re) = Regex::new(r#"([^<]+)"#) { if let Some(cap) = re.captures(content) { let url = cap[1].trim(); if url.contains("MIT") { return Ok("MIT".to_string()); } else if url.contains("Apache") { return Ok("Apache-2.0".to_string()); } else if url.contains("BSD") { return Ok("BSD".to_string()); } else if url.contains("GPL") { return Ok("GPL".to_string()); } return Ok(url.to_string()); } } Err("No license found in nuspec".to_string()) } fn find_file_in_dir(dir: &Path, filename: &str) -> Result { let file_path = dir.join(filename); if file_path.exists() { file_path .to_str() .map(|s| s.to_string()) .ok_or_else(|| "Invalid path".to_string()) } else { Err(format!("{filename} not found in directory")) } } feluda-1.11.1/src/languages/go.rs000064400000000000000000000742541046102023000146710ustar 00000000000000use regex::Regex; use reqwest::blocking::Client; use scraper::{Html, Selector}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use std::thread::sleep; use std::time::Duration; use crate::config::FeludaConfig; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; /// Go module names to exclude from dependency analysis /// These are special Go directives and built-in modules, not actual dependencies const EXCLUDED_GO_MODULES: &[&str] = &["go", "toolchain"]; /// Go package information #[derive(Debug)] pub struct GoPackages { pub name: String, pub version: String, } /// Analyze the licenses of Go dependencies pub fn analyze_go_licenses(go_mod_path: &str, config: &FeludaConfig) -> Vec { log( LogLevel::Info, &format!("Analyzing Go dependencies from: {go_mod_path}"), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; let content = match fs::read_to_string(go_mod_path) { Ok(content) => content, Err(err) => { log_error(&format!("Failed to read go.mod file: {go_mod_path}"), &err); return Vec::new(); } }; let direct_dependencies = get_go_dependencies(content); log( LogLevel::Info, &format!("Found {} direct Go dependencies", direct_dependencies.len()), ); log_debug("Direct Go dependencies", &direct_dependencies); // Try to resolve all dependencies using go mod graph let max_depth = config.dependencies.max_depth; log( LogLevel::Info, &format!("Using max dependency depth: {max_depth}"), ); let all_deps = resolve_go_dependencies(go_mod_path, &direct_dependencies, max_depth); // Process all resolved dependencies let mut licenses = Vec::new(); for (name, version) in all_deps { log( LogLevel::Info, &format!("Processing dependency: {name} ({version})"), ); let license_result = fetch_license_for_go_dependency(name.as_str(), version.as_str()); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } licenses.push(LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, }); } log( LogLevel::Info, &format!("Found {} Go dependencies with licenses", licenses.len()), ); licenses } /// Parse Go dependencies from go.mod content pub fn get_go_dependencies(content_string: String) -> Vec { log(LogLevel::Info, "Parsing Go dependencies"); let re_comment = match Regex::new(r"(?m)^(.*?)\s*(//|#).*?$") { Ok(re) => re, Err(err) => { log_error("Failed to compile comment regex", &err); return Vec::new(); } }; let cleaned = re_comment.replace_all(content_string.as_str(), "$1"); let re = match Regex::new( r"require\s*(?:\(\s*)?((?:[\w./-]+\s+v[\d][\w\d.-]+(?:-\w+)?(?:\+\w+)?\s*)+)\)?", ) { Ok(re) => re, Err(err) => { log_error("Failed to compile require regex", &err); return Vec::new(); } }; let re_dependency = match Regex::new(r"([\w./-]+)\s+(v[\d]+(?:\.\d+)*(?:-\S+)?)") { Ok(re) => re, Err(err) => { log_error("Failed to compile dependency regex", &err); return Vec::new(); } }; let mut dependency = vec![]; for cap in re.captures_iter(&cleaned) { let dependency_block = &cap[1]; log_debug("Dependency block", &dependency_block); for dep_cap in re_dependency.captures_iter(dependency_block) { let name = dep_cap[1].to_string(); let version = dep_cap[2].to_string(); // Skip excluded Go modules if is_excluded_go_module(&name) { log( LogLevel::Info, &format!("Skipping excluded Go module: {name} ({version})"), ); continue; } log( LogLevel::Info, &format!("Found Go dependency: {name} ({version})"), ); dependency.push(GoPackages { name, version }); } } log( LogLevel::Info, &format!("Parsed {} Go dependencies", dependency.len()), ); dependency } /// Resolve all Go dependencies fn resolve_go_dependencies( go_mod_path: &str, direct_deps: &[GoPackages], max_depth: u32, ) -> Vec<(String, String)> { log( LogLevel::Info, &format!("Resolving Go dependencies (including transitive up to depth {max_depth})"), ); // go mod graph for complete dependency resolution if let Ok(go_deps) = resolve_with_go_mod_graph(go_mod_path, max_depth) { if !go_deps.is_empty() { log( LogLevel::Info, &format!( "Resolved {} dependencies using go mod graph (depth {})", go_deps.len(), max_depth ), ); return go_deps; } } // Direct dependencies in case go mod graph fails log( LogLevel::Info, "Falling back to direct dependencies only (go mod graph not available)", ); direct_deps .iter() .map(|dep| (dep.name.clone(), dep.version.clone())) .collect() } /// Resolve dependencies using go mod graph with depth limit fn resolve_with_go_mod_graph( go_mod_path: &str, max_depth: u32, ) -> Result, String> { let project_dir = Path::new(go_mod_path) .parent() .ok_or("Cannot determine project directory")?; log( LogLevel::Info, &format!("Attempting to resolve dependencies with go mod graph (max depth: {max_depth})"), ); // Run go mod graph to get dependency graph let output = Command::new("go") .args(["mod", "graph"]) .current_dir(project_dir) .output() .map_err(|e| format!("Failed to run go mod graph: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("go mod graph failed: {stderr}")); } let stdout_str = String::from_utf8_lossy(&output.stdout); let deps = parse_go_mod_graph_output(&stdout_str, max_depth); log( LogLevel::Info, &format!( "Resolved {} dependencies from go mod graph output", deps.len() ), ); Ok(deps) } /// Parse go mod graph output to extract dependencies with depth awareness fn parse_go_mod_graph_output(output: &str, max_depth: u32) -> Vec<(String, String)> { let mut all_deps = HashMap::new(); let mut depth_map = HashMap::new(); let mut edges = HashMap::new(); log( LogLevel::Info, &format!("Parsing go mod graph with max depth {max_depth}"), ); // Collect all edges and modules for line in output.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some((from, to)) = line.split_once(' ') { let from = from.trim(); let to = to.trim(); // Parse module name and version if let Some((from_name, from_version)) = parse_go_module_version(from) { all_deps.insert(from_name.clone(), from_version); } if let Some((to_name, to_version)) = parse_go_module_version(to) { all_deps.insert(to_name.clone(), to_version); // Track edges for depth calculation edges .entry(from.to_string()) .or_insert_with(Vec::new) .push(to.to_string()); } } } // Find root modules let mut roots = HashSet::new(); let mut destinations = HashSet::new(); for line in output.lines() { let line = line.trim(); if line.is_empty() { continue; } if let Some((from, to)) = line.split_once(' ') { let from = from.trim(); let to = to.trim(); roots.insert(from.to_string()); destinations.insert(to.to_string()); } } let root_modules: Vec<_> = roots.difference(&destinations).collect(); // Calculate depths using BFS from root modules let mut queue = Vec::new(); let mut visited = HashSet::new(); for root in root_modules { queue.push((root.clone(), 0u32)); depth_map.insert(root.clone(), 0); } let mut depth_stats = HashMap::new(); while let Some((current, depth)) = queue.pop() { if visited.contains(¤t) || depth >= max_depth { if depth >= max_depth { log( LogLevel::Info, &format!("Skipping {current} - exceeded max depth {max_depth}"), ); } continue; } visited.insert(current.clone()); depth_map.insert(current.clone(), depth); // Track depth statistics *depth_stats.entry(depth).or_insert(0) += 1; // Add children to queue if let Some(children) = edges.get(¤t) { for child in children { if !visited.contains(child) && depth + 1 < max_depth { queue.push((child.clone(), depth + 1)); } } } } // Filter dependencies based on depth limit let filtered_deps: Vec<(String, String)> = all_deps .into_iter() .filter(|(name, _version)| { // Find the full module name in depth_map for (module_full, depth) in &depth_map { if let Some((module_name, _)) = parse_go_module_version(module_full) { if module_name == *name && *depth < max_depth { return true; } } } false }) .collect(); // Log depth statistics for depth in 0..max_depth { if let Some(count) = depth_stats.get(&depth) { log( LogLevel::Info, &format!("Depth {depth}: {count} dependencies"), ); } } log( LogLevel::Info, &format!( "Go mod graph resolution completed. Total dependencies: {} (explored up to depth {})", filtered_deps.len(), max_depth ), ); filtered_deps } /// Check if a module name should be excluded from dependency analysis fn is_excluded_go_module(module_name: &str) -> bool { EXCLUDED_GO_MODULES.contains(&module_name) } /// Parse Go module string to extract name and version fn parse_go_module_version(module_str: &str) -> Option<(String, String)> { // Handle formats like: github.com/user/repo@v1.2.3 or github.com/user/repo@v1.2.3-0.20210101000000-abcdef123456 if let Some(at_pos) = module_str.rfind('@') { let name = module_str[..at_pos].to_string(); let version = module_str[at_pos + 1..].to_string(); // Filter out excluded Go modules if is_excluded_go_module(&name) { return None; } Some((name, version)) } else { // Handle cases without version let module_name = module_str.to_string(); // Filter out excluded Go modules if is_excluded_go_module(&module_name) { return None; } Some((module_name, "unknown".to_string())) } } /// Fetch the license for a Go dependency, trying local sources first, then pkg.go.dev pub fn fetch_license_for_go_dependency( name: impl Into, version: impl Into, ) -> String { let name = name.into(); let version = version.into(); if let Some(license) = get_license_from_local_go_mod(&name) { log( LogLevel::Info, &format!("Found license in local go.mod for {name}: {license}"), ); return license; } if let Some(license) = get_license_from_go_module_cache(&name, &version) { log( LogLevel::Info, &format!("Found license in Go module cache for {name}: {license}"), ); return license; } fetch_license_from_pkg_go_dev(&name) } fn get_license_from_local_go_mod(package_name: &str) -> Option { let go_mod_path = Path::new("go.mod"); if !go_mod_path.exists() { return None; } let content = fs::read_to_string(go_mod_path).ok()?; for line in content.lines() { let line = line.trim(); if line.starts_with("//") && line.to_lowercase().contains("license") && line.contains(package_name) { if let Some(license_part) = line.split("license:").nth(1) { return Some(license_part.trim().to_string()); } } } None } fn get_license_from_go_module_cache(package_name: &str, version: &str) -> Option { let module_cache = get_gomodcache_path()?; let exact_path = build_module_cache_path(&module_cache, package_name, version); if let Some(license) = read_license_from_dir(&exact_path) { return Some(license); } find_license_in_any_version(&module_cache, package_name) } fn get_gomodcache_path() -> Option { if let Ok(output) = Command::new("go").args(["env", "GOMODCACHE"]).output() { if output.status.success() { let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); if !path.is_empty() { let path_buf = PathBuf::from(&path); if path_buf.exists() { return Some(path_buf); } } } } let home_dir = std::env::var("HOME") .or_else(|_| std::env::var("USERPROFILE")) .ok()?; let gopath = std::env::var("GOPATH").unwrap_or_else(|_| format!("{home_dir}/go")); let fallback = Path::new(&gopath).join("pkg").join("mod"); if fallback.exists() { Some(fallback) } else { None } } fn escape_go_module_path(module: &str) -> String { let mut escaped = String::with_capacity(module.len()); for ch in module.chars() { match ch { '!' => escaped.push_str("!!"), 'A'..='Z' => { escaped.push('!'); escaped.extend(ch.to_lowercase()); } _ => escaped.push(ch), } } escaped } fn build_module_cache_path(root: &Path, module: &str, version: &str) -> PathBuf { let escaped = escape_go_module_path(module); root.join(format!("{escaped}@{version}")) } fn read_license_from_dir(dir: &Path) -> Option { if !dir.exists() { return None; } let license_files = [ "LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING", "COPYING.md", ]; for license_file in &license_files { let license_path = dir.join(license_file); if license_path.exists() { if let Ok(content) = fs::read_to_string(&license_path) { if let Some(license) = detect_license_from_content(&content) { return Some(license); } } } } None } fn find_license_in_any_version(root: &Path, module: &str) -> Option { let escaped = escape_go_module_path(module); let prefix = format!("{escaped}@"); if let Ok(entries) = fs::read_dir(root) { for entry in entries.flatten() { let path = entry.path(); if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) { if file_name.starts_with(&prefix) { if let Some(license) = read_license_from_dir(&path) { return Some(license); } } } } } None } fn detect_license_from_content(content: &str) -> Option { let content_upper = content.to_uppercase(); let patterns = vec![ ("MIT", "MIT License"), ("APACHE", "Apache License"), ("GPL", "GPL"), ("BSD", "BSD"), ("ISC", "ISC License"), ("LGPL", "LGPL"), ("UNLICENSE", "Unlicense"), ("MPL", "Mozilla Public License"), ]; for (pattern, label) in patterns { if content_upper.contains(pattern) { return Some(label.to_string()); } } None } fn fetch_license_from_pkg_go_dev(name: &str) -> String { let api_url = format!("https://pkg.go.dev/{name}?tab=licenses"); log( LogLevel::Info, &format!("Fetching license from Go Package Index: {api_url}"), ); let client = match Client::builder() .user_agent("feluda.anirudha.dev/1") .connect_timeout(Duration::from_secs(60)) .timeout(Duration::from_secs(10)) .build() { Ok(client) => client, Err(err) => { log_error("Failed to build HTTP client", &err); return "Unknown".into(); } }; let mut attempts = 0; let max_attempts = 7; // Retry max 7 times. Thala for a reason 🙌 let wait_time = 12; while attempts < max_attempts { let response = client .get(&api_url) .header( "User-Agent", "Mozilla/5.0 (compatible; Feluda-Bot/1.0; +https://github.com/anistark/feluda)", ) .header( "Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", ) .header("Referer", "https://pkg.go.dev/") .send(); match response { Ok(response) => { let status = response.status(); log( LogLevel::Info, &format!("Go Package Index API response status: {status}"), ); if status.as_u16() == 429 { log( LogLevel::Warn, &format!( "Received 429 Too Many Requests, retrying... (attempt {}/{})", attempts + 1, max_attempts ), ); sleep(Duration::from_secs(wait_time)); attempts += 1; continue; } if status.is_success() { match response.text() { Ok(html_content) => { if let Some(license) = extract_license_from_html(&html_content) { log( LogLevel::Info, &format!("License found for {name}: {license}"), ); return license; } else { log( LogLevel::Warn, &format!("No license found in HTML for {name}"), ); } } Err(err) => { log_error(&format!("Failed to extract HTML content for {name}"), &err); } } } else { log( LogLevel::Error, &format!("Unexpected HTTP status: {status} for {name}"), ); } break; } Err(err) => { log_error(&format!("Failed to fetch metadata for {name}"), &err); break; } } } log( LogLevel::Warn, &format!("Unable to determine license for {name} after {attempts} attempts"), ); "Unknown".into() } /// Extract license information from the HTML content fn extract_license_from_html(html: &str) -> Option { log(LogLevel::Info, "Extracting license from HTML content"); let document = Html::parse_document(html); // Select the
with class "License" let section_selector = match Selector::parse("section.License") { Ok(selector) => selector, Err(err) => { log_error("Failed to parse section selector", &err); return None; } }; let div_selector = match Selector::parse("h2.go-textTitle div") { Ok(selector) => selector, Err(err) => { log_error("Failed to parse div selector", &err); return None; } }; if let Some(section) = document.select(§ion_selector).next() { if let Some(div) = section.select(&div_selector).next() { let license_text = div.text().collect::>().join(" ").trim().to_string(); log( LogLevel::Info, &format!("License found in HTML: {license_text}"), ); return Some(license_text); } else { log(LogLevel::Warn, "Found section but no license div"); } } else { log(LogLevel::Warn, "No license section found in HTML"); } None } #[cfg(test)] mod tests { use super::*; #[test] fn test_get_go_dependencies() { let content = r#"require ( github.com/user/repo v1.0.0 github.com/another/pkg v2.3.4 )"#; let deps = get_go_dependencies(content.to_string()); assert_eq!(deps.len(), 2); assert_eq!(deps[0].name, "github.com/user/repo"); assert_eq!(deps[0].version, "v1.0.0"); } #[test] fn test_get_go_dependencies_single_require() { let content = "require github.com/test/pkg v1.0.0".to_string(); let deps = get_go_dependencies(content); assert_eq!(deps.len(), 1); assert_eq!(deps[0].name, "github.com/test/pkg"); assert_eq!(deps[0].version, "v1.0.0"); } #[test] fn test_get_go_dependencies_with_comments() { let content = r#"require ( github.com/user/repo v1.0.0 // This is a comment github.com/another/pkg v2.0.0 # This is also a comment )"# .to_string(); let deps = get_go_dependencies(content); assert_eq!(deps.len(), 2); assert_eq!(deps[0].name, "github.com/user/repo"); assert_eq!(deps[1].name, "github.com/another/pkg"); } #[test] fn test_get_go_dependencies_complex_versions() { let content = r#"require ( github.com/user/repo v1.2.3-beta+build github.com/another/pkg v2.0.0-rc.1 github.com/third/mod v0.1.0-alpha )"# .to_string(); let deps = get_go_dependencies(content); assert_eq!(deps.len(), 3); assert_eq!(deps[0].version, "v1.2.3-beta+build"); assert_eq!(deps[1].version, "v2.0.0-rc.1"); assert_eq!(deps[2].version, "v0.1.0-alpha"); } #[test] fn test_get_go_dependencies_empty_content() { let content = "".to_string(); let deps = get_go_dependencies(content); assert!(deps.is_empty()); } #[test] fn test_extract_license_from_html() { let html_content = r#"

MIT

"#; let license = extract_license_from_html(html_content); assert_eq!(license, Some("MIT".to_string())); } #[test] fn test_extract_license_from_html_no_license() { let html_content = r#" "#; let license = extract_license_from_html(html_content); assert_eq!(license, None); } #[test] fn test_fetch_license_for_go_dependency_error_handling() { // Test with invalid package name let result = fetch_license_for_go_dependency("invalid/package/name", "v1.0.0"); assert_eq!(result, "Unknown"); } #[test] fn test_go_packages_debug() { let go_package = GoPackages { name: "github.com/test/package".to_string(), version: "v1.0.0".to_string(), }; let debug_str = format!("{go_package:?}"); assert!(debug_str.contains("github.com/test/package")); assert!(debug_str.contains("v1.0.0")); } #[test] fn test_parse_go_module_version() { // Test with version assert_eq!( parse_go_module_version("github.com/user/repo@v1.2.3"), Some(("github.com/user/repo".to_string(), "v1.2.3".to_string())) ); // Test with complex version assert_eq!( parse_go_module_version("github.com/user/repo@v1.2.3-0.20210101000000-abcdef123456"), Some(( "github.com/user/repo".to_string(), "v1.2.3-0.20210101000000-abcdef123456".to_string() )) ); // Test without version assert_eq!( parse_go_module_version("github.com/user/repo"), Some(("github.com/user/repo".to_string(), "unknown".to_string())) ); } #[test] fn test_parse_go_mod_graph_output() { let graph_output = r#" github.com/myproject@v0.0.0 github.com/gin-gonic/gin@v1.9.1 github.com/myproject@v0.0.0 github.com/golang/protobuf@v1.5.3 github.com/gin-gonic/gin@v1.9.1 github.com/bytedance/sonic@v1.9.1 github.com/gin-gonic/gin@v1.9.1 github.com/chenzhuoyu/base64x@v0.0.0-20221115062448-fe3a3abad311 github.com/bytedance/sonic@v1.9.1 github.com/klauspost/cpuid/v2@v2.0.9 "#; let deps = parse_go_mod_graph_output(graph_output, 5); // Should include dependencies up to the specified depth assert!(!deps.is_empty()); // Should include root dependencies let dep_names: Vec = deps.iter().map(|(name, _)| name.clone()).collect(); assert!(dep_names.contains(&"github.com/gin-gonic/gin".to_string())); assert!(dep_names.contains(&"github.com/golang/protobuf".to_string())); } #[test] fn test_parse_go_mod_graph_output_with_depth_limit() { let graph_output = r#"github.com/myproject@v0.0.0 github.com/level1@v1.0.0 github.com/level1@v1.0.0 github.com/level2@v1.0.0 github.com/level2@v1.0.0 github.com/level3@v1.0.0"#; // With depth limit 3, should include level1 and level2 but not level3 let deps = parse_go_mod_graph_output(graph_output, 3); let dep_names: Vec = deps.iter().map(|(name, _)| name.clone()).collect(); // level1 is at depth 1, level2 is at depth 2 - both should be included with max_depth 3 assert!(dep_names.contains(&"github.com/level1".to_string())); assert!(dep_names.contains(&"github.com/level2".to_string())); // level3 is at depth 3, should not be included with max_depth 3 (since we check depth >= max_depth) assert!(!dep_names.contains(&"github.com/level3".to_string())); } #[test] fn test_resolve_go_dependencies_fallback() { let direct_deps = vec![ GoPackages { name: "github.com/test/pkg1".to_string(), version: "v1.0.0".to_string(), }, GoPackages { name: "github.com/test/pkg2".to_string(), version: "v2.0.0".to_string(), }, ]; // This should fall back to direct dependencies when go mod graph fails let result = resolve_go_dependencies("/nonexistent/go.mod", &direct_deps, 5); assert_eq!(result.len(), 2); assert_eq!( result[0], ("github.com/test/pkg1".to_string(), "v1.0.0".to_string()) ); assert_eq!( result[1], ("github.com/test/pkg2".to_string(), "v2.0.0".to_string()) ); } #[test] fn test_is_excluded_go_module() { // Test that standard Go modules are excluded assert!(is_excluded_go_module("go")); assert!(is_excluded_go_module("toolchain")); // Test that regular packages are not excluded assert!(!is_excluded_go_module("github.com/user/repo")); assert!(!is_excluded_go_module("go.uber.org/zap")); } #[test] fn test_get_go_dependencies_excludes_toolchain() { let content = r#"require ( github.com/gin-gonic/gin v1.9.1 go v1.21 github.com/spf13/cobra v1.8.0 toolchain go1.24 )"#; let deps = get_go_dependencies(content.to_string()); // Should only have 2 real dependencies, go and toolchain should be filtered out assert_eq!(deps.len(), 2); assert_eq!(deps[0].name, "github.com/gin-gonic/gin"); assert_eq!(deps[1].name, "github.com/spf13/cobra"); } #[test] fn test_parse_go_module_version_excludes_toolchain() { // Test that go module is filtered out assert!(parse_go_module_version("go@1.21").is_none()); // Test that toolchain module is filtered out assert!(parse_go_module_version("toolchain@go1.24").is_none()); // Test that regular modules are parsed correctly let result = parse_go_module_version("github.com/user/repo@v1.2.3"); assert!(result.is_some()); let (name, version) = result.unwrap(); assert_eq!(name, "github.com/user/repo"); assert_eq!(version, "v1.2.3"); } } feluda-1.11.1/src/languages/mod.rs000064400000000000000000000056711046102023000150400ustar 00000000000000//! Language-specific parsing and license analysis modules pub mod c; pub mod cpp; pub mod dotnet; pub mod go; pub mod node; pub mod python; pub mod r; pub mod rust; use crate::licenses::LicenseInfo; use std::path::Path; /// Common trait for language-specific dependency parsers #[allow(dead_code)] pub trait LanguageParser { /// Parse dependencies from a project file and return license information fn parse_dependencies( &self, project_path: &Path, ) -> crate::debug::FeludaResult>; /// Get the name of the language fn language_name(&self) -> &'static str; /// Get the typical project files fn supported_files(&self) -> &'static [&'static str]; } /// Language identification #[derive(Debug, PartialEq, Clone, Copy)] pub enum Language { C(&'static [&'static str]), Cpp(&'static [&'static str]), DotNet(&'static [&'static str]), Rust(&'static str), Node(&'static str), Go(&'static str), Python(&'static [&'static str]), R(&'static [&'static str]), } impl Language { pub fn from_file_name(file_name: &str) -> Option { match file_name { "Cargo.toml" => Some(Language::Rust("Cargo.toml")), "package.json" => Some(Language::Node("package.json")), "go.mod" => Some(Language::Go("go.mod")), "vcpkg.json" => Some(Language::Cpp(&CPP_PATHS[..])), "conanfile.txt" | "conanfile.py" => Some(Language::Cpp(&CPP_PATHS[..])), "MODULE.bazel" => Some(Language::Cpp(&CPP_PATHS[..])), "configure.ac" | "configure.in" | "Makefile" => Some(Language::C(&C_PATHS[..])), "CMakeLists.txt" => Some(Language::Cpp(&CPP_PATHS[..])), _ => { if file_name.ends_with(".csproj") || file_name.ends_with(".fsproj") || file_name.ends_with(".vbproj") || file_name.ends_with(".slnx") { Some(Language::DotNet(&DOTNET_PATHS[..])) } else if PYTHON_PATHS.contains(&file_name) { Some(Language::Python(&PYTHON_PATHS[..])) } else if R_PATHS.contains(&file_name) { Some(Language::R(&R_PATHS[..])) } else { None } } } } } /// C project file patterns pub const C_PATHS: [&str; 3] = ["configure.ac", "configure.in", "Makefile"]; /// C++ project file patterns pub const CPP_PATHS: [&str; 5] = [ "vcpkg.json", "conanfile.txt", "conanfile.py", "CMakeLists.txt", "MODULE.bazel", ]; /// Python project file patterns pub const PYTHON_PATHS: [&str; 4] = [ "requirements.txt", "Pipfile.lock", "pip_freeze.txt", "pyproject.toml", ]; /// R project file patterns pub const R_PATHS: [&str; 2] = ["DESCRIPTION", "renv.lock"]; /// .NET project file patterns pub const DOTNET_PATHS: [&str; 4] = [".csproj", ".fsproj", ".vbproj", ".slnx"]; feluda-1.11.1/src/languages/node.rs000064400000000000000000002422511046102023000152030ustar 00000000000000use rayon::prelude::*; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; /// Type alias for dependency detection type DependencyDetector = fn(&Path) -> Result, String>; /// Structure representing a package.json file #[derive(Deserialize, Serialize, Debug)] pub struct PackageJson { pub dependencies: Option>, #[serde(rename = "devDependencies")] pub dev_dependencies: Option>, #[serde(rename = "peerDependencies")] pub peer_dependencies: Option>, #[serde(rename = "optionalDependencies")] pub optional_dependencies: Option>, } impl PackageJson { /// Get all dependencies from package.json (production + dev + peer + optional) #[allow(dead_code)] pub fn get_all_dependencies(&self) -> HashMap { let mut all_dependencies: HashMap = HashMap::new(); if let Some(deps) = &self.dependencies { all_dependencies.extend(deps.clone()); } if let Some(dev_deps) = &self.dev_dependencies { all_dependencies.extend(dev_deps.clone()); } if let Some(peer_deps) = &self.peer_dependencies { all_dependencies.extend(peer_deps.clone()); } if let Some(opt_deps) = &self.optional_dependencies { all_dependencies.extend(opt_deps.clone()); } all_dependencies } } /// Recursive dependency resolver struct DependencyResolver { resolved_cache: HashMap, processing_stack: HashSet, } #[derive(Debug, Clone)] #[allow(dead_code)] struct PackageMetadata { name: String, version: String, license: Option, dependencies: HashMap, } impl DependencyResolver { fn new() -> Self { Self { resolved_cache: HashMap::new(), processing_stack: HashSet::new(), } } fn resolve_recursive_dependencies( &mut self, package_json_path: &str, max_depth: usize, ) -> Result, String> { let root_package = self.parse_local_package_json(package_json_path)?; let mut all_dependencies = HashMap::new(); let to_resolve = root_package.dependencies.clone(); self.resolve_dependencies_recursive(to_resolve, &mut all_dependencies, 0, max_depth)?; Ok(all_dependencies) } fn resolve_dependencies_recursive( &mut self, dependencies: HashMap, all_deps: &mut HashMap, current_depth: usize, max_depth: usize, ) -> Result<(), String> { if current_depth >= max_depth { return Ok(()); } for (name, version_spec) in dependencies { if all_deps.contains_key(&name) || self.processing_stack.contains(&name) { continue; } self.processing_stack.insert(name.clone()); match self.resolve_package_metadata(&name, &version_spec) { Ok(metadata) => { all_deps.insert(name.clone(), metadata.version.clone()); self.resolve_dependencies_recursive( metadata.dependencies, all_deps, current_depth + 1, max_depth, )?; } Err(_) => { all_deps.insert(name.clone(), clean_version_string(&version_spec)); } } self.processing_stack.remove(&name); } Ok(()) } fn resolve_package_metadata( &mut self, name: &str, version_spec: &str, ) -> Result { let cache_key = format!("{name}@{version_spec}"); if let Some(cached) = self.resolved_cache.get(&cache_key) { return Ok(cached.clone()); } let metadata = self.fetch_package_metadata_from_registry(name, version_spec)?; self.resolved_cache.insert(cache_key, metadata.clone()); Ok(metadata) } fn fetch_package_metadata_from_registry( &self, name: &str, version_spec: &str, ) -> Result { let clean_version = clean_version_string(version_spec); let url = if clean_version == "latest" || clean_version.is_empty() { format!("https://registry.npmjs.org/{name}") } else { format!("https://registry.npmjs.org/{name}/{clean_version}") }; let response = reqwest::blocking::get(&url).map_err(|e| format!("Registry request failed: {e}"))?; if !response.status().is_success() { return Err(format!("Registry returned status: {}", response.status())); } let json: Value = response .json() .map_err(|e| format!("Failed to parse registry response: {e}"))?; self.parse_registry_metadata(&json, name, &clean_version) } fn parse_registry_metadata( &self, json: &Value, name: &str, requested_version: &str, ) -> Result { let version_to_use = if requested_version == "latest" { json.get("dist-tags") .and_then(|tags| tags.get("latest")) .and_then(|v| v.as_str()) .unwrap_or("latest") } else { requested_version }; let version_data = if let Some(versions) = json.get("versions") { if let Some(specific_version) = versions.get(version_to_use) { specific_version } else { json } } else { json }; let license = version_data .get("license") .and_then(|l| l.as_str()) .or_else(|| { version_data .get("licenses") .and_then(|ls| ls.as_array()) .and_then(|arr| arr.first()) .and_then(|first| first.get("type")) .and_then(|t| t.as_str()) }) .map(String::from); let dependencies = self.extract_dependencies_from_json(version_data, "dependencies"); Ok(PackageMetadata { name: name.to_string(), version: version_to_use.to_string(), license, dependencies, }) } fn extract_dependencies_from_json( &self, json: &Value, dep_type: &str, ) -> HashMap { json.get(dep_type) .and_then(|deps| deps.as_object()) .map(|obj| { obj.iter() .map(|(k, v)| (k.clone(), v.as_str().unwrap_or("*").to_string())) .collect() }) .unwrap_or_default() } fn parse_local_package_json(&self, path: &str) -> Result { let content = std::fs::read_to_string(path) .map_err(|e| format!("Failed to read package.json: {e}"))?; let json: Value = serde_json::from_str(&content) .map_err(|e| format!("Failed to parse package.json: {e}"))?; let dependencies = self.extract_dependencies_from_json(&json, "dependencies"); Ok(PackageMetadata { name: "root".to_string(), version: "0.0.0".to_string(), license: None, dependencies, }) } } #[allow(dead_code)] pub fn analyze_js_licenses(package_json_path: &str) -> Vec { let config = crate::config::load_config().unwrap_or_default(); analyze_js_licenses_with_config(package_json_path, &config, false) } pub fn analyze_js_licenses_with_no_local( package_json_path: &str, no_local: bool, ) -> Vec { let config = crate::config::load_config().unwrap_or_default(); analyze_js_licenses_with_config(package_json_path, &config, no_local) } pub fn analyze_js_licenses_with_config( package_json_path: &str, config: &crate::config::FeludaConfig, no_local: bool, ) -> Vec { log( LogLevel::Info, &format!("Analyzing JavaScript dependencies from: {package_json_path}"), ); let project_root = Path::new(package_json_path) .parent() .unwrap_or(Path::new(".")); let all_dependencies = if project_root.join("pnpm-lock.yaml").exists() { log( LogLevel::Info, "Detected pnpm project - using specialized pnpm analysis", ); analyze_pnpm_project_comprehensive(project_root, package_json_path) } else { log(LogLevel::Info, "Using general npm/yarn analysis"); try_all_dependency_detection_methods(project_root, package_json_path) }; if all_dependencies.is_empty() { log(LogLevel::Warn, "No dependencies found using any method"); return Vec::new(); } log( LogLevel::Info, &format!( "Successfully found {} total dependencies", all_dependencies.len() ), ); log_debug( "All detected dependencies (first 20)", &all_dependencies.iter().take(20).collect::>(), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; // Process dependencies in parallel all_dependencies .par_iter() .map(|(name, version)| { let license = get_license_for_package(project_root, name, version, no_local); let is_restrictive = is_license_restrictive(&Some(license.clone()), &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license} for {name}"), ); } LicenseInfo { name: name.to_string(), version: clean_version_string(version), license: Some(license.clone()), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: crate::licenses::get_osi_status(&license), } }) .collect() } fn try_all_dependency_detection_methods( project_root: &Path, package_json_path: &str, ) -> HashMap { let mut all_deps = HashMap::new(); // pnpm if project_root.join("pnpm-lock.yaml").exists() { log(LogLevel::Info, "pnpm dependency detection..."); for method in get_pnpm_methods() { if let Ok(deps) = method(project_root) { if !deps.is_empty() { log( LogLevel::Info, &format!("pnpm method found {} dependencies", deps.len()), ); all_deps.extend(deps); } } } } // yarn if all_deps.is_empty() && project_root.join("yarn.lock").exists() { log(LogLevel::Info, "yarn dependency detection..."); for method in get_yarn_methods() { if let Ok(deps) = method(project_root) { if !deps.is_empty() { log( LogLevel::Info, &format!("yarn method found {} dependencies", deps.len()), ); all_deps.extend(deps); break; } } } } // npm ls if all_deps.is_empty() { log(LogLevel::Info, "npm dependency detection..."); for method in get_npm_methods() { if let Ok(deps) = method(project_root) { if !deps.is_empty() { log( LogLevel::Info, &format!("npm method found {} dependencies", deps.len()), ); all_deps.extend(deps); break; } } } } // node_modules if all_deps.len() < 50 { log(LogLevel::Info, "node_modules scanning..."); if let Ok(scanned_deps) = comprehensive_node_modules_scan(project_root) { log( LogLevel::Info, &format!( "node_modules scan found {} additional dependencies", scanned_deps.len() ), ); all_deps.extend(scanned_deps); } } // Lockfile parsing if let Ok(lockfile_deps) = parse_lockfiles(project_root) { log( LogLevel::Info, &format!( "Lockfile parsing found {} additional dependencies", lockfile_deps.len() ), ); all_deps.extend(lockfile_deps); } // Workspace detection if let Ok(workspace_deps) = detect_workspace_dependencies(project_root, package_json_path) { log( LogLevel::Info, &format!( "Workspace detection found {} additional dependencies", workspace_deps.len() ), ); all_deps.extend(workspace_deps); } // recursive resolver if all_deps.len() < 20 { log(LogLevel::Info, "Using recursive resolver as final fallback"); let mut resolver = DependencyResolver::new(); if let Ok(recursive_deps) = resolver.resolve_recursive_dependencies(package_json_path, 15) { log( LogLevel::Info, &format!( "Recursive resolver found {} dependencies", recursive_deps.len() ), ); all_deps.extend(recursive_deps); } } all_deps } /// Get all pnpm detection methods fn get_pnpm_methods() -> Vec { vec![ pnpm_list_all_recursive, pnpm_list_json_depth_infinity, pnpm_list_prod_dev, pnpm_why_based_detection, ] } /// Get all yarn detection methods fn get_yarn_methods() -> Vec { vec![ yarn_list_recursive, yarn_list_all_pattern, yarn_info_workspaces, ] } /// Get all npm detection methods fn get_npm_methods() -> Vec { vec![npm_ls_all_json, npm_ls_long_format, npm_list_global_style] } // ============================================================================= // PNPM DETECTION METHODS // ============================================================================= fn pnpm_list_all_recursive(project_root: &Path) -> Result, String> { log( LogLevel::Info, "Trying: pnpm list --recursive --depth Infinity", ); let output = Command::new("pnpm") .args([ "list", "--recursive", "--depth", "Infinity", "--json", "--prod", "--dev", ]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list recursive failed: {e}"))?; parse_pnpm_json_output(&output) } fn pnpm_list_json_depth_infinity(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: pnpm list --json --depth Infinity"); let output = Command::new("pnpm") .args(["list", "--json", "--depth", "Infinity"]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list depth infinity failed: {e}"))?; parse_pnpm_json_output(&output) } fn pnpm_list_prod_dev(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: pnpm list --prod --dev --long"); let output = Command::new("pnpm") .args(["list", "--prod", "--dev", "--long", "--depth", "999"]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list prod dev failed: {e}"))?; parse_pnpm_text_output(&output) } fn pnpm_why_based_detection(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: pnpm-based package discovery"); let package_json_content = fs::read_to_string(project_root.join("package.json")) .map_err(|e| format!("Failed to read package.json: {e}"))?; let package_json: Value = serde_json::from_str(&package_json_content) .map_err(|e| format!("Failed to parse package.json: {e}"))?; let mut all_deps = HashMap::new(); // Get direct dependencies if let Some(deps) = package_json.get("dependencies").and_then(|d| d.as_object()) { for (name, _) in deps { if let Ok(transitive) = get_pnpm_transitive_deps(project_root, name) { all_deps.extend(transitive); } } } Ok(all_deps) } fn get_pnpm_transitive_deps( project_root: &Path, package_name: &str, ) -> Result, String> { let output = Command::new("pnpm") .args(["why", package_name, "--json"]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm why failed: {e}"))?; if !output.status.success() { return Ok(HashMap::new()); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut deps = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { extract_deps_from_pnpm_why(&json, &mut deps); } Ok(deps) } // ============================================================================= // YARN DETECTION METHODS // ============================================================================= fn yarn_list_recursive(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: yarn list --recursive"); let output = Command::new("yarn") .args(["list", "--recursive", "--json"]) .current_dir(project_root) .output() .map_err(|e| format!("yarn list recursive failed: {e}"))?; parse_yarn_json_output(&output) } fn yarn_list_all_pattern(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: yarn list --pattern '*'"); let output = Command::new("yarn") .args(["list", "--pattern", "*", "--json"]) .current_dir(project_root) .output() .map_err(|e| format!("yarn list pattern failed: {e}"))?; parse_yarn_json_output(&output) } fn yarn_info_workspaces(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: yarn workspaces info"); let output = Command::new("yarn") .args(["workspaces", "info", "--json"]) .current_dir(project_root) .output() .map_err(|e| format!("yarn workspaces info failed: {e}"))?; parse_yarn_workspaces_output(&output) } // ============================================================================= // NPM DETECTION METHODS // ============================================================================= fn npm_ls_all_json(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: npm ls --all --json"); #[cfg(windows)] const NPM: &str = "npm.cmd"; #[cfg(not(windows))] const NPM: &str = "npm"; let output = Command::new(NPM) .args(["ls", "--all", "--json", "--production", "--dev"]) .current_dir(project_root) .output() .map_err(|e| format!("npm ls all failed: {e}"))?; parse_npm_json_output(&output) } fn npm_ls_long_format(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: npm ls --long --parseable"); #[cfg(windows)] const NPM: &str = "npm.cmd"; #[cfg(not(windows))] const NPM: &str = "npm"; let output = Command::new(NPM) .args(["ls", "--long", "--parseable", "--all"]) .current_dir(project_root) .output() .map_err(|e| format!("npm ls long failed: {e}"))?; parse_npm_parseable_output(&output) } fn npm_list_global_style(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Trying: npm list --global-style"); #[cfg(windows)] const NPM: &str = "npm.cmd"; #[cfg(not(windows))] const NPM: &str = "npm"; let output = Command::new(NPM) .args(["list", "--global-style", "--depth", "999"]) .current_dir(project_root) .output() .map_err(|e| format!("npm list global-style failed: {e}"))?; parse_npm_tree_output(&output) } // ============================================================================= // NODE_MODULES SCANNING // ============================================================================= fn comprehensive_node_modules_scan(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Starting comprehensive node_modules scan"); let node_modules = project_root.join("node_modules"); if !node_modules.exists() { return Ok(HashMap::new()); } let mut all_packages = HashMap::new(); let mut visited_paths = HashSet::new(); scan_with_symlink_resolution(&node_modules, &mut all_packages, &mut visited_paths, 0)?; let pnpm_dir = node_modules.join(".pnpm"); if pnpm_dir.exists() { log( LogLevel::Info, "Found .pnpm directory, scanning pnpm virtual store", ); scan_pnpm_virtual_store(&pnpm_dir, &mut all_packages)?; } Ok(all_packages) } fn scan_with_symlink_resolution( dir: &Path, packages: &mut HashMap, visited: &mut HashSet, depth: usize, ) -> Result<(), String> { if depth > 25 || visited.contains(&dir.to_path_buf()) { return Ok(()); } visited.insert(dir.to_path_buf()); let entries = fs::read_dir(dir).map_err(|e| format!("Failed to read {}: {}", dir.display(), e))?; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.starts_with('.') { continue; } if name.starts_with('@') { if let Ok(scoped_entries) = fs::read_dir(&path) { for scoped_entry in scoped_entries.flatten() { let scoped_path = scoped_entry.path(); if scoped_path.is_dir() { let scoped_name = scoped_path .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); let full_name = format!("{name}/{scoped_name}"); if let Some(version) = read_package_version_safe(&scoped_path) { packages.insert(full_name, version); } let nested = scoped_path.join("node_modules"); if nested.exists() { scan_with_symlink_resolution(&nested, packages, visited, depth + 1)?; } } } } } else if let Some(version) = read_package_version_safe(&path) { packages.insert(name.to_string(), version); let nested = path.join("node_modules"); if nested.exists() { scan_with_symlink_resolution(&nested, packages, visited, depth + 1)?; } } } Ok(()) } fn scan_pnpm_virtual_store( pnpm_dir: &Path, packages: &mut HashMap, ) -> Result<(), String> { let entries = fs::read_dir(pnpm_dir).map_err(|e| format!("Failed to read .pnpm: {e}"))?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if let Some((pkg_with_version, _hash)) = dir_name.split_once('_') { if let Some((pkg_name, version)) = pkg_with_version.rsplit_once('@') { let clean_name = if pkg_name.starts_with('@') && pkg_name.matches('@').count() == 2 { if let Some(at_pos) = pkg_name[1..].find('@') { format!("@{}", &pkg_name[at_pos + 2..]) } else { pkg_name.to_string() } } else { pkg_name.to_string() }; packages.insert(clean_name, version.to_string()); let inner_node_modules = path.join("node_modules"); if inner_node_modules.exists() { let mut visited = HashSet::new(); let _ = scan_with_symlink_resolution( &inner_node_modules, packages, &mut visited, 0, ); } } } } } Ok(()) } // ============================================================================= // LOCKFILE PARSING // ============================================================================= fn parse_lockfiles(project_root: &Path) -> Result, String> { let mut deps = HashMap::new(); // Parse pnpm-lock.yaml if let Some(pnpm_deps) = parse_pnpm_lockfile(project_root) { deps.extend(pnpm_deps); } // Parse yarn.lock if let Some(yarn_deps) = parse_yarn_lockfile(project_root) { deps.extend(yarn_deps); } // Parse package-lock.json if let Some(npm_deps) = parse_npm_lockfile(project_root) { deps.extend(npm_deps); } Ok(deps) } fn parse_pnpm_lockfile(project_root: &Path) -> Option> { let lockfile_path = project_root.join("pnpm-lock.yaml"); if !lockfile_path.exists() { return None; } log(LogLevel::Info, "Parsing pnpm-lock.yaml"); if let Ok(content) = fs::read_to_string(&lockfile_path) { let mut deps = HashMap::new(); for line in content.lines() { if line.trim().starts_with('/') && line.contains(':') { if let Some(pkg_info) = line.trim().strip_prefix('/') { if let Some(colon_pos) = pkg_info.find(':') { let pkg_with_version = &pkg_info[..colon_pos]; if let Some((pkg_name, version)) = pkg_with_version.rsplit_once('@') { deps.insert(pkg_name.to_string(), version.to_string()); } } } } } log( LogLevel::Info, &format!("Parsed {} dependencies from pnpm-lock.yaml", deps.len()), ); Some(deps) } else { None } } fn parse_yarn_lockfile(project_root: &Path) -> Option> { let lockfile_path = project_root.join("yarn.lock"); if !lockfile_path.exists() { return None; } log(LogLevel::Info, "Parsing yarn.lock"); if let Ok(content) = fs::read_to_string(&lockfile_path) { let mut deps = HashMap::new(); let mut current_package = None; for line in content.lines() { let trimmed = line.trim(); if !trimmed.is_empty() && !trimmed.starts_with(' ') && trimmed.contains('@') && trimmed.ends_with(':') { let package_line = trimmed.trim_end_matches(':'); if let Some((name, _range)) = package_line.split_once('@') { current_package = Some(name.trim_matches('"').to_string()); } } if let Some(version_line) = trimmed.strip_prefix("version ") { if let Some(ref pkg_name) = current_package { let version = version_line.trim_matches('"'); deps.insert(pkg_name.clone(), version.to_string()); current_package = None; } } } log( LogLevel::Info, &format!("Parsed {} dependencies from yarn.lock", deps.len()), ); Some(deps) } else { None } } fn parse_npm_lockfile(project_root: &Path) -> Option> { let lockfile_path = project_root.join("package-lock.json"); if !lockfile_path.exists() { return None; } log(LogLevel::Info, "Parsing package-lock.json"); if let Ok(content) = fs::read_to_string(&lockfile_path) { if let Ok(json) = serde_json::from_str::(&content) { let mut deps = HashMap::new(); if let Some(packages) = json.get("packages").and_then(|p| p.as_object()) { for (path, info) in packages { if !path.is_empty() && !path.starts_with("node_modules/") { continue; } if let Some(name) = info.get("name").and_then(|n| n.as_str()) { if let Some(version) = info.get("version").and_then(|v| v.as_str()) { deps.insert(name.to_string(), version.to_string()); } } } } log( LogLevel::Info, &format!("Parsed {} dependencies from package-lock.json", deps.len()), ); Some(deps) } else { None } } else { None } } // ============================================================================= // WORKSPACE DETECTION // ============================================================================= fn detect_workspace_dependencies( project_root: &Path, package_json_path: &str, ) -> Result, String> { let mut workspace_deps = HashMap::new(); if let Ok(content) = fs::read_to_string(package_json_path) { if let Ok(json) = serde_json::from_str::(&content) { if let Some(workspaces) = json.get("workspaces") { log(LogLevel::Info, "Detected workspace configuration"); let workspace_patterns = if let Some(array) = workspaces.as_array() { array.iter().filter_map(|v| v.as_str()).collect::>() } else if let Some(obj) = workspaces.as_object() { if let Some(packages) = obj.get("packages").and_then(|p| p.as_array()) { packages .iter() .filter_map(|v| v.as_str()) .collect::>() } else { vec![] } } else { vec![] }; for pattern in workspace_patterns { if let Ok(workspace_deps_found) = scan_workspace_pattern(project_root, pattern) { workspace_deps.extend(workspace_deps_found); } } } } } Ok(workspace_deps) } fn scan_workspace_pattern( project_root: &Path, pattern: &str, ) -> Result, String> { let mut deps = HashMap::new(); let pattern_path = if let Some(stripped) = pattern.strip_suffix("/*") { project_root.join(stripped) } else { project_root.join(pattern) }; if pattern_path.exists() && pattern_path.is_dir() { if pattern.ends_with("/*") { if let Ok(entries) = fs::read_dir(&pattern_path) { for entry in entries.flatten() { let workspace_path = entry.path(); if workspace_path.is_dir() { let workspace_package_json = workspace_path.join("package.json"); if workspace_package_json.exists() { let workspace_deps_found = try_all_dependency_detection_methods( &workspace_path, workspace_package_json.to_str().unwrap_or(""), ); deps.extend(workspace_deps_found); } } } } } else { let workspace_package_json = pattern_path.join("package.json"); if workspace_package_json.exists() { let workspace_deps_found = try_all_dependency_detection_methods( &pattern_path, workspace_package_json.to_str().unwrap_or(""), ); deps.extend(workspace_deps_found); } } } Ok(deps) } // ============================================================================= // PARSER HELPER FUNCTIONS // ============================================================================= fn parse_pnpm_json_output( output: &std::process::Output, ) -> Result, String> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("pnpm command failed: {stderr}")); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { extract_dependencies_from_json(&json, &mut dependencies); } Ok(dependencies) } fn parse_pnpm_text_output( output: &std::process::Output, ) -> Result, String> { let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); for line in stdout_str.lines() { if let Some((name, version)) = parse_dependency_line(line) { dependencies.insert(name, version); } } Ok(dependencies) } fn parse_yarn_json_output( output: &std::process::Output, ) -> Result, String> { if !output.status.success() { return Err("yarn command failed".to_string()); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); for line in stdout_str.lines() { if let Ok(json) = serde_json::from_str::(line) { if json.get("type").and_then(|t| t.as_str()) == Some("tree") { if let Some(data) = json.get("data") { extract_dependencies_from_json(data, &mut dependencies); } } } } Ok(dependencies) } fn parse_yarn_workspaces_output( output: &std::process::Output, ) -> Result, String> { if !output.status.success() { return Err("yarn workspaces command failed".to_string()); } let stdout_str = String::from_utf8_lossy(&output.stdout); let dependencies = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { if let Some(workspaces) = json.as_object() { for (_workspace_name, workspace_info) in workspaces { if let Some(location) = workspace_info.get("location").and_then(|l| l.as_str()) { log(LogLevel::Info, &format!("Found workspace at: {location}")); } } } } Ok(dependencies) } fn parse_npm_json_output(output: &std::process::Output) -> Result, String> { let stdout_str = String::from_utf8_lossy(&output.stdout); if !output.status.success() { log( LogLevel::Warn, &format!( "npm command had non-zero exit: {}", String::from_utf8_lossy(&output.stderr) ), ); } let mut dependencies = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { extract_dependencies_from_json(&json, &mut dependencies); } Ok(dependencies) } fn parse_npm_parseable_output( output: &std::process::Output, ) -> Result, String> { let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); for line in stdout_str.lines() { if line.contains("node_modules") { if let Some(package_path) = line.split("node_modules/").last() { let parts: Vec<&str> = package_path.split('/').collect(); let package_name = if parts[0].starts_with('@') && parts.len() > 1 { format!("{}/{}", parts[0], parts[1]) } else { parts[0].to_string() }; if let Some(version) = read_package_version_from_path(line) { dependencies.insert(package_name, version); } } } } Ok(dependencies) } fn parse_npm_tree_output(output: &std::process::Output) -> Result, String> { let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); for line in stdout_str.lines() { if let Some((name, version)) = parse_dependency_line(line) { dependencies.insert(name, version); } } Ok(dependencies) } fn extract_dependencies_from_json(json: &Value, dependencies: &mut HashMap) { if let Some(deps) = json.get("dependencies").and_then(|d| d.as_object()) { extract_deps_recursive(deps, dependencies); } // pnpm structure if let Some(projects) = json.as_array() { for project in projects { if let Some(deps) = project.get("dependencies").and_then(|d| d.as_object()) { extract_deps_recursive(deps, dependencies); } if let Some(dev_deps) = project.get("devDependencies").and_then(|d| d.as_object()) { extract_deps_recursive(dev_deps, dependencies); } } } // Yarn tree structure if let Some(trees) = json.get("trees").and_then(|t| t.as_array()) { for tree in trees { if let Some(name) = tree.get("name").and_then(|n| n.as_str()) { if let Some((pkg_name, version)) = name.rsplit_once('@') { dependencies.insert(pkg_name.to_string(), version.to_string()); } } } } } fn extract_deps_recursive( deps: &serde_json::Map, all_deps: &mut HashMap, ) { for (name, dep_info) in deps { if let Some(version) = dep_info.get("version").and_then(|v| v.as_str()) { all_deps.insert(name.clone(), version.to_string()); } // Recursive extract nested dependencies if let Some(nested_deps) = dep_info.get("dependencies").and_then(|d| d.as_object()) { extract_deps_recursive(nested_deps, all_deps); } } } fn extract_deps_from_pnpm_why(json: &Value, deps: &mut HashMap) { if let Some(dependents) = json.get("dependents").and_then(|d| d.as_array()) { for dependent in dependents { if let Some(from) = dependent.get("from").and_then(|f| f.as_str()) { if let Some((name, version)) = from.rsplit_once('@') { deps.insert(name.to_string(), version.to_string()); } } } } } fn parse_dependency_line(line: &str) -> Option<(String, String)> { let trimmed = line.trim(); // Handle tree output like "├── package@1.0.0" or "└── package@1.0.0" let clean_line = trimmed .trim_start_matches("├── ") .trim_start_matches("└── ") .trim_start_matches("│ ") .trim_start_matches(" "); if let Some(at_pos) = clean_line.rfind('@') { let name_part = &clean_line[..at_pos]; let version_part = &clean_line[at_pos + 1..]; if version_part .chars() .next() .is_some_and(|c| c.is_ascii_digit()) { return Some((name_part.to_string(), version_part.to_string())); } } None } fn read_package_version_safe(package_dir: &Path) -> Option { let package_json_path = package_dir.join("package.json"); match fs::read_to_string(&package_json_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(json) => json .get("version") .and_then(|v| v.as_str()) .map(String::from), Err(_) => None, }, Err(_) => None, } } fn read_package_version_from_path(path: &str) -> Option { let path_buf = PathBuf::from(path); read_package_version_safe(&path_buf) } // ============================================================================= // LICENSE DETECTION // ============================================================================= fn get_license_for_package( project_root: &Path, name: &str, version: &str, no_local: bool, ) -> String { #[cfg(windows)] const NPM: &str = "npm.cmd"; #[cfg(not(windows))] const NPM: &str = "npm"; let mut result = get_license_from_package_json(project_root, name, version); if result.is_none() && !no_local { result = get_license_from_local_license_file(project_root, name); } result .or_else(|| get_license_from_pnpm_metadata(project_root, name, version)) .or_else(|| get_license_from_npm_view(NPM, name, version)) .or_else(|| get_license_from_npm_registry_api(name, version)) .unwrap_or_else(|| "Unknown (failed to retrieve)".to_string()) } fn get_license_from_package_json( project_root: &Path, package_name: &str, _version: &str, ) -> Option { let possible_paths = vec![ if package_name.starts_with('@') { let parts: Vec<&str> = package_name.splitn(2, '/').collect(); if parts.len() == 2 { Some( project_root .join("node_modules") .join(parts[0]) .join(parts[1]) .join("package.json"), ) } else { None } } else { Some( project_root .join("node_modules") .join(package_name) .join("package.json"), ) }, if package_name.starts_with('@') { let parts: Vec<&str> = package_name.splitn(2, '/').collect(); if parts.len() == 2 { Some( project_root .join("node_modules") .join(".pnpm") .join("node_modules") .join(parts[0]) .join(parts[1]) .join("package.json"), ) } else { None } } else { Some( project_root .join("node_modules") .join(".pnpm") .join("node_modules") .join(package_name) .join("package.json"), ) }, ]; for package_path in possible_paths.into_iter().flatten() { if let Ok(content) = fs::read_to_string(&package_path) { if let Ok(json) = serde_json::from_str::(&content) { if let Some(license) = json.get("license").and_then(|l| l.as_str()) { if !license.is_empty() && license != "UNLICENSED" { log( LogLevel::Info, &format!("Found license in package.json for {package_name}: {license}"), ); return Some(license.to_string()); } } if let Some(licenses) = json.get("licenses").and_then(|l| l.as_array()) { if let Some(first_license) = licenses.first() { if let Some(license_type) = first_license.get("type").and_then(|t| t.as_str()) { log( LogLevel::Info, &format!( "Found license in licenses array for {package_name}: {license_type}" ), ); return Some(license_type.to_string()); } } } } } } None } fn get_license_from_npm_view(npm_cmd: &str, package_name: &str, version: &str) -> Option { let clean_version = clean_version_string(version); let package_spec = if clean_version == "latest" || clean_version.is_empty() { package_name.to_string() } else { format!("{package_name}@{clean_version}") }; log( LogLevel::Info, &format!("Trying npm view for: {package_spec}"), ); let output = Command::new(npm_cmd) .arg("view") .arg(&package_spec) .arg("license") .arg("--json") .output() .ok()?; if !output.status.success() { return None; } let output_str = String::from_utf8_lossy(&output.stdout); if let Ok(json) = serde_json::from_str::(&output_str) { if let Some(license) = json.as_str() { return Some(license.to_string()); } } let license = output_str.trim().trim_matches('"'); if !license.is_empty() && license != "undefined" { Some(license.to_string()) } else { None } } fn get_license_from_npm_registry_api(package_name: &str, version: &str) -> Option { log( LogLevel::Info, &format!("Trying npm registry API for {package_name}"), ); let versions_to_try = if version == "latest" || version.is_empty() { vec!["latest"] } else { vec![version, "latest"] }; for ver in versions_to_try { let url = if ver == "latest" { format!("https://registry.npmjs.org/{package_name}") } else { format!("https://registry.npmjs.org/{package_name}/{ver}") }; if let Ok(response) = reqwest::blocking::get(&url) { if response.status().is_success() { if let Ok(json) = response.json::() { let license_paths = [ vec!["license"], vec!["licenses", "0", "type"], vec!["licenses", "0"], vec!["latest", "license"], ]; for path in &license_paths { if let Some(license_value) = get_nested_json_value(&json, path) { if let Some(license_str) = license_value.as_str() { if !license_str.is_empty() && license_str != "UNLICENSED" { log( LogLevel::Info, &format!( "Found license via registry API for {package_name}: {license_str}" ), ); return Some(license_str.to_string()); } } } } } } } } None } fn get_license_from_pnpm_metadata( project_root: &Path, package_name: &str, version: &str, ) -> Option { let pnpm_meta_path = project_root.join("node_modules").join(".pnpm"); if pnpm_meta_path.exists() { let expected_dir_name = format!("{package_name}@{version}"); if let Ok(entries) = fs::read_dir(&pnpm_meta_path) { for entry in entries.flatten() { let dir_name = entry.file_name(); let dir_name_str = dir_name.to_string_lossy(); if dir_name_str.starts_with(&expected_dir_name) { let package_json_path = entry .path() .join("node_modules") .join(package_name) .join("package.json"); if let Ok(content) = fs::read_to_string(&package_json_path) { if let Ok(json) = serde_json::from_str::(&content) { if let Some(license) = json.get("license").and_then(|l| l.as_str()) { if !license.is_empty() && license != "UNLICENSED" { return Some(license.to_string()); } } } } break; } } } } None } fn get_license_from_local_license_file(project_root: &Path, package_name: &str) -> Option { let package_dirs = if package_name.starts_with('@') { let parts: Vec<&str> = package_name.splitn(2, '/').collect(); if parts.len() == 2 { vec![ project_root .join("node_modules") .join(parts[0]) .join(parts[1]), project_root .join("node_modules") .join(".pnpm") .join("node_modules") .join(parts[0]) .join(parts[1]), ] } else { return None; } } else { vec![ project_root.join("node_modules").join(package_name), project_root .join("node_modules") .join(".pnpm") .join("node_modules") .join(package_name), ] }; let license_filenames = [ "LICENSE", "LICENSE.md", "LICENSE.txt", "COPYING", "COPYING.md", ]; for dir in package_dirs { if !dir.exists() { continue; } for filename in &license_filenames { let license_path = dir.join(filename); if license_path.exists() { if let Ok(content) = fs::read_to_string(&license_path) { if !content.trim().is_empty() { log( LogLevel::Info, &format!("Found license file for {package_name}: {filename}"), ); return detect_license_from_content(&content); } } } } } None } fn detect_license_from_content(content: &str) -> Option { let content_upper = content.to_uppercase(); let patterns = vec![ ("MIT", "MIT License"), ("APACHE", "Apache License"), ("GPL", "GPL"), ("BSD", "BSD"), ("ISC", "ISC License"), ("LGPL", "LGPL"), ("UNLICENSE", "Unlicense"), ("MPL", "Mozilla Public License"), ]; for (pattern, label) in patterns { if content_upper.contains(pattern) { return Some(label.to_string()); } } None } fn get_nested_json_value<'a>(json: &'a Value, path: &[&str]) -> Option<&'a Value> { let mut current = json; for key in path { current = current.get(key)?; } Some(current) } fn clean_version_string(version: &str) -> String { version .trim_start_matches('^') .trim_start_matches('~') .trim_start_matches(">=") .trim_start_matches('>') .trim_start_matches("<=") .trim_start_matches('<') .trim_start_matches('=') .split_whitespace() .next() .unwrap_or(version) .to_string() } #[allow(dead_code)] fn parse_package_json_dependencies( package_json_path: &str, ) -> Result, String> { log( LogLevel::Info, &format!("Parsing dependencies directly from: {package_json_path}"), ); let content = std::fs::read_to_string(package_json_path) .map_err(|e| format!("Failed to read package.json: {e}"))?; let package_json: PackageJson = serde_json::from_str(&content).map_err(|e| format!("Failed to parse package.json: {e}"))?; let all_deps = package_json.get_all_dependencies(); log( LogLevel::Info, &format!( "Found {} total dependencies in package.json", all_deps.len() ), ); log_debug("Dependencies from package.json", &all_deps); Ok(all_deps) } fn analyze_pnpm_project_comprehensive( project_root: &Path, _package_json_path: &str, ) -> HashMap { let mut all_deps = HashMap::new(); log( LogLevel::Info, "Method 1: Comprehensive pnpm-lock.yaml parsing", ); if let Ok(lockfile_deps) = parse_pnpm_lockfile_comprehensive(project_root) { log( LogLevel::Info, &format!( "pnpm-lock.yaml parsing found {} dependencies", lockfile_deps.len() ), ); all_deps.extend(lockfile_deps); } log(LogLevel::Info, "Method 2: pnpm list commands"); let before_pnpm_commands = all_deps.len(); if let Ok(deps) = try_pnpm_list_all_dependencies(project_root) { if !deps.is_empty() { log( LogLevel::Info, &format!("pnpm list all found {} dependencies", deps.len()), ); all_deps.extend(deps); } } if all_deps.len() == before_pnpm_commands { for pnpm_method in [ try_pnpm_list_comprehensive, try_pnpm_list_flat, try_pnpm_list_recursive_json, ] { if let Ok(deps) = pnpm_method(project_root) { if !deps.is_empty() { log( LogLevel::Info, &format!("pnpm command found {} dependencies", deps.len()), ); all_deps.extend(deps); break; } } } } log( LogLevel::Info, &format!( "Total after pnpm commands: {} (added {})", all_deps.len(), all_deps.len().saturating_sub(before_pnpm_commands) ), ); log(LogLevel::Info, "Method 3: Enhanced lockfile parsing"); let before_enhanced_lockfile = all_deps.len(); if let Ok(enhanced_deps) = parse_pnpm_lockfile_enhanced(project_root) { log( LogLevel::Info, &format!( "Enhanced lockfile parsing found {} dependencies", enhanced_deps.len() ), ); all_deps.extend(enhanced_deps); } log( LogLevel::Info, &format!( "Total after enhanced lockfile: {} (added {})", all_deps.len(), all_deps.len().saturating_sub(before_enhanced_lockfile) ), ); log(LogLevel::Info, "Method 4: .pnpm virtual store analysis"); let before_virtual_store = all_deps.len(); if let Ok(virtual_store_deps) = analyze_pnpm_virtual_store_comprehensive(project_root) { log( LogLevel::Info, &format!( "Virtual store analysis found {} dependencies", virtual_store_deps.len() ), ); all_deps.extend(virtual_store_deps); } log( LogLevel::Info, &format!( "Total after virtual store: {} (added {})", all_deps.len(), all_deps.len().saturating_sub(before_virtual_store) ), ); log(LogLevel::Info, "Method 5: node_modules symlink resolution"); let before_symlinks = all_deps.len(); if let Ok(symlink_deps) = resolve_pnpm_symlinks(project_root) { log( LogLevel::Info, &format!( "Symlink resolution found {} dependencies", symlink_deps.len() ), ); all_deps.extend(symlink_deps); } log( LogLevel::Info, &format!( "Total after symlinks: {} (added {})", all_deps.len(), all_deps.len().saturating_sub(before_symlinks) ), ); log(LogLevel::Info, "Method 6: Deep .pnpm directory scanning"); let before_deep_scan = all_deps.len(); if let Ok(deep_scan_deps) = deep_scan_pnpm_store(project_root) { log( LogLevel::Info, &format!( "Deep .pnpm scan found {} dependencies", deep_scan_deps.len() ), ); all_deps.extend(deep_scan_deps); } log( LogLevel::Info, &format!( "Total after deep scan: {} (added {})", all_deps.len(), all_deps.len().saturating_sub(before_deep_scan) ), ); if all_deps.len() < 200 { log(LogLevel::Info, "Method 7: node_modules scan"); let before_fallback = all_deps.len(); if let Ok(fallback_deps) = comprehensive_node_modules_scan(project_root) { log( LogLevel::Info, &format!( "Comprehensive scan found {} dependencies", fallback_deps.len() ), ); all_deps.extend(fallback_deps); } log( LogLevel::Info, &format!( "Total after comprehensive scan: {} (added {})", all_deps.len(), all_deps.len().saturating_sub(before_fallback) ), ); } all_deps } fn parse_pnpm_lockfile_comprehensive( project_root: &Path, ) -> Result, String> { let lockfile_path = project_root.join("pnpm-lock.yaml"); if !lockfile_path.exists() { return Err("pnpm-lock.yaml not found".to_string()); } log(LogLevel::Info, "Parsing pnpm-lock.yaml comprehensively"); let content = fs::read_to_string(&lockfile_path) .map_err(|e| format!("Failed to read pnpm-lock.yaml: {e}"))?; let mut deps = HashMap::new(); let mut in_packages_section = false; for line in content.lines() { let trimmed = line.trim(); if trimmed == "packages:" { in_packages_section = true; continue; } if !trimmed.is_empty() && !trimmed.starts_with(' ') && trimmed.ends_with(':') && in_packages_section && trimmed != "packages:" { in_packages_section = false; continue; } if in_packages_section && trimmed.starts_with('/') && trimmed.contains(':') { if let Some(pkg_info) = trimmed.strip_prefix('/').and_then(|s| s.strip_suffix(':')) { if let Some((pkg_name, version)) = parse_pnpm_package_entry(pkg_info) { deps.insert(pkg_name, version); } } } if trimmed.contains('@') && trimmed.contains(':') && !trimmed.starts_with('#') { if let Some((pkg_name, version)) = extract_package_from_lockfile_line(trimmed) { deps.insert(pkg_name, version); } } } log( LogLevel::Info, &format!( "Comprehensive pnpm-lock.yaml parsing found {} dependencies", deps.len() ), ); Ok(deps) } fn parse_pnpm_package_entry(pkg_info: &str) -> Option<(String, String)> { let clean_info = pkg_info.split('(').next().unwrap_or(pkg_info); let clean_info = clean_info.split('_').next().unwrap_or(clean_info); if let Some(at_pos) = clean_info.rfind('@') { let name_part = &clean_info[..at_pos]; let version_part = &clean_info[at_pos + 1..]; if version_part .chars() .next() .is_some_and(|c| c.is_ascii_digit()) { return Some((name_part.to_string(), version_part.to_string())); } } None } fn extract_package_from_lockfile_line(line: &str) -> Option<(String, String)> { if line.contains("resolution:") { return None; } if let Some(colon_pos) = line.find(':') { let name_part = line[..colon_pos].trim(); let version_part = line[colon_pos + 1..].trim(); if name_part.is_empty() || version_part.is_empty() { return None; } if name_part.contains('/') && !name_part.starts_with('@') { return None; } if version_part .chars() .next() .is_some_and(|c| c.is_ascii_digit()) { return Some((name_part.to_string(), version_part.to_string())); } } None } fn try_pnpm_list_comprehensive(project_root: &Path) -> Result, String> { log( LogLevel::Info, "Attempting: pnpm list --depth=Infinity --json --prod --dev", ); let output = Command::new("pnpm") .args([ "list", "--depth=Infinity", "--json", "--prod", "--dev", "--no-optional", ]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list comprehensive failed: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("pnpm list failed: {stderr}")); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { if let Some(projects) = json.as_array() { for project in projects { extract_all_pnpm_dependencies(project, &mut dependencies); } } else { extract_all_pnpm_dependencies(&json, &mut dependencies); } } Ok(dependencies) } fn extract_all_pnpm_dependencies(project: &Value, deps: &mut HashMap) { let dep_types = [ "dependencies", "devDependencies", "optionalDependencies", "peerDependencies", ]; for dep_type in &dep_types { if let Some(dep_obj) = project.get(dep_type).and_then(|d| d.as_object()) { extract_deps_recursive_pnpm(dep_obj, deps); } } } fn extract_deps_recursive_pnpm( deps_obj: &serde_json::Map, all_deps: &mut HashMap, ) { for (name, dep_info) in deps_obj { if let Some(version) = dep_info.get("version").and_then(|v| v.as_str()) { all_deps.insert(name.clone(), version.to_string()); } if let Some(nested_deps) = dep_info.get("dependencies").and_then(|d| d.as_object()) { extract_deps_recursive_pnpm(nested_deps, all_deps); } } } fn try_pnpm_list_flat(project_root: &Path) -> Result, String> { log( LogLevel::Info, "Attempting: pnpm list --depth=Infinity (flat output)", ); let output = Command::new("pnpm") .args(["list", "--depth=Infinity"]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list flat failed: {e}"))?; let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); for line in stdout_str.lines() { if let Some((name, version)) = parse_pnpm_tree_line(line) { dependencies.insert(name, version); } } Ok(dependencies) } fn parse_pnpm_tree_line(line: &str) -> Option<(String, String)> { let trimmed = line.trim(); let clean_line = trimmed .trim_start_matches("├── ") .trim_start_matches("└── ") .trim_start_matches("│ "); let parts: Vec<&str> = clean_line.split_whitespace().collect(); if parts.len() >= 2 { let name = parts[0]; let version = parts[1]; if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some((name.to_string(), version.to_string())); } } None } fn try_pnpm_list_recursive_json(project_root: &Path) -> Result, String> { log(LogLevel::Info, "Attempting: pnpm list --recursive --json"); let output = Command::new("pnpm") .args(["list", "--recursive", "--json"]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list recursive json failed: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(format!("pnpm list recursive failed: {stderr}")); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { if let Some(projects) = json.as_array() { for project in projects { extract_all_pnpm_dependencies(project, &mut dependencies); } } } Ok(dependencies) } fn analyze_pnpm_virtual_store_comprehensive( project_root: &Path, ) -> Result, String> { let pnpm_dir = project_root.join("node_modules").join(".pnpm"); if !pnpm_dir.exists() { return Ok(HashMap::new()); } log(LogLevel::Info, "Analyzing .pnpm virtual store"); let mut packages = HashMap::new(); let entries = fs::read_dir(&pnpm_dir).map_err(|e| format!("Failed to read .pnpm directory: {e}"))?; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if let Some((pkg_name, version)) = parse_pnpm_virtual_store_entry(dir_name) { packages.insert(pkg_name.clone(), version); let nested_modules = path.join("node_modules"); if nested_modules.exists() { if let Ok(nested_deps) = scan_nested_node_modules(&nested_modules, 0) { packages.extend(nested_deps); } } } } } log( LogLevel::Info, &format!("Virtual store analysis found {} packages", packages.len()), ); Ok(packages) } fn deep_scan_pnpm_store(project_root: &Path) -> Result, String> { let pnpm_dir = project_root.join("node_modules").join(".pnpm"); if !pnpm_dir.exists() { return Ok(HashMap::new()); } log(LogLevel::Info, "Deep scanning .pnpm directory structure"); let mut packages = HashMap::new(); scan_pnpm_directory_recursive(&pnpm_dir, &mut packages, 0)?; log( LogLevel::Info, &format!("Deep .pnpm scan found {} packages", packages.len()), ); Ok(packages) } fn scan_pnpm_directory_recursive( dir: &Path, packages: &mut HashMap, depth: usize, ) -> Result<(), String> { if depth > 10 { return Ok(()); } if let Ok(entries) = fs::read_dir(dir) { for entry in entries.flatten() { let path = entry.path(); let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if path.is_dir() { if let Some((pkg_name, version)) = parse_any_pnpm_directory_name(name) { packages.insert(pkg_name, version); } let node_modules_path = path.join("node_modules"); if node_modules_path.exists() { scan_all_packages_in_node_modules(&node_modules_path, packages)?; } scan_pnpm_directory_recursive(&path, packages, depth + 1)?; } } } Ok(()) } fn parse_any_pnpm_directory_name(dir_name: &str) -> Option<(String, String)> { if let Some((pkg_with_version, _hash)) = dir_name.split_once('_') { let pkg_with_version = pkg_with_version.replace('+', "/"); if let Some((pkg_name, version)) = pkg_with_version.rsplit_once('@') { if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some((pkg_name.to_string(), version.to_string())); } } } if let Some((pkg_name, version)) = dir_name.rsplit_once('@') { if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some((pkg_name.replace('+', "/"), version.to_string())); } } None } fn scan_all_packages_in_node_modules( node_modules: &Path, packages: &mut HashMap, ) -> Result<(), String> { if let Ok(entries) = fs::read_dir(node_modules) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.starts_with('.') { continue; } if name.starts_with('@') { if let Ok(scoped_entries) = fs::read_dir(&path) { for scoped_entry in scoped_entries.flatten() { let scoped_path = scoped_entry.path(); if scoped_path.is_dir() { let scoped_name = scoped_path .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); let full_name = format!("{name}/{scoped_name}"); if let Some(version) = read_package_version_safe(&scoped_path) { packages.insert(full_name, version); } } } } } else if let Some(version) = read_package_version_safe(&path) { packages.insert(name.to_string(), version); } } } } Ok(()) } fn try_pnpm_list_all_dependencies(project_root: &Path) -> Result, String> { log( LogLevel::Info, "Attempting: pnpm list --all --depth=Infinity --json", ); let output = Command::new("pnpm") .args([ "list", "--all", "--depth=Infinity", "--json", "--prod", "--dev", "--optional", ]) .current_dir(project_root) .output() .map_err(|e| format!("pnpm list all failed: {e}"))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); log(LogLevel::Warn, &format!("pnpm list --all failed: {stderr}")); return Err(format!("pnpm list all failed: {stderr}")); } let stdout_str = String::from_utf8_lossy(&output.stdout); let mut dependencies = HashMap::new(); if let Ok(json) = serde_json::from_str::(&stdout_str) { extract_all_pnpm_dependencies(&json, &mut dependencies); } Ok(dependencies) } fn parse_pnpm_lockfile_enhanced(project_root: &Path) -> Result, String> { let lockfile_path = project_root.join("pnpm-lock.yaml"); if !lockfile_path.exists() { return Err("pnpm-lock.yaml not found".to_string()); } log(LogLevel::Info, "Enhanced parsing of pnpm-lock.yaml"); let content = fs::read_to_string(&lockfile_path) .map_err(|e| format!("Failed to read pnpm-lock.yaml: {e}"))?; let mut deps = HashMap::new(); let mut current_section = None; for line in content.lines() { let trimmed = line.trim(); if trimmed.ends_with(':') && !trimmed.starts_with(' ') { current_section = Some(trimmed.trim_end_matches(':').to_string()); continue; } match current_section.as_deref() { Some("packages") => { if trimmed.starts_with('/') && trimmed.contains(':') { if let Some(pkg_info) = trimmed.strip_prefix('/').and_then(|s| s.strip_suffix(':')) { if let Some((pkg_name, version)) = parse_pnpm_package_entry(pkg_info) { deps.insert(pkg_name, version); } } } } Some("dependencies") | Some("devDependencies") | Some("optionalDependencies") => { if let Some(colon_pos) = trimmed.find(':') { let name = trimmed[..colon_pos].trim(); let version_spec = trimmed[colon_pos + 1..].trim(); if !name.is_empty() && !version_spec.is_empty() { let clean_version = clean_version_string(version_spec); deps.insert(name.to_string(), clean_version); } } } _ => { if trimmed.contains('@') && trimmed.contains(':') && !trimmed.starts_with('#') { if let Some((potential_pkg, _)) = trimmed.split_once(':') { if let Some((pkg_name, version)) = potential_pkg.trim().rsplit_once('@') { if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { deps.insert(pkg_name.to_string(), version.to_string()); } } } } } } } log( LogLevel::Info, &format!( "Enhanced lockfile parsing found {} dependencies", deps.len() ), ); Ok(deps) } fn parse_pnpm_virtual_store_entry(dir_name: &str) -> Option<(String, String)> { if let Some((pkg_with_version, _hash)) = dir_name.split_once('_') { let pkg_with_version = pkg_with_version.replace('+', "/"); if let Some((pkg_name, version)) = pkg_with_version.rsplit_once('@') { if version.chars().next().is_some_and(|c| c.is_ascii_digit()) { return Some((pkg_name.to_string(), version.to_string())); } } } None } fn resolve_pnpm_symlinks(project_root: &Path) -> Result, String> { let node_modules = project_root.join("node_modules"); if !node_modules.exists() { return Ok(HashMap::new()); } log(LogLevel::Info, "Resolving pnpm symlinks"); let mut packages = HashMap::new(); let mut visited = HashSet::new(); scan_pnpm_symlinks_recursive(&node_modules, &mut packages, &mut visited, 0)?; log( LogLevel::Info, &format!("Symlink resolution found {} packages", packages.len()), ); Ok(packages) } fn scan_pnpm_symlinks_recursive( dir: &Path, packages: &mut HashMap, visited: &mut HashSet, depth: usize, ) -> Result<(), String> { if depth > 30 || visited.contains(&dir.to_path_buf()) { return Ok(()); } visited.insert(dir.to_path_buf()); let entries = fs::read_dir(dir) .map_err(|e| format!("Failed to read directory {}: {}", dir.display(), e))?; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.starts_with('.') { continue; } if name.starts_with('@') { if let Ok(scoped_entries) = fs::read_dir(&path) { for scoped_entry in scoped_entries.flatten() { let scoped_path = scoped_entry.path(); if scoped_path.is_dir() { let scoped_name = scoped_path .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); let full_name = format!("{name}/{scoped_name}"); if let Some(version) = read_package_version_safe(&scoped_path) { packages.insert(full_name, version); } let nested = scoped_path.join("node_modules"); if nested.exists() { scan_pnpm_symlinks_recursive(&nested, packages, visited, depth + 1)?; } } } } } else if let Some(version) = read_package_version_safe(&path) { packages.insert(name.to_string(), version); let nested = path.join("node_modules"); if nested.exists() { scan_pnpm_symlinks_recursive(&nested, packages, visited, depth + 1)?; } } } Ok(()) } fn scan_nested_node_modules( node_modules_path: &Path, depth: usize, ) -> Result, String> { if depth > 15 { return Ok(HashMap::new()); } let mut packages = HashMap::new(); if let Ok(entries) = fs::read_dir(node_modules_path) { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if name.starts_with('.') { continue; } if name.starts_with('@') { if let Ok(scoped_entries) = fs::read_dir(&path) { for scoped_entry in scoped_entries.flatten() { let scoped_path = scoped_entry.path(); if scoped_path.is_dir() { let scoped_name = scoped_path .file_name() .and_then(|n| n.to_str()) .unwrap_or(""); let full_name = format!("{name}/{scoped_name}"); if let Some(version) = read_package_version_safe(&scoped_path) { packages.insert(full_name, version); } } } } } else if let Some(version) = read_package_version_safe(&path) { packages.insert(name.to_string(), version); } } } } Ok(packages) } #[cfg(test)] mod tests { use super::*; use std::fs; use tempfile::TempDir; #[test] fn test_detect_license_from_content_mit() { let mit_content = "MIT License\n\nCopyright (c) 2024"; assert_eq!( detect_license_from_content(mit_content), Some("MIT License".to_string()) ); } #[test] fn test_detect_license_from_content_apache() { let apache_content = "Apache License\nVersion 2.0"; assert_eq!( detect_license_from_content(apache_content), Some("Apache License".to_string()) ); } #[test] fn test_detect_license_from_content_gpl() { let gpl_content = "GNU GPL License\n\nVersion 2"; assert_eq!( detect_license_from_content(gpl_content), Some("GPL".to_string()) ); } #[test] fn test_detect_license_from_content_no_match() { let unknown = "Some random content"; assert_eq!(detect_license_from_content(unknown), None); } #[test] fn test_get_license_from_local_license_file_mit() { let temp_dir = TempDir::new().unwrap(); let package_dir = temp_dir.path().join("node_modules").join("test-pkg"); fs::create_dir_all(&package_dir).unwrap(); let license_path = package_dir.join("LICENSE"); fs::write(&license_path, "MIT License\n\nCopyright (c) 2024").unwrap(); let result = get_license_from_local_license_file(temp_dir.path(), "test-pkg"); assert_eq!(result, Some("MIT License".to_string())); } #[test] fn test_get_license_from_local_license_file_scoped() { let temp_dir = TempDir::new().unwrap(); let package_dir = temp_dir .path() .join("node_modules") .join("@scope") .join("package"); fs::create_dir_all(&package_dir).unwrap(); let license_path = package_dir.join("LICENSE.md"); fs::write(&license_path, "Apache License").unwrap(); let result = get_license_from_local_license_file(temp_dir.path(), "@scope/package"); assert_eq!(result, Some("Apache License".to_string())); } #[test] fn test_get_license_from_local_license_file_not_found() { let temp_dir = TempDir::new().unwrap(); let result = get_license_from_local_license_file(temp_dir.path(), "nonexistent"); assert_eq!(result, None); } #[test] fn test_get_license_from_local_license_file_pnpm() { let temp_dir = TempDir::new().unwrap(); let package_dir = temp_dir .path() .join("node_modules") .join(".pnpm") .join("node_modules") .join("test-pkg"); fs::create_dir_all(&package_dir).unwrap(); let license_path = package_dir.join("LICENSE.txt"); fs::write(&license_path, "BSD License").unwrap(); let result = get_license_from_local_license_file(temp_dir.path(), "test-pkg"); assert_eq!(result, Some("BSD".to_string())); } } feluda-1.11.1/src/languages/python.rs000064400000000000000000001233721046102023000156010ustar 00000000000000use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; use std::process::Command; use toml::Value as TomlValue; use crate::config::FeludaConfig; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; /// Represents an environment marker in a Python requirement /// Environment markers follow PEP 508 and are used to specify conditional dependencies /// Examples: "python_version < '3.8'", "sys_platform == 'win32'", "os_name == 'nt' and python_version >= '3.6'" #[derive(Debug, Clone, PartialEq)] pub struct EnvironmentMarker { /// The raw marker string pub raw: String, /// Parsed marker components (simplified representation) pub components: Vec, } /// A single marker component (variable, operator, value) #[derive(Debug, Clone, PartialEq)] pub struct MarkerComponent { pub variable: String, pub operator: String, pub value: String, } impl EnvironmentMarker { /// Parse an environment marker string from a PEP 508 requirement /// Returns Some(marker) if marker exists, None if no marker or parsing fails gracefully fn parse(marker_str: &str) -> Option { let marker_str = marker_str.trim(); if marker_str.is_empty() { return None; } let raw = marker_str.to_string(); let components = parse_marker_components(marker_str); Some(EnvironmentMarker { raw, components }) } /// Get a human-readable description of what environments this marker applies to pub fn describe(&self) -> String { if self.components.is_empty() { return self.raw.clone(); } let mut descriptions = Vec::new(); for component in &self.components { descriptions.push(format!( "{} {} '{}'", component.variable, component.operator, component.value )); } descriptions.join(" and ") } /// Check if this marker applies to a specific environment /// For now, we assume all markers apply (conservative approach) /// In production, you'd evaluate against actual environment #[allow(dead_code)] pub fn applies_to_environment(&self) -> bool { // Conservative approach: include all requirements regardless of markers // This ensures we don't miss license dependencies for specific environments true } } /// Parse environment marker components from a marker string /// Supports markers like: /// - "python_version < '3.8'" /// - "sys_platform == 'win32'" /// - "python_version >= '3.6' and os_name == 'nt'" fn parse_marker_components(marker_str: &str) -> Vec { let mut components = Vec::new(); // Split by "and" operator for multiple conditions let conditions: Vec<&str> = marker_str.split(" and ").collect(); for condition in conditions { let condition = condition.trim(); // Try to parse each condition as a marker component // Format: variable operator 'value' or variable operator "value" let operators = vec!["!=", "==", "<=", ">=", "<", ">", "in", "not"]; for op in operators { if let Some(parts) = condition.split_once(op) { let variable = parts.0.trim().to_string(); let value_part = parts.1.trim(); // Remove quotes from value let value = value_part.trim_matches('\'').trim_matches('"').to_string(); if !variable.is_empty() && !value.is_empty() { components.push(MarkerComponent { variable, operator: op.to_string(), value, }); break; } } } } components } /// Analyze the licenses of Python dependencies with transitive resolution pub fn analyze_python_licenses(package_file_path: &str, config: &FeludaConfig) -> Vec { let mut licenses = Vec::new(); log( LogLevel::Info, &format!("Analyzing Python dependencies from: {package_file_path}"), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; // Check if it's a pyproject.toml file if package_file_path.ends_with("pyproject.toml") { match fs::read_to_string(package_file_path) { Ok(content) => match toml::from_str::(&content) { Ok(toml_config) => { if let Some(project) = toml_config.as_table().and_then(|t| t.get("project")) { if let Some(deps) = project .as_table() .and_then(|t| t.get("dependencies")) .and_then(|d| d.as_array()) { log( LogLevel::Info, &format!("Found {} Python dependencies", deps.len()), ); log_debug("Dependencies", deps); // First collect direct dependencies let mut direct_deps = Vec::new(); for dep in deps { if let Some(dep_str) = dep.as_str() { let (name, version) = if let Some((n, v)) = dep_str .split_once("==") .or_else(|| dep_str.split_once(">=")) .or_else(|| dep_str.split_once(">")) .or_else(|| dep_str.split_once("~=")) .or_else(|| dep_str.split_once("<=")) .or_else(|| dep_str.split_once("<")) { (n.trim(), v.trim()) } else { (dep_str.trim(), "latest") }; direct_deps.push((name.to_string(), version.to_string())); } } // Try to resolve all dependencies (direct + transitive) using uv or fallback to PyPI let max_depth = config.dependencies.max_depth; log( LogLevel::Info, &format!("Using max dependency depth: {max_depth}"), ); let all_deps = resolve_python_dependencies( &direct_deps, package_file_path, max_depth, ); // Process all resolved dependencies for (name, version) in all_deps { log( LogLevel::Info, &format!("Processing dependency: {name} ({version})"), ); let license_result = fetch_license_for_python_dependency(&name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive( &license, &known_licenses, config.strict, ); if is_restrictive { log( LogLevel::Warn, &format!( "Restrictive license found: {license:?} for {name}" ), ); } licenses.push(LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, }); } } else { log( LogLevel::Warn, "Failed to find dependencies in pyproject.toml", ); } } else { log( LogLevel::Warn, "No 'project' section found in pyproject.toml", ); } } Err(err) => { log_error("Failed to parse pyproject.toml", &err); } }, Err(err) => { log_error("Failed to read pyproject.toml file", &err); } } } else { log(LogLevel::Info, "Processing requirements.txt format"); match File::open(package_file_path) { Ok(file) => { let reader = BufReader::new(file); let mut direct_deps = Vec::new(); // Direct dependencies for line_result in reader.lines() { match line_result { Ok(line) => { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } // Parse requirement line (supporting various formats) if let Some((name, version)) = parse_requirement_line(line) { direct_deps.push((name, version)); } else { log(LogLevel::Warn, &format!("Invalid requirement line: {line}")); } } Err(err) => { log_error("Failed to read line from requirements.txt", &err); } } } log( LogLevel::Info, &format!( "Found {} direct requirements in requirements.txt", direct_deps.len() ), ); // Try to resolve all dependencies (direct + transitive) let max_depth = config.dependencies.max_depth; log( LogLevel::Info, &format!("Using max dependency depth: {max_depth}"), ); let all_deps = resolve_python_dependencies(&direct_deps, package_file_path, max_depth); // Process all resolved dependencies for (name, version) in all_deps { log( LogLevel::Info, &format!("Processing dependency: {name} ({version})"), ); let license_result = fetch_license_for_python_dependency(&name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } licenses.push(LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, }); } log( LogLevel::Info, &format!( "Processed {} total dependencies (including transitive)", licenses.len() ), ); } Err(err) => { log_error("Failed to open requirements.txt file", &err); } } } log( LogLevel::Info, &format!("Found {} Python dependencies with licenses", licenses.len()), ); licenses } /// Fetch the license for a Python dependency, trying local sources first, then PyPI pub fn fetch_license_for_python_dependency(name: &str, version: &str) -> String { if let Some(license) = get_license_from_local_site_packages(name) { log( LogLevel::Info, &format!("Found license in local site-packages for {name}: {license}"), ); return license; } fetch_license_from_pypi(name, version) } fn get_license_from_local_site_packages(package_name: &str) -> Option { let python_paths = get_python_site_packages_paths(); for site_packages in python_paths { if let Some(license) = check_site_package_metadata(&site_packages, package_name) { return Some(license); } if let Some(license) = check_site_package_license_file(&site_packages, package_name) { return Some(license); } } None } fn get_python_site_packages_paths() -> Vec { let mut paths = Vec::new(); if let Ok(output) = Command::new("python3") .args([ "-c", "import site; print('\\n'.join(site.getsitepackages()))", ]) .output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { paths.push(std::path::PathBuf::from(line.trim())); } } } if let Ok(output) = Command::new("python") .args([ "-c", "import site; print('\\n'.join(site.getsitepackages()))", ]) .output() { if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { let path = std::path::PathBuf::from(line.trim()); if !paths.contains(&path) { paths.push(path); } } } } paths } fn check_site_package_metadata(site_packages: &Path, package_name: &str) -> Option { let metadata_file = site_packages .join(format!("{package_name}.dist-info")) .join("METADATA"); if metadata_file.exists() { if let Ok(content) = fs::read_to_string(&metadata_file) { for line in content.lines() { if line.starts_with("License:") { if let Some(license) = line.strip_prefix("License:") { let license = license.trim(); if !license.is_empty() && license != "UNKNOWN" { return Some(license.to_string()); } } } } } } let normalized_name = package_name.replace('-', "_"); let metadata_file_normalized = site_packages .join(format!("{normalized_name}.dist-info")) .join("METADATA"); if metadata_file_normalized.exists() { if let Ok(content) = fs::read_to_string(&metadata_file_normalized) { for line in content.lines() { if line.starts_with("License:") { if let Some(license) = line.strip_prefix("License:") { let license = license.trim(); if !license.is_empty() && license != "UNKNOWN" { return Some(license.to_string()); } } } } } } None } fn check_site_package_license_file(site_packages: &Path, package_name: &str) -> Option { let package_dirs = vec![ site_packages.join(package_name), site_packages.join(package_name.replace('-', "_")), ]; let license_files = [ "LICENSE", "LICENSE.txt", "LICENSE.md", "COPYING", "COPYING.md", ]; for package_dir in package_dirs { if !package_dir.exists() { continue; } for license_file in &license_files { let license_path = package_dir.join(license_file); if license_path.exists() { if let Ok(content) = fs::read_to_string(&license_path) { if let Some(license) = detect_license_from_content(&content) { return Some(license); } } } } } None } fn detect_license_from_content(content: &str) -> Option { let content_upper = content.to_uppercase(); let patterns = vec![ ("MIT", "MIT License"), ("APACHE", "Apache License"), ("GPL", "GPL"), ("BSD", "BSD"), ("ISC", "ISC License"), ("LGPL", "LGPL"), ("UNLICENSE", "Unlicense"), ("MPL", "Mozilla Public License"), ]; for (pattern, label) in patterns { if content_upper.contains(pattern) { return Some(label.to_string()); } } None } fn fetch_license_from_pypi(name: &str, version: &str) -> String { let api_url = format!("https://pypi.org/pypi/{name}/{version}/json"); log( LogLevel::Info, &format!("Fetching license from PyPI: {api_url}"), ); match reqwest::blocking::get(&api_url) { Ok(response) => { let status = response.status(); log( LogLevel::Info, &format!("PyPI API response status: {status}"), ); if status.is_success() { match response.json::() { Ok(json) => match json["info"]["license"].as_str() { Some(license_str) if !license_str.is_empty() => { log( LogLevel::Info, &format!("License found for {name}: {license_str}"), ); license_str.to_string() } _ => { log( LogLevel::Warn, &format!("No license found for {name} ({version})"), ); format!("Unknown license for {name}: {version}") } }, Err(err) => { log_error(&format!("Failed to parse JSON for {name}: {version}"), &err); String::from("Unknown") } } } else { log( LogLevel::Error, &format!("Failed to fetch metadata for {name}: HTTP {status}"), ); String::from("Unknown") } } Err(err) => { log_error(&format!("Failed to fetch metadata for {name}"), &err); String::from("Unknown") } } } /// Parse a requirement line from requirements.txt supporting various formats /// Handles requirements.txt format with optional environment markers /// Examples: /// - "requests==2.31.0" /// - "flask>=2.0.0" /// - "django; python_version >= '3.8'" /// - "numpy>=1.20.0; sys_platform == 'linux'" fn parse_requirement_line(line: &str) -> Option<(String, String)> { let line = line.trim(); // Extract marker if present let (base_req, marker) = if let Some((base, marker_str)) = line.split_once(';') { (base.trim(), EnvironmentMarker::parse(marker_str)) } else { (line, None) }; // Log marker information if present if let Some(marker) = &marker { log_debug( "Environment Marker (requirements.txt)", &format!("Detected marker: {} -> {}", marker.raw, marker.describe()), ); } // Handle various requirement formats on the base requirement if let Some((name, version)) = base_req .split_once("==") .or_else(|| base_req.split_once(">=")) .or_else(|| base_req.split_once(">")) .or_else(|| base_req.split_once("~=")) .or_else(|| base_req.split_once("<=")) .or_else(|| base_req.split_once("<")) { let name = name.trim(); let version = version .trim() .trim_matches('"') .trim_matches('\'') .replace("^", "") .replace("~", ""); Some((name.to_string(), version)) } else { // Package name without version (with or without marker) Some((base_req.to_string(), "latest".to_string())) } } /// Resolve all Python dependencies (direct + transitive) with configurable depth fn resolve_python_dependencies( direct_deps: &[(String, String)], package_file_path: &str, max_depth: u32, ) -> Vec<(String, String)> { log( LogLevel::Info, &format!("Resolving Python dependencies (including transitive up to depth {max_depth})"), ); // First, try using uv for complete dependency resolution if let Ok(uv_deps) = resolve_with_uv(package_file_path, max_depth) { if !uv_deps.is_empty() { log( LogLevel::Info, &format!( "Resolved {} dependencies using uv (depth {})", uv_deps.len(), max_depth ), ); return uv_deps; } } // Fallback to PyPI-based transitive resolution log( LogLevel::Info, "Falling back to PyPI-based transitive dependency resolution", ); resolve_with_pypi(direct_deps, max_depth) } /// Try to resolve dependencies using uv tool with depth limit fn resolve_with_uv( package_file_path: &str, max_depth: u32, ) -> Result, String> { let project_dir = Path::new(package_file_path) .parent() .ok_or("Cannot determine project directory")?; log( LogLevel::Info, &format!("Attempting to resolve dependencies with uv (max depth: {max_depth})"), ); // Try uv lock command first (for uv-managed projects) if let Ok(output) = Command::new("uv") .args(["lock", "--dry-run"]) .current_dir(project_dir) .output() { if output.status.success() { // Parse uv.lock file if it exists let lock_file = project_dir.join("uv.lock"); if lock_file.exists() { if let Ok(deps) = parse_uv_lock(&lock_file, max_depth) { log( LogLevel::Info, &format!("Resolved {} dependencies from uv.lock", deps.len()), ); return Ok(deps); } } } } // Try pip-compile style resolution using uv if let Ok(output) = Command::new("uv") .args(["pip", "compile", "--dry-run", package_file_path]) .current_dir(project_dir) .output() { if output.status.success() { let stdout_str = String::from_utf8_lossy(&output.stdout); let deps = parse_pip_compile_output(&stdout_str); log( LogLevel::Info, &format!( "Resolved {} dependencies from pip-compile output", deps.len() ), ); return Ok(deps); } } Err("uv resolution failed".to_string()) } /// Parse uv.lock file to extract dependencies with depth awareness fn parse_uv_lock(lock_file: &Path, max_depth: u32) -> Result, String> { let content = fs::read_to_string(lock_file).map_err(|e| format!("Failed to read uv.lock: {e}"))?; // uv.lock is TOML format let lock_data: TomlValue = toml::from_str(&content).map_err(|e| format!("Failed to parse uv.lock: {e}"))?; let mut deps = Vec::new(); log( LogLevel::Info, &format!("Parsing uv.lock with max depth {max_depth}"), ); // Extract packages from uv.lock format if let Some(packages) = lock_data.get("package").and_then(|p| p.as_array()) { for package in packages { if let Some(package_table) = package.as_table() { if let (Some(name), Some(version)) = ( package_table.get("name").and_then(|n| n.as_str()), package_table.get("version").and_then(|v| v.as_str()), ) { deps.push((name.to_string(), version.to_string())); } } } log( LogLevel::Info, &format!( "Extracted {} dependencies from uv.lock (all depths included)", deps.len() ), ); } Ok(deps) } /// Parse pip-compile style output to extract dependencies fn parse_pip_compile_output(output: &str) -> Vec<(String, String)> { let mut deps = Vec::new(); for line in output.lines() { let line = line.trim(); if line.is_empty() || line.starts_with('#') { continue; } if let Some((name, version)) = parse_requirement_line(line) { deps.push((name, version)); } } deps } /// Resolve transitive dependencies using PyPI API with configurable depth limit fn resolve_with_pypi(direct_deps: &[(String, String)], max_depth: u32) -> Vec<(String, String)> { let mut all_deps = HashMap::new(); let mut processed = HashSet::new(); let mut to_process: Vec<(String, String, u32)> = direct_deps .iter() .map(|(name, version)| (name.clone(), version.clone(), 0)) .collect(); log( LogLevel::Info, &format!( "Starting PyPI-based resolution with {} direct dependencies (max depth: {})", direct_deps.len(), max_depth ), ); let mut depth_stats = HashMap::new(); // Iteratively resolve dependencies with depth tracking while let Some((name, version, depth)) = to_process.pop() { let key = format!("{name}@{version}"); if processed.contains(&key) { continue; } // Skip if we've exceeded max depth if depth >= max_depth { log( LogLevel::Info, &format!("Skipping {name}@{version} - exceeded max depth {max_depth}"), ); continue; } processed.insert(key); all_deps.insert(name.clone(), version.clone()); // Track depth statistics *depth_stats.entry(depth).or_insert(0) += 1; log( LogLevel::Info, &format!("Resolving dependencies for: {name}@{version} (depth {depth})"), ); // Fetch dependencies for this package if let Ok(transitive_deps) = fetch_pypi_dependencies(&name, &version) { log( LogLevel::Info, &format!( "Found {} transitive dependencies for {} at depth {}", transitive_deps.len(), name, depth ), ); for (dep_name, dep_version) in transitive_deps { let dep_key = format!("{dep_name}@{dep_version}"); if !processed.contains(&dep_key) { to_process.push((dep_name, dep_version, depth + 1)); } } } } // Log depth statistics for depth in 0..=max_depth { if let Some(count) = depth_stats.get(&depth) { log( LogLevel::Info, &format!("Depth {depth}: {count} dependencies"), ); } } log( LogLevel::Info, &format!( "PyPI resolution completed. Total dependencies: {} (explored up to depth {})", all_deps.len(), max_depth ), ); all_deps.into_iter().collect() } /// Fetch dependencies from PyPI for a specific package fn fetch_pypi_dependencies(name: &str, version: &str) -> Result, String> { let api_url = format!("https://pypi.org/pypi/{name}/{version}/json"); match reqwest::blocking::get(&api_url) { Ok(response) => { if response.status().is_success() { if let Ok(json) = response.json::() { let mut deps = Vec::new(); // Extract requires_dist information if let Some(requires_dist) = json["info"]["requires_dist"].as_array() { for req in requires_dist { if let Some(req_str) = req.as_str() { if let Some((dep_name, dep_version)) = parse_pypi_requirement(req_str) { deps.push((dep_name, dep_version)); } } } } return Ok(deps); } } } Err(err) => { log_error(&format!("Failed to fetch dependencies for {name}"), &err); } } Ok(Vec::new()) } /// Parse a PyPI requires_dist requirement string with full PEP 508 support /// Handles requirements like: /// - "requests>=2.20.0" /// - "flask" /// - "typing-extensions>=3.7.4; python_version < '3.8'" /// - "urllib3 (<3,>=1.21.1); python_version >= '3.10'" /// - "package[extra1,extra2]; sys_platform == 'win32'" fn parse_pypi_requirement(req_str: &str) -> Option<(String, String)> { let req_str = req_str.trim(); // Parse the requirement and extract marker separately let (base_req, marker) = if let Some((base, marker_str)) = req_str.split_once(';') { (base.trim(), EnvironmentMarker::parse(marker_str)) } else { (req_str, None) }; // Log marker information if present if let Some(marker) = &marker { log_debug( "Environment Marker", &format!("Detected marker: {} -> {}", marker.raw, marker.describe()), ); } // Parse base requirement (name and version) let mut chars = base_req.chars().peekable(); let mut name = String::new(); // Extract package name (stop at version constraints, spaces, or extras) while let Some(ch) = chars.peek() { if ">= = remaining.split(',').collect(); let mut best_version = "latest"; for constraint in &constraints { if let Some((_operator, version_part)) = parse_version_constraint(constraint.trim()) { if constraint.trim().starts_with(">=") || constraint.trim().starts_with("==") { best_version = version_part.trim(); break; } else if best_version == "latest" { best_version = version_part.trim(); } } } Some((name, best_version.to_string())) } /// Parse version constraint operators fn parse_version_constraint(constraint: &str) -> Option<(&str, &str)> { let constraint = constraint.trim(); if let Some(version) = constraint.strip_prefix(">=") { Some((">=", version.trim())) } else if let Some(version) = constraint.strip_prefix("<=") { Some(("<=", version.trim())) } else if let Some(version) = constraint.strip_prefix("==") { Some(("==", version.trim())) } else if let Some(version) = constraint.strip_prefix("~=") { Some(("~=", version.trim())) } else if let Some(version) = constraint.strip_prefix("!=") { Some(("!=", version.trim())) } else if let Some(version) = constraint.strip_prefix(">") { Some((">", version.trim())) } else { constraint .strip_prefix("<") .map(|version| ("<", version.trim())) } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_analyze_python_licenses_pyproject_toml() { let temp_dir = TempDir::new().unwrap(); let pyproject_toml_path = temp_dir.path().join("pyproject.toml"); std::fs::write( &pyproject_toml_path, r#"[project] name = "test-project" version = "0.1.0" dependencies = [ "requests>=2.31.0", "flask~=2.0.0" ] "#, ) .unwrap(); let config = FeludaConfig::default(); let result = analyze_python_licenses(pyproject_toml_path.to_str().unwrap(), &config); assert!(!result.is_empty()); assert!(result.iter().any(|info| info.name == "requests")); assert!(result.iter().any(|info| info.name == "flask")); } #[test] fn test_analyze_python_licenses_empty_file() { let temp_dir = TempDir::new().unwrap(); let requirements_path = temp_dir.path().join("requirements.txt"); std::fs::write(&requirements_path, "").unwrap(); let config = FeludaConfig::default(); let result = analyze_python_licenses(requirements_path.to_str().unwrap(), &config); assert!(result.is_empty()); } #[test] fn test_analyze_python_licenses_invalid_format() { let temp_dir = TempDir::new().unwrap(); let requirements_path = temp_dir.path().join("requirements.txt"); std::fs::write(&requirements_path, "# This is a comment\n\n").unwrap(); let config = FeludaConfig::default(); let result = analyze_python_licenses(requirements_path.to_str().unwrap(), &config); assert!(result.is_empty()); } #[test] fn test_analyze_python_licenses_packages_without_versions() { let temp_dir = TempDir::new().unwrap(); let requirements_path = temp_dir.path().join("requirements.txt"); std::fs::write( &requirements_path, "requests\nflask\n# This is a comment\nnumpy", ) .unwrap(); let config = FeludaConfig::default(); let result = analyze_python_licenses(requirements_path.to_str().unwrap(), &config); // Process packages without explicit versions using transitive resolution assert!(!result.is_empty()); assert!(result.iter().any(|info| info.name == "requests")); assert!(result.iter().any(|info| info.name == "flask")); assert!(result.iter().any(|info| info.name == "numpy")); } #[test] fn test_fetch_license_for_python_dependency_error_handling() { // Test with a definitely non-existent package let result = fetch_license_for_python_dependency("definitely_nonexistent_package_12345", "1.0.0"); assert!(result.contains("Unknown") || result.contains("nonexistent")); } #[test] fn test_parse_requirement_line() { // Test various requirement formats assert_eq!( parse_requirement_line("requests==2.31.0"), Some(("requests".to_string(), "2.31.0".to_string())) ); assert_eq!( parse_requirement_line("flask>=2.0.0"), Some(("flask".to_string(), "2.0.0".to_string())) ); assert_eq!( parse_requirement_line("django"), Some(("django".to_string(), "latest".to_string())) ); } #[test] fn test_parse_pypi_requirement() { // Test PyPI requires_dist format parsing assert_eq!( parse_pypi_requirement("requests>=2.20.0"), Some(("requests".to_string(), "2.20.0".to_string())) ); assert_eq!( parse_pypi_requirement("typing-extensions>=3.7.4; python_version < '3.8'"), Some(("typing-extensions".to_string(), "3.7.4".to_string())) ); assert_eq!( parse_pypi_requirement("flask"), Some(("flask".to_string(), "latest".to_string())) ); // Test complex version constraints assert_eq!( parse_pypi_requirement("urllib3 (<3,>=1.21.1)"), Some(("urllib3".to_string(), "1.21.1".to_string())) ); assert_eq!( parse_pypi_requirement("chardet (<6,>=3.0.2)"), Some(("chardet".to_string(), "3.0.2".to_string())) ); assert_eq!( parse_pypi_requirement("PySocks (!=1.5.7,>=1.5.6)"), Some(("PySocks".to_string(), "1.5.6".to_string())) ); } #[test] fn test_parse_environment_markers() { // Test simple markers let marker1 = EnvironmentMarker::parse("python_version < '3.8'"); assert!(marker1.is_some()); if let Some(m) = marker1 { assert!(!m.components.is_empty()); assert!(m.components[0].variable.contains("python_version")); assert_eq!(m.components[0].operator, "<"); assert_eq!(m.components[0].value, "3.8"); } // Test marker with == let marker2 = EnvironmentMarker::parse("sys_platform == 'win32'"); assert!(marker2.is_some()); if let Some(m) = marker2 { assert!(!m.components.is_empty()); assert_eq!(m.components[0].operator, "=="); assert_eq!(m.components[0].value, "win32"); } // Test multiple conditions let marker3 = EnvironmentMarker::parse("python_version >= '3.6' and os_name == 'nt'"); assert!(marker3.is_some()); if let Some(m) = marker3 { assert_eq!(m.components.len(), 2); } // Test empty marker let marker4 = EnvironmentMarker::parse(""); assert!(marker4.is_none()); } #[test] fn test_parse_requirement_with_markers() { // Test requirements.txt format with markers assert_eq!( parse_requirement_line("requests==2.31.0"), Some(("requests".to_string(), "2.31.0".to_string())) ); assert_eq!( parse_requirement_line("django>=3.2; python_version >= '3.8'"), Some(("django".to_string(), "3.2".to_string())) ); assert_eq!( parse_requirement_line("numpy; sys_platform == 'linux'"), Some(("numpy".to_string(), "latest".to_string())) ); // Test with multiple version constraints and markers // Note: We keep the full version string when there are multiple constraints assert_eq!( parse_requirement_line("scipy>=1.7,<2.0; python_version >= '3.9'"), Some(("scipy".to_string(), "1.7,<2.0".to_string())) ); } #[test] fn test_parse_pypi_requirement_with_extras() { // Test requirements with extras (square brackets) assert_eq!( parse_pypi_requirement("requests[security]>=2.20.0"), Some(("requests".to_string(), "2.20.0".to_string())) ); assert_eq!( parse_pypi_requirement("package[extra1,extra2]>=1.0; python_version >= '3.7'"), Some(("package".to_string(), "1.0".to_string())) ); } #[test] fn test_marker_description() { let marker = EnvironmentMarker::parse("python_version < '3.8' and sys_platform == 'win32'"); assert!(marker.is_some()); if let Some(m) = marker { let description = m.describe(); assert!(description.contains("python_version")); assert!(description.contains("sys_platform")); } } #[test] fn test_marker_applies_to_environment() { // All markers should return true (conservative approach) let marker1 = EnvironmentMarker::parse("python_version < '3.8'"); assert!(marker1.unwrap().applies_to_environment()); let marker2 = EnvironmentMarker::parse("sys_platform == 'darwin'"); assert!(marker2.unwrap().applies_to_environment()); } } feluda-1.11.1/src/languages/r.rs000064400000000000000000000352271046102023000145220ustar 00000000000000use serde_json::Value; use std::collections::HashMap; use std::fs; use crate::config::FeludaConfig; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, License, LicenseCompatibility, LicenseInfo, }; pub fn analyze_r_licenses(package_file_path: &str, config: &FeludaConfig) -> Vec { let mut licenses = Vec::new(); log( LogLevel::Info, &format!("Analyzing R dependencies from: {package_file_path}"), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; if package_file_path.ends_with("renv.lock") { licenses.extend(parse_renv_lock(package_file_path, &known_licenses, config)); } else if package_file_path.ends_with("DESCRIPTION") { let max_depth = config.dependencies.max_depth; licenses.extend(parse_description_file( package_file_path, max_depth, &known_licenses, config, )); } else { log( LogLevel::Warn, &format!("Unsupported R dependency file: {package_file_path}"), ); } log( LogLevel::Info, &format!("Found {} R dependencies with licenses", licenses.len()), ); licenses } fn parse_renv_lock( lock_file_path: &str, known_licenses: &HashMap, config: &FeludaConfig, ) -> Vec { let mut licenses = Vec::new(); match fs::read_to_string(lock_file_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(json) => { if let Some(packages) = json["Packages"].as_object() { log( LogLevel::Info, &format!("Found {} packages in renv.lock", packages.len()), ); log_debug("Packages", packages); for (name, pkg_info) in packages { let version = pkg_info["Version"] .as_str() .unwrap_or("unknown") .to_string(); log( LogLevel::Info, &format!("Processing R package: {name} ({version})"), ); let license_result = fetch_license_for_r_dependency(name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } licenses.push(LicenseInfo { name: name.clone(), version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, }); } } else { log(LogLevel::Warn, "No 'Packages' section found in renv.lock"); } } Err(err) => { log_error("Failed to parse renv.lock JSON", &err); } }, Err(err) => { log_error("Failed to read renv.lock file", &err); } } licenses } fn parse_description_file( desc_file_path: &str, _max_depth: u32, known_licenses: &HashMap, config: &FeludaConfig, ) -> Vec { let mut licenses = Vec::new(); match fs::read_to_string(desc_file_path) { Ok(content) => { let direct_deps = parse_dcf_dependencies(&content); if direct_deps.is_empty() { log(LogLevel::Warn, "No dependencies found in DESCRIPTION file"); return licenses; } log( LogLevel::Info, &format!( "Found {} dependencies in DESCRIPTION file (direct dependencies only - use renv.lock for transitive dependencies)", direct_deps.len() ), ); let all_deps = direct_deps; for (name, version) in all_deps { log( LogLevel::Info, &format!("Processing R package: {name} ({version})"), ); let license_result = fetch_license_for_r_dependency(&name, &version); let license = Some(license_result); let is_restrictive = is_license_restrictive(&license, known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!("Restrictive license found: {license:?} for {name}"), ); } licenses.push(LicenseInfo { name, version, license: license.clone(), is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &license { Some(l) => crate::licenses::get_osi_status(l), None => crate::licenses::OsiStatus::Unknown, }, }); } } Err(err) => { log_error("Failed to read DESCRIPTION file", &err); } } licenses } fn parse_dcf_dependencies(content: &str) -> Vec<(String, String)> { let mut deps = Vec::new(); let mut current_field = String::new(); let mut current_value = String::new(); for line in content.lines() { if line.starts_with(' ') || line.starts_with('\t') { current_value.push(' '); current_value.push_str(line.trim()); } else if let Some((field, value)) = line.split_once(':') { if !current_field.is_empty() { process_dependency_field(¤t_field, ¤t_value, &mut deps); } current_field = field.trim().to_string(); current_value = value.trim().to_string(); } } if !current_field.is_empty() { process_dependency_field(¤t_field, ¤t_value, &mut deps); } deps } fn process_dependency_field(field: &str, value: &str, deps: &mut Vec<(String, String)>) { let dependency_fields = ["Imports", "Depends", "Suggests", "LinkingTo"]; if !dependency_fields.contains(&field) { return; } for dep_part in value.split(',') { let dep_part = dep_part.trim(); if dep_part.is_empty() || dep_part.starts_with("R (") { continue; } let (name, version) = if let Some((pkg, ver_spec)) = dep_part.split_once('(') { let pkg = pkg.trim(); let ver = ver_spec .trim_end_matches(')') .trim() .replace(">=", "") .replace("<=", "") .replace(">", "") .replace("<", "") .replace("==", "") .trim() .to_string(); (pkg.to_string(), ver) } else { (dep_part.to_string(), "latest".to_string()) }; deps.push((name, version)); } } pub fn fetch_license_for_r_dependency(name: &str, version: &str) -> String { let search_url = format!("https://r-universe.dev/api/search?q={name}&limit=1"); log( LogLevel::Info, &format!("Fetching license from R-universe: {search_url}"), ); match reqwest::blocking::get(&search_url) { Ok(response) => { let status = response.status(); log( LogLevel::Info, &format!("R-universe API response status: {status}"), ); if status.is_success() { match response.json::() { Ok(json) => { if let Some(results) = json["results"].as_array() { if let Some(first_result) = results.first() { if let Some(user) = first_result["_user"].as_str() { let package_url = format!( "https://{user}.r-universe.dev/api/packages/{name}" ); log( LogLevel::Info, &format!("Fetching package details from: {package_url}"), ); if let Ok(pkg_response) = reqwest::blocking::get(&package_url) { if let Ok(pkg_json) = pkg_response.json::() { if let Some(license) = pkg_json["License"].as_str() { if !license.is_empty() { log( LogLevel::Info, &format!( "License found for {name}: {license}" ), ); return license.to_string(); } } } } } } } log( LogLevel::Warn, &format!("No license found for {name} ({version})"), ); format!("Unknown license for {name}: {version}") } Err(err) => { log_error(&format!("Failed to parse JSON for {name}: {version}"), &err); String::from("Unknown") } } } else { log( LogLevel::Error, &format!("Failed to fetch metadata for {name}: HTTP {status}"), ); String::from("Unknown") } } Err(err) => { log_error(&format!("Failed to fetch metadata for {name}"), &err); String::from("Unknown") } } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_parse_dcf_dependencies() { let content = r#"Package: testpkg Version: 1.0.0 Imports: dplyr (>= 1.0.0), ggplot2, tidyr (>= 1.2.0) Suggests: testthat, knitr "#; let deps = parse_dcf_dependencies(content); assert_eq!(deps.len(), 5); assert!(deps.iter().any(|(name, _)| name == "dplyr")); assert!(deps.iter().any(|(name, _)| name == "ggplot2")); assert!(deps.iter().any(|(name, _)| name == "tidyr")); assert!(deps.iter().any(|(name, _)| name == "testthat")); assert!(deps.iter().any(|(name, _)| name == "knitr")); } #[test] fn test_parse_dcf_dependencies_with_versions() { let content = r#"Imports: dplyr (>= 1.0.0), ggplot2 (>= 3.3.0)"#; let deps = parse_dcf_dependencies(content); assert_eq!(deps.len(), 2); let dplyr_dep = deps.iter().find(|(name, _)| name == "dplyr").unwrap(); assert_eq!(dplyr_dep.1, "1.0.0"); let ggplot2_dep = deps.iter().find(|(name, _)| name == "ggplot2").unwrap(); assert_eq!(ggplot2_dep.1, "3.3.0"); } #[test] fn test_parse_dcf_dependencies_ignores_r_version() { let content = r#"Depends: R (>= 4.0.0), dplyr"#; let deps = parse_dcf_dependencies(content); assert_eq!(deps.len(), 1); assert_eq!(deps[0].0, "dplyr"); } #[test] fn test_parse_renv_lock() { let temp_dir = TempDir::new().unwrap(); let lock_path = temp_dir.path().join("renv.lock"); let lock_content = r#"{ "R": { "Version": "4.2.0", "Repositories": [] }, "Packages": { "dplyr": { "Package": "dplyr", "Version": "1.0.9", "Source": "Repository", "Repository": "CRAN" }, "ggplot2": { "Package": "ggplot2", "Version": "3.3.6", "Source": "Repository", "Repository": "CRAN" } } }"#; fs::write(&lock_path, lock_content).unwrap(); let known_licenses = HashMap::new(); let config = FeludaConfig::default(); let result = parse_renv_lock(lock_path.to_str().unwrap(), &known_licenses, &config); assert_eq!(result.len(), 2); assert!(result.iter().any(|info| info.name == "dplyr")); assert!(result.iter().any(|info| info.name == "ggplot2")); } #[test] fn test_analyze_r_licenses_description() { let temp_dir = TempDir::new().unwrap(); let desc_path = temp_dir.path().join("DESCRIPTION"); let desc_content = r#"Package: testpkg Version: 1.0.0 Imports: dplyr (>= 1.0.0), ggplot2 "#; fs::write(&desc_path, desc_content).unwrap(); let config = FeludaConfig::default(); let result = analyze_r_licenses(desc_path.to_str().unwrap(), &config); assert!(!result.is_empty()); assert!(result.iter().any(|info| info.name == "dplyr")); assert!(result.iter().any(|info| info.name == "ggplot2")); } #[test] fn test_analyze_r_licenses_empty_description() { let temp_dir = TempDir::new().unwrap(); let desc_path = temp_dir.path().join("DESCRIPTION"); fs::write(&desc_path, "Package: testpkg\nVersion: 1.0.0\n").unwrap(); let config = FeludaConfig::default(); let result = analyze_r_licenses(desc_path.to_str().unwrap(), &config); assert!(result.is_empty()); } } feluda-1.11.1/src/languages/rust.rs000064400000000000000000000156311046102023000152530ustar 00000000000000use cargo_metadata::Package; use rayon::prelude::*; use std::collections::HashMap; use crate::debug::{log, log_error, LogLevel}; use crate::licenses::{ fetch_licenses_from_github, is_license_restrictive, LicenseCompatibility, LicenseInfo, }; /// Analyze the licenses of Rust dependencies from Cargo packages #[allow(dead_code)] pub fn analyze_rust_licenses(packages: Vec) -> Vec { let config = crate::config::load_config().unwrap_or_default(); analyze_rust_licenses_with_config(packages, &config, false) } pub fn analyze_rust_licenses_with_no_local( packages: Vec, no_local: bool, ) -> Vec { let config = crate::config::load_config().unwrap_or_default(); analyze_rust_licenses_with_config(packages, &config, no_local) } pub fn analyze_rust_licenses_with_config( packages: Vec, config: &crate::config::FeludaConfig, no_local: bool, ) -> Vec { if packages.is_empty() { log( LogLevel::Warn, "No Rust packages found for license analysis", ); return vec![]; } log( LogLevel::Info, &format!("Analyzing licenses for {} Rust packages", packages.len()), ); let known_licenses = match fetch_licenses_from_github() { Ok(licenses) => { log( LogLevel::Info, &format!("Fetched {} known licenses from GitHub", licenses.len()), ); licenses } Err(err) => { log_error("Failed to fetch licenses from GitHub", &err); HashMap::new() } }; packages .par_iter() .map(|package| { log( LogLevel::Info, &format!("Analyzing package: {} ({})", package.name, package.version), ); let license = package.license.clone().or_else(|| { if no_local { None } else { get_license_from_manifest(&package.manifest_path) } }); let is_restrictive = is_license_restrictive(&license, &known_licenses, config.strict); if is_restrictive { log( LogLevel::Warn, &format!( "Restrictive license found: {:?} for {}", license, package.name ), ); } LicenseInfo { name: package.name.to_string(), version: package.version.to_string(), license, is_restrictive, compatibility: LicenseCompatibility::Unknown, osi_status: match &package.license { Some(license) => crate::licenses::get_osi_status(license), None => crate::licenses::OsiStatus::Unknown, }, } }) .collect() } fn get_license_from_manifest>(manifest_path: P) -> Option { use std::fs; use toml::Value; let manifest_path = manifest_path.as_ref(); log( crate::debug::LogLevel::Info, &format!("Checking manifest for license: {}", manifest_path.display()), ); if !manifest_path.exists() { return None; } match fs::read_to_string(manifest_path) { Ok(content) => match toml::from_str::(&content) { Ok(manifest) => manifest .get("package") .and_then(|pkg| pkg.get("license")) .and_then(|license| license.as_str()) .map(|s| { log( crate::debug::LogLevel::Info, &format!("Found license in manifest: {s}"), ); s.to_string() }), Err(_) => None, }, Err(_) => None, } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn setup() -> TempDir { tempfile::tempdir().unwrap() } #[test] fn test_analyze_rust_licenses_empty() { let packages = vec![]; let result = analyze_rust_licenses(packages); assert!(result.is_empty()); } #[test] fn test_license_restrictive_with_default_config() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); let known_licenses = HashMap::new(); assert!(is_license_restrictive( &Some("GPL-3.0".to_string()), &known_licenses, false )); assert!(!is_license_restrictive( &Some("MIT".to_string()), &known_licenses, false )); }); } #[test] fn test_license_restrictive_no_license() { temp_env::with_var("FELUDA_LICENSES_RESTRICTIVE", None::<&str>, || { let dir = setup(); std::env::set_current_dir(dir.path()).unwrap(); let known_licenses = HashMap::new(); assert!(is_license_restrictive( &Some("No License".to_string()), &known_licenses, false )); }); } #[test] fn test_get_license_from_manifest() { let temp_dir = TempDir::new().unwrap(); let manifest_path = temp_dir.path().join("Cargo.toml"); let manifest_content = r#"[package] name = "test-crate" version = "0.1.0" license = "MIT" "#; std::fs::write(&manifest_path, manifest_content).unwrap(); let result = get_license_from_manifest(&manifest_path); assert_eq!(result, Some("MIT".to_string())); } #[test] fn test_get_license_from_manifest_apache() { let temp_dir = TempDir::new().unwrap(); let manifest_path = temp_dir.path().join("Cargo.toml"); let manifest_content = r#"[package] name = "test-crate" version = "0.1.0" license = "Apache-2.0" "#; std::fs::write(&manifest_path, manifest_content).unwrap(); let result = get_license_from_manifest(&manifest_path); assert_eq!(result, Some("Apache-2.0".to_string())); } #[test] fn test_get_license_from_manifest_missing() { let temp_dir = TempDir::new().unwrap(); let manifest_path = temp_dir.path().join("Cargo.toml"); let manifest_content = r#"[package] name = "test-crate" version = "0.1.0" "#; std::fs::write(&manifest_path, manifest_content).unwrap(); let result = get_license_from_manifest(&manifest_path); assert_eq!(result, None); } #[test] fn test_get_license_from_manifest_not_found() { let temp_dir = TempDir::new().unwrap(); let manifest_path = temp_dir.path().join("nonexistent.toml"); let result = get_license_from_manifest(&manifest_path); assert_eq!(result, None); } } feluda-1.11.1/src/license_cache.rs000064400000000000000000000076151046102023000150600ustar 00000000000000use crate::licenses::License; use std::collections::HashMap; use std::sync::{Arc, Mutex}; /// Thread-safe cache for GitHub licenses pub struct LicenseCache { licenses: Arc>>>, } impl LicenseCache { /// Create a new empty license cache pub fn new() -> Self { Self { licenses: Arc::new(Mutex::new(None)), } } /// TODO: Set the cached licenses. Will be used when implementing cache invalidation /// or manual cache clearing features for the API. #[allow(dead_code)] pub fn set(&self, licenses: HashMap) { if let Ok(mut cache) = self.licenses.lock() { *cache = Some(licenses); } } /// TODO: Get a reference to the cached licenses. Will be used for cache introspection /// features to allow users to query what's currently cached. #[allow(dead_code)] pub fn get(&self) -> Option> { if let Ok(cache) = self.licenses.lock() { cache.clone() } else { None } } /// TODO: Check if licenses are cached. Will be used to optimize repeated calls /// or implement lazy-loading patterns in future versions. #[allow(dead_code)] pub fn is_cached(&self) -> bool { if let Ok(cache) = self.licenses.lock() { cache.is_some() } else { false } } } impl Clone for LicenseCache { fn clone(&self) -> Self { Self { licenses: Arc::clone(&self.licenses), } } } impl Default for LicenseCache { fn default() -> Self { Self::new() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_license_cache_creation() { let cache = LicenseCache::new(); assert!(!cache.is_cached()); } #[test] fn test_license_cache_set_and_get() { let cache = LicenseCache::new(); let mut licenses = HashMap::new(); licenses.insert( "MIT".to_string(), License { key: "mit".to_string(), name: "MIT License".to_string(), spdx_id: "MIT".to_string(), url: "https://opensource.org/licenses/MIT".to_string(), html_url: "https://github.com/licenses/MIT".to_string(), description: "A short and simple permissive license with conditions only requiring preservation of copyright and license notices.".to_string(), implementation: "Add one or more copies of the notice, in text form, to any redistributed or derivative code.".to_string(), permissions: vec!["commercial-use".to_string()], conditions: vec!["include-copyright".to_string()], limitations: vec!["liability".to_string()], }, ); cache.set(licenses.clone()); assert!(cache.is_cached()); let retrieved = cache.get(); assert!(retrieved.is_some()); assert_eq!(retrieved.unwrap().len(), 1); } #[test] fn test_license_cache_clone() { let cache1 = LicenseCache::new(); let cache2 = cache1.clone(); let mut licenses = HashMap::new(); licenses.insert("MIT".to_string(), License { key: "mit".to_string(), name: "MIT License".to_string(), spdx_id: "MIT".to_string(), url: "https://opensource.org/licenses/MIT".to_string(), html_url: "https://github.com/licenses/MIT".to_string(), description: "A short and simple permissive license.".to_string(), implementation: "Add one or more copies of the notice.".to_string(), permissions: vec![], conditions: vec![], limitations: vec![], }); cache1.set(licenses); // Both caches should see the same data assert!(cache1.is_cached()); assert!(cache2.is_cached()); } } feluda-1.11.1/src/licenses.rs000064400000000000000000001366231046102023000141220ustar 00000000000000//! Core license analysis functionality and types use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; use std::fs; use std::path::Path; use std::sync::Arc; use std::sync::OnceLock; use std::time::Duration; use tokio::sync::Semaphore; use toml::Value as TomlValue; use crate::cache; use crate::cli; use crate::config; use crate::debug::{log, log_debug, log_error, FeludaResult, LogLevel}; static GITHUB_TOKEN: OnceLock> = OnceLock::new(); /// Set the GitHub API token for authenticated requests pub fn set_github_token(token: Option) { let _ = GITHUB_TOKEN.set(token); } /// Get the GitHub API token if set fn get_github_token() -> Option<&'static str> { GITHUB_TOKEN.get().and_then(|t| t.as_deref()) } /// License compatibility enum #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LicenseCompatibility { Compatible, Incompatible, Unknown, } impl std::fmt::Display for LicenseCompatibility { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Compatible => write!(f, "Compatible"), Self::Incompatible => write!(f, "Incompatible"), Self::Unknown => write!(f, "Unknown"), } } } /// Structure for deserializing license compatibility matrix from TOML #[derive(Deserialize, Debug, Clone)] struct LicenseCompatibilityMatrix { #[serde(rename = "MIT")] mit: Option, #[serde(rename = "Apache-2_0")] apache_2_0: Option, #[serde(rename = "GPL-3_0")] gpl_3_0: Option, #[serde(rename = "GPL-2_0")] gpl_2_0: Option, #[serde(rename = "AGPL-3_0")] agpl_3_0: Option, #[serde(rename = "LGPL-3_0")] lgpl_3_0: Option, #[serde(rename = "LGPL-2_1")] lgpl_2_1: Option, #[serde(rename = "MPL-2_0")] mpl_2_0: Option, #[serde(rename = "BSD-3-Clause")] bsd_3_clause: Option, #[serde(rename = "BSD-2-Clause")] bsd_2_clause: Option, #[serde(rename = "ISC")] isc: Option, #[serde(rename = "_0BSD")] bsd_0: Option, #[serde(rename = "Unlicense")] unlicense: Option, #[serde(rename = "WTFPL")] wtfpl: Option, } #[derive(Deserialize, Debug, Clone)] struct LicenseEntry { compatible_with: Vec, } /// Static cache for the compatibility matrix #[cfg(not(test))] static COMPATIBILITY_MATRIX: OnceLock>> = OnceLock::new(); /// OSI license status #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum OsiStatus { Approved, NotApproved, Unknown, } impl std::fmt::Display for OsiStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Approved => write!(f, "approved"), Self::NotApproved => write!(f, "not-approved"), Self::Unknown => write!(f, "unknown"), } } } /// OSI license information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OsiLicenseInfo { pub id: String, pub name: String, pub status: OsiStatus, } /// License Info of dependencies #[derive(Serialize, Debug, Clone)] pub struct LicenseInfo { pub name: String, // The name of the software or library pub version: String, // The version of the software or library pub license: Option, // An optional field that contains the license type (e.g., MIT, Apache 2.0) pub is_restrictive: bool, // A boolean indicating whether the license is restrictive or not pub compatibility: LicenseCompatibility, // Compatibility with project license pub osi_status: OsiStatus, // OSI approval status } impl LicenseInfo { pub fn get_license(&self) -> String { match &self.license { Some(license_name) => String::from(license_name), None => String::from("No License"), } } pub fn name(&self) -> &str { &self.name } pub fn version(&self) -> &str { &self.version } pub fn is_restrictive(&self) -> &bool { &self.is_restrictive } pub fn compatibility(&self) -> &LicenseCompatibility { &self.compatibility } pub fn osi_status(&self) -> &OsiStatus { &self.osi_status } #[allow(dead_code)] pub fn osi_info(&self) -> Option { self.license.as_ref().map(|license| OsiLicenseInfo { id: license.clone(), name: license.clone(), status: self.osi_status, }) } } /// License Info structure for GitHub API data #[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] pub struct License { pub title: String, // The full name of the license pub spdx_id: String, // The SPDX identifier for the license pub permissions: Vec, // A list of permissions granted by the license pub conditions: Vec, // A list of conditions that must be met under the license pub limitations: Vec, // A list of limitations imposed by the license } /// Fetch license data from GitHub's official Licenses API /// Attempts to load from cache first, falls back to GitHub API if cache miss or stale pub fn fetch_licenses_from_github() -> FeludaResult> { log(LogLevel::Info, "Fetching licenses from GitHub Licenses API"); match cache::load_github_licenses_from_cache() { Ok(Some(cached_licenses)) => { log( LogLevel::Info, &format!("Using cached licenses ({})", cached_licenses.len()), ); return Ok(cached_licenses); } Ok(None) => { log(LogLevel::Info, "Cache miss or stale, fetching from GitHub"); } Err(e) => { log( LogLevel::Warn, &format!("Cache read error: {e}, fetching from GitHub"), ); } } let licenses_map = cli::with_spinner("Fetching licenses from GitHub API", |indicator| { // Use tokio runtime for async operations let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(err) => { log_error("Failed to create tokio runtime", &err); return HashMap::new(); } }; rt.block_on(fetch_licenses_concurrent(indicator)) }); if !licenses_map.is_empty() { if let Err(e) = cache::save_github_licenses_to_cache(&licenses_map) { log(LogLevel::Warn, &format!("Failed to save cache: {e}")); } } else { log( LogLevel::Warn, "No licenses fetched from GitHub API, cache not saved", ); } Ok(licenses_map) } /// Async helper function for concurrent license fetching with rate limiting async fn fetch_licenses_concurrent( indicator: &crate::cli::LoadingIndicator, ) -> HashMap { let mut licenses_map = HashMap::new(); // Create async HTTP client with optional authentication let mut client_builder = reqwest::Client::builder() .user_agent("feluda-license-checker/1.0") .timeout(Duration::from_secs(30)); if let Some(token) = get_github_token() { log( LogLevel::Info, "Using authenticated GitHub API requests (higher rate limits)", ); let mut headers = reqwest::header::HeaderMap::new(); headers.insert( reqwest::header::AUTHORIZATION, format!("Bearer {token}") .parse() .expect("Invalid token format"), ); client_builder = client_builder.default_headers(headers); } let client = match client_builder.build() { Ok(client) => client, Err(err) => { log_error("Failed to create HTTP client", &err); return licenses_map; } }; indicator.update_progress("fetching license list"); // First, get the list of available licenses let licenses_list_url = "https://api.github.com/licenses"; let response = match client.get(licenses_list_url).send().await { Ok(response) => response, Err(err) => { log_error("Failed to fetch licenses list from GitHub API", &err); return licenses_map; } }; if !response.status().is_success() { log( LogLevel::Error, &format!("GitHub API returned error status: {}", response.status()), ); return licenses_map; } let licenses_list: Vec = match response.json().await { Ok(list) => list, Err(err) => { log_error("Failed to parse licenses list JSON", &err); return licenses_map; } }; let total_licenses = licenses_list.len(); indicator.update_progress(&format!("found {total_licenses} licenses")); // Rate limiting: Allow max 10 concurrent requests (GitHub's recommended limit) let semaphore = Arc::new(Semaphore::new(10)); let client = Arc::new(client); // Collect all license keys let license_keys: Vec = licenses_list .iter() .filter_map(|license_info| { license_info .get("key") .and_then(|k| k.as_str()) .map(|s| s.to_string()) }) .collect(); // Create futures for concurrent processing let mut tasks = Vec::new(); for license_key in license_keys { let semaphore = Arc::clone(&semaphore); let client = Arc::clone(&client); let task = tokio::spawn(async move { // Acquire semaphore permit for rate limiting let _permit = semaphore.acquire().await.unwrap(); log( LogLevel::Info, &format!("Fetching detailed license info: {license_key}"), ); let license_url = format!("https://api.github.com/licenses/{license_key}"); // Add delay for rate limiting (reduced from 100ms since we have concurrency control) tokio::time::sleep(Duration::from_millis(50)).await; match client.get(&license_url).send().await { Ok(license_response) => { if license_response.status().is_success() { match license_response.json::().await { Ok(license_data) => { // Extract the license information we need let title = license_data .get("name") .and_then(|n| n.as_str()) .unwrap_or(&license_key) .to_string(); let spdx_id = license_data .get("spdx_id") .and_then(|s| s.as_str()) .unwrap_or(&license_key) .to_string(); let permissions = license_data .get("permissions") .and_then(|p| p.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(String::from) .collect() }) .unwrap_or_default(); let conditions = license_data .get("conditions") .and_then(|c| c.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(String::from) .collect() }) .unwrap_or_default(); let limitations = license_data .get("limitations") .and_then(|l| l.as_array()) .map(|arr| { arr.iter() .filter_map(|v| v.as_str()) .map(String::from) .collect() }) .unwrap_or_default(); let license = License { title, spdx_id, permissions, conditions, limitations, }; // Use the SPDX ID as the key for consistency let key_to_use = license_data .get("spdx_id") .and_then(|s| s.as_str()) .unwrap_or(&license_key); log( LogLevel::Info, &format!("Successfully processed license: {key_to_use}"), ); Some((key_to_use.to_string(), license)) } Err(err) => { log_error( &format!("Failed to parse license JSON for {license_key}"), &err, ); None } } } else { log( LogLevel::Error, &format!( "Failed to fetch license {}: HTTP {}", license_key, license_response.status() ), ); None } } Err(err) => { log_error( &format!("Failed to fetch license details for {license_key}"), &err, ); None } } }); tasks.push(task); } // Wait for all tasks to complete and collect results let mut license_count = 0; for (i, task) in tasks.into_iter().enumerate() { indicator.update_progress(&format!( "processing {}/{}: concurrent requests", i + 1, total_licenses, )); if let Ok(Some((key, license))) = task.await { licenses_map.insert(key, license); license_count += 1; } } indicator.update_progress(&format!("processed {license_count} licenses")); log( LogLevel::Info, &format!("Successfully fetched {license_count} licenses from GitHub API using concurrent requests"), ); licenses_map } /// Static cache for OSI approved licenses #[cfg(not(test))] static OSI_LICENSES: OnceLock> = OnceLock::new(); /// Fetch OSI approved licenses from official API pub fn fetch_osi_licenses() -> FeludaResult> { log(LogLevel::Info, "Fetching OSI approved licenses"); let osi_map = cli::with_spinner("Fetching OSI approved licenses", |indicator| { // Use tokio runtime for async operations let rt = match tokio::runtime::Runtime::new() { Ok(rt) => rt, Err(err) => { log_error("Failed to create tokio runtime", &err); return HashMap::new(); } }; rt.block_on(fetch_osi_licenses_async(indicator)) }); Ok(osi_map) } /// Async helper function for fetching OSI licenses async fn fetch_osi_licenses_async( indicator: &crate::cli::LoadingIndicator, ) -> HashMap { let mut osi_map = HashMap::new(); // Create async HTTP client let client = match reqwest::Client::builder() .user_agent("feluda-license-checker/1.0") .timeout(Duration::from_secs(30)) .build() { Ok(client) => client, Err(err) => { log_error("Failed to create HTTP client", &err); return osi_map; } }; indicator.update_progress("fetching OSI licenses"); let osi_api_url = "https://api.opensource.org/licenses/"; let response = match client.get(osi_api_url).send().await { Ok(response) => response, Err(err) => { log_error("Failed to fetch OSI licenses from API", &err); return osi_map; } }; if !response.status().is_success() { log( LogLevel::Error, &format!("OSI API returned error status: {}", response.status()), ); return osi_map; } let osi_licenses: Vec = match response.json().await { Ok(licenses) => licenses, Err(err) => { log_error("Failed to parse OSI licenses JSON", &err); return osi_map; } }; let total_licenses = osi_licenses.len(); indicator.update_progress(&format!("found {total_licenses} OSI licenses")); for license_data in osi_licenses { if let Some(id) = license_data.get("id").and_then(|id| id.as_str()) { // All licenses from OSI API are approved osi_map.insert(id.to_string(), OsiStatus::Approved); } } indicator.update_progress(&format!("processed {total_licenses} OSI licenses")); log( LogLevel::Info, &format!("Successfully fetched {total_licenses} OSI approved licenses"), ); osi_map } /// Get the OSI licenses map, loading it if not already cached fn get_osi_licenses() -> &'static HashMap { #[cfg(not(test))] { OSI_LICENSES.get_or_init(|| { fetch_osi_licenses().unwrap_or_else(|e| { log(LogLevel::Warn, &format!("Failed to load OSI licenses: {e}")); log(LogLevel::Warn, "Continuing without OSI license information"); HashMap::new() }) }) } #[cfg(test)] { use std::cell::RefCell; thread_local! { static OSI_MAP: RefCell>> = const { RefCell::new(None) }; } OSI_MAP.with(|m| { let mut map = m.borrow_mut(); if map.is_none() { match fetch_osi_licenses() { Ok(loaded_map) => { *map = Some(loaded_map); } Err(_) => { *map = Some(HashMap::new()); } } } // Leak the memory to get a static reference (only for tests) let leaked: &'static HashMap = Box::leak(Box::new(map.as_ref().unwrap().clone())); leaked }) } } /// Check OSI approval status for a license pub fn get_osi_status(license_id: &str) -> OsiStatus { let normalized_id = normalize_license_id(license_id); let osi_licenses = get_osi_licenses(); // Check for exact match first if let Some(status) = osi_licenses.get(&normalized_id) { return *status; } // Check for original license ID if let Some(status) = osi_licenses.get(license_id) { return *status; } // For well-known licenses, we can provide static mappings as fallback match normalized_id.as_str() { "MIT" | "Apache-2.0" | "BSD-3-Clause" | "BSD-2-Clause" | "GPL-3.0" | "GPL-2.0" | "LGPL-3.0" | "LGPL-2.1" | "MPL-2.0" | "ISC" | "0BSD" => OsiStatus::Approved, "No License" => OsiStatus::NotApproved, _ => OsiStatus::Unknown, } } /// Check if a license is considered restrictive based on configuration and known licenses pub fn is_license_restrictive( license: &Option, known_licenses: &HashMap, strict: bool, ) -> bool { log( LogLevel::Info, &format!("Checking if license is restrictive: {license:?} (strict={strict})"), ); let config = match config::load_config() { Ok(cfg) => { log(LogLevel::Info, "Successfully loaded configuration"); cfg } Err(e) => { log_error("Error loading configuration", &e); log(LogLevel::Warn, "Using default configuration"); config::FeludaConfig::default() } }; if license.as_deref() == Some("No License") { log( LogLevel::Warn, "No license specified, considering as restrictive", ); return true; } if let Some(license_str) = license { log_debug( "Checking against known licenses", &known_licenses.keys().collect::>(), ); if let Some(license_data) = known_licenses.get(license_str) { log_debug("Found license data", license_data); let conditions = if strict { vec![ "source-disclosure", "network-use-disclosure", "disclose-source", "same-license", ] } else { vec!["source-disclosure", "network-use-disclosure"] }; let is_restrictive = conditions .iter() .any(|&condition| license_data.conditions.contains(&condition.to_string())); if is_restrictive { log( LogLevel::Warn, &format!("License {license_str} is restrictive due to conditions"), ); } else { log( LogLevel::Info, &format!("License {license_str} is not restrictive"), ); } return is_restrictive; } else { let is_restrictive = config .licenses .restrictive .iter() .any(|restrictive_license| license_str.contains(restrictive_license)); if is_restrictive { log( LogLevel::Warn, &format!("License {license_str} matches restrictive pattern in config"), ); } else if strict && license_str.contains("Unknown") { log( LogLevel::Warn, &format!( "License {license_str} is unknown in strict mode, considering restrictive" ), ); return true; } else { log( LogLevel::Info, &format!("License {license_str} does not match any restrictive pattern"), ); } return is_restrictive; } } if strict { log( LogLevel::Warn, "No license information available in strict mode, considering restrictive", ); return true; } log(LogLevel::Warn, "No license information available"); false } /// Check if a license should be ignored from analysis /// /// Returns true if the license is in the ignore list configured in `.feluda.toml` /// or via `FELUDA_LICENSES_IGNORE` environment variable. pub fn is_license_ignored(license: Option<&str>) -> bool { log( LogLevel::Info, &format!("Checking if license should be ignored: {license:?}"), ); let config = match config::load_config() { Ok(cfg) => { log(LogLevel::Info, "Successfully loaded configuration"); cfg } Err(e) => { log_error("Error loading configuration", &e); log(LogLevel::Warn, "Using default configuration"); config::FeludaConfig::default() } }; if let Some(license_str) = license { let is_ignored = config .licenses .ignore .iter() .any(|ignore_license| license_str.contains(ignore_license)); if is_ignored { log( LogLevel::Info, &format!("License {license_str} matches ignore pattern in config"), ); } else { log( LogLevel::Info, &format!("License {license_str} does not match any ignore pattern"), ); } return is_ignored; } log(LogLevel::Info, "No license specified, not ignoring"); false } /// This is the default configuration const EMBEDDED_LICENSE_COMPATIBILITY_TOML: &str = include_str!("../config/license_compatibility.toml"); /// Load license compatibility matrix from external TOML file if available /// Looks for the file in the following order: /// 1. .feluda/license_compatibility.toml (user-specific config directory) /// 2. Embedded configuration fn load_compatibility_matrix() -> FeludaResult>> { log( LogLevel::Info, "Loading license compatibility matrix from TOML file", ); // Only check for user-specific config in .feluda directory let config_paths = vec![Path::new(".feluda/license_compatibility.toml").to_path_buf()]; let mut config_content = None; let mut used_path = None; for path in &config_paths { if path.exists() { log( LogLevel::Info, &format!("Found license compatibility config at: {}", path.display()), ); match fs::read_to_string(path) { Ok(content) => { config_content = Some(content); used_path = Some(path); break; } Err(e) => { log( LogLevel::Warn, &format!("Failed to read {}: {}", path.display(), e), ); continue; } } } } // Use embedded configuration as fallback if no external file is found let config_content = match config_content { Some(content) => content, None => { log( LogLevel::Info, "No external license compatibility config found, using embedded configuration", ); EMBEDDED_LICENSE_COMPATIBILITY_TOML.to_string() } }; let matrix: LicenseCompatibilityMatrix = toml::from_str(&config_content).map_err(|e| { let source = match &used_path { Some(path) => format!("external config file ({})", path.display()), None => "embedded configuration".to_string(), }; log( LogLevel::Error, &format!("Failed to parse license compatibility {source}: {e}"), ); std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()) })?; // Convert TOML structure to HashMap let entries = [ ("MIT", &matrix.mit), ("Apache-2.0", &matrix.apache_2_0), ("GPL-3.0", &matrix.gpl_3_0), ("GPL-2.0", &matrix.gpl_2_0), ("AGPL-3.0", &matrix.agpl_3_0), ("LGPL-3.0", &matrix.lgpl_3_0), ("LGPL-2.1", &matrix.lgpl_2_1), ("MPL-2.0", &matrix.mpl_2_0), ("BSD-3-Clause", &matrix.bsd_3_clause), ("BSD-2-Clause", &matrix.bsd_2_clause), ("ISC", &matrix.isc), ("0BSD", &matrix.bsd_0), ("Unlicense", &matrix.unlicense), ("WTFPL", &matrix.wtfpl), ]; let result: HashMap> = entries .iter() .filter_map(|(key, option_entry)| { option_entry .as_ref() .map(|entry| (key.to_string(), entry.compatible_with.clone())) }) .collect(); log( LogLevel::Info, &format!("Loaded {} license compatibility entries", result.len()), ); Ok(result) } /// Get the compatibility matrix, loading it if not already cached fn get_compatibility_matrix() -> &'static HashMap> { #[cfg(not(test))] { COMPATIBILITY_MATRIX.get_or_init(|| { load_compatibility_matrix().unwrap_or_else(|e| { log(LogLevel::Error, &format!("Failed to load license compatibility matrix: {e}")); log(LogLevel::Error, "This is a critical error. The application cannot function without license compatibility data."); std::process::exit(1); }) }) } #[cfg(test)] { // For tests, use a thread-local storage to avoid the OnceLock static initialization issue use std::cell::RefCell; thread_local! { static MATRIX: RefCell>>> = const { RefCell::new(None) }; } // This is a hack to return a static reference from thread-local storage // We leak the memory in tests, which is acceptable for testing MATRIX.with(|m| { let mut matrix = m.borrow_mut(); if matrix.is_none() { match load_compatibility_matrix() { Ok(loaded_matrix) => { *matrix = Some(loaded_matrix); } Err(e) => { panic!( "License compatibility configuration file not found during testing: {e}" ); } } } // Leak the memory to get a static reference (only for tests) let leaked: &'static HashMap> = Box::leak(Box::new(matrix.as_ref().unwrap().clone())); leaked }) } } /// Check if a license is compatible with the base project license pub fn is_license_compatible( dependency_license: &str, project_license: &str, strict: bool, ) -> LicenseCompatibility { log( LogLevel::Info, &format!( "Checking if dependency license {dependency_license} is compatible with project license {project_license} (strict={strict})" ), ); let compatibility_matrix = get_compatibility_matrix(); let norm_dependency_license = normalize_license_id(dependency_license); let norm_project_license = normalize_license_id(project_license); log( LogLevel::Info, &format!( "Normalized licenses: dependency={norm_dependency_license}, project={norm_project_license}" ), ); match compatibility_matrix.get(&norm_project_license) { Some(compatible_licenses) => { if compatible_licenses.contains(&norm_dependency_license) { log( LogLevel::Info, &format!( "License {norm_dependency_license} is compatible with project license {norm_project_license}" ), ); LicenseCompatibility::Compatible } else { log( LogLevel::Warn, &format!( "License {norm_dependency_license} may be incompatible with project license {norm_project_license}" ), ); LicenseCompatibility::Incompatible } } None => { if strict { log( LogLevel::Warn, &format!("Unknown compatibility for project license {norm_project_license} in strict mode, marking as incompatible"), ); LicenseCompatibility::Incompatible } else { log( LogLevel::Warn, &format!("Unknown compatibility for project license {norm_project_license}"), ); LicenseCompatibility::Unknown } } } } /// Normalize license identifier to a standard format fn normalize_license_id(license_id: &str) -> String { let trimmed = license_id.trim().to_uppercase(); // Handle common variations and aliases match trimmed.as_str() { "MIT" | "MIT LICENSE" => "MIT".to_string(), "ISC" | "ISC LICENSE" => "ISC".to_string(), "0BSD" | "BSD-ZERO-CLAUSE" | "BSD ZERO CLAUSE" => "0BSD".to_string(), "UNLICENSE" | "THE UNLICENSE" => "Unlicense".to_string(), "WTFPL" | "DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE" => "WTFPL".to_string(), "ZLIB" | "ZLIB LICENSE" => "Zlib".to_string(), "CC0" | "CC0-1.0" | "CC0 1.0" | "CREATIVE COMMONS ZERO" => "CC0-1.0".to_string(), id if id.contains("APACHE") && (id.contains("2.0") || id.contains("2")) => { "Apache-2.0".to_string() } id if id.contains("AGPL") && id.contains("3") => "AGPL-3.0".to_string(), id if id.contains("AFFERO") && id.contains("GPL") && id.contains("3") => { "AGPL-3.0".to_string() } id if id.contains("GPL") && id.contains("3") && !id.contains("LGPL") => { "GPL-3.0".to_string() } id if id.contains("GPL") && id.contains("2") && !id.contains("LGPL") => { "GPL-2.0".to_string() } id if id.contains("LGPL") && id.contains("3") => "LGPL-3.0".to_string(), id if id.contains("LGPL") && id.contains("2.1") => "LGPL-2.1".to_string(), id if id.contains("LGPL") && id.contains("2") && !id.contains("2.1") => { "LGPL-2.1".to_string() } id if id.contains("MPL") && id.contains("2.0") => "MPL-2.0".to_string(), id if id.contains("BSD") && (id.contains("3") || id.contains("THREE")) => { "BSD-3-Clause".to_string() } id if id.contains("BSD") && (id.contains("2") || id.contains("TWO")) => { "BSD-2-Clause".to_string() } _ => license_id.to_string(), } } /// Detect the project's license pub fn detect_project_license(project_path: &str) -> FeludaResult> { log( LogLevel::Info, &format!("Detecting license for project at path: {project_path}"), ); // Check LICENSE file let license_paths = [ Path::new(project_path).join("LICENSE"), Path::new(project_path).join("LICENSE.txt"), Path::new(project_path).join("LICENSE.md"), Path::new(project_path).join("license"), Path::new(project_path).join("COPYING"), ]; for license_path in &license_paths { if license_path.exists() { log( LogLevel::Info, &format!("Found license file: {}", license_path.display()), ); match fs::read_to_string(license_path) { Ok(content) => { // Check for MIT license if content.contains("MIT License") || content.contains("Permission is hereby granted, free of charge") { log(LogLevel::Info, "Detected MIT license"); return Ok(Some("MIT".to_string())); } // Check for GPL-3.0 if content.contains("GNU GENERAL PUBLIC LICENSE") && content.contains("Version 3") { log(LogLevel::Info, "Detected GPL-3.0 license"); return Ok(Some("GPL-3.0".to_string())); } // Check for Apache-2.0 if content.contains("Apache License") && content.contains("Version 2.0") { log(LogLevel::Info, "Detected Apache-2.0 license"); return Ok(Some("Apache-2.0".to_string())); } // Check for BSD-3-Clause if content.contains("BSD") && content.contains("Redistribution and use") && content.contains("Neither the name") { log(LogLevel::Info, "Detected BSD-3-Clause license"); return Ok(Some("BSD-3-Clause".to_string())); } // Check for LGPL-3.0 if content.contains("GNU LESSER GENERAL PUBLIC LICENSE") && content.contains("Version 3") { log(LogLevel::Info, "Detected LGPL-3.0 license"); return Ok(Some("LGPL-3.0".to_string())); } // Check for MPL-2.0 if content.contains("Mozilla Public License") && content.contains("Version 2.0") { log(LogLevel::Info, "Detected MPL-2.0 license"); return Ok(Some("MPL-2.0".to_string())); } log( LogLevel::Warn, "License file found but could not determine license type", ); } Err(err) => { log( LogLevel::Error, &format!("Failed to read license file: {}", license_path.display()), ); log_debug("Error details", &err); } } } } // Check package.json for Node.js projects let package_json_path = Path::new(project_path).join("package.json"); if package_json_path.exists() { log( LogLevel::Info, &format!("Found package.json at {}", package_json_path.display()), ); match fs::read_to_string(&package_json_path) { Ok(content) => match serde_json::from_str::(&content) { Ok(json) => { if let Some(license) = json.get("license").and_then(|l| l.as_str()) { log( LogLevel::Info, &format!("Detected license from package.json: {license}"), ); return Ok(Some(license.to_string())); } } Err(err) => { log( LogLevel::Error, &format!("Failed to parse package.json: {err}"), ); } }, Err(err) => { log( LogLevel::Error, &format!( "Failed to read package.json: {}", package_json_path.display() ), ); log_debug("Error details", &err); } } } // Check Cargo.toml for Rust projects let cargo_toml_path = Path::new(project_path).join("Cargo.toml"); if cargo_toml_path.exists() { log( LogLevel::Info, &format!("Found Cargo.toml at {}", cargo_toml_path.display()), ); match fs::read_to_string(&cargo_toml_path) { Ok(content) => match toml::from_str::(&content) { Ok(toml) => { if let Some(package) = toml.as_table().and_then(|t| t.get("package")) { if let Some(license) = package.get("license").and_then(|l| l.as_str()) { log( LogLevel::Info, &format!("Detected license from Cargo.toml: {license}"), ); return Ok(Some(license.to_string())); } } } Err(err) => { log( LogLevel::Error, &format!("Failed to parse Cargo.toml: {err}"), ); } }, Err(err) => { log( LogLevel::Error, &format!("Failed to read Cargo.toml: {}", cargo_toml_path.display()), ); log_debug("Error details", &err); } } } // Check pyproject.toml for Python projects let pyproject_toml_path = Path::new(project_path).join("pyproject.toml"); if pyproject_toml_path.exists() { log( LogLevel::Info, &format!("Found pyproject.toml at {}", pyproject_toml_path.display()), ); match fs::read_to_string(&pyproject_toml_path) { Ok(content) => match toml::from_str::(&content) { Ok(toml) => { if let Some(project) = toml.as_table().and_then(|t| t.get("project")) { if let Some(license_info) = project.get("license") { if let Some(license) = license_info.as_str() { log( LogLevel::Info, &format!("Detected license from pyproject.toml: {license}"), ); return Ok(Some(license.to_string())); } else if let Some(license_table) = license_info.as_table() { if let Some(license_text) = license_table.get("text").and_then(|t| t.as_str()) { log( LogLevel::Info, &format!( "Detected license from pyproject.toml: {license_text}" ), ); return Ok(Some(license_text.to_string())); } } } } } Err(err) => { log( LogLevel::Error, &format!("Failed to parse pyproject.toml: {err}"), ); } }, Err(err) => { log( LogLevel::Error, &format!( "Failed to read pyproject.toml: {}", pyproject_toml_path.display() ), ); log_debug("Error details", &err); } } } log(LogLevel::Warn, "No license detected for project"); Ok(None) } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn test_license_compatibility_display() { assert_eq!(LicenseCompatibility::Compatible.to_string(), "Compatible"); assert_eq!( LicenseCompatibility::Incompatible.to_string(), "Incompatible" ); assert_eq!(LicenseCompatibility::Unknown.to_string(), "Unknown"); } #[test] fn test_license_info_methods() { let info = LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: OsiStatus::Approved, }; assert_eq!(info.name(), "test_package"); assert_eq!(info.version(), "1.0.0"); assert_eq!(info.get_license(), "MIT"); assert!(!info.is_restrictive()); assert_eq!(info.compatibility(), &LicenseCompatibility::Compatible); } #[test] fn test_license_info_no_license() { let info = LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: None, is_restrictive: true, compatibility: LicenseCompatibility::Unknown, osi_status: OsiStatus::Unknown, }; assert_eq!(info.get_license(), "No License"); } #[test] fn test_normalize_license_id() { assert_eq!(normalize_license_id("MIT"), "MIT"); assert_eq!(normalize_license_id("mit"), "MIT"); assert_eq!(normalize_license_id("Apache 2.0"), "Apache-2.0"); assert_eq!(normalize_license_id("APACHE-2.0"), "Apache-2.0"); assert_eq!(normalize_license_id("GPL 3.0"), "GPL-3.0"); assert_eq!(normalize_license_id("gpl-3.0"), "GPL-3.0"); assert_eq!(normalize_license_id("LGPL 3.0"), "LGPL-3.0"); assert_eq!(normalize_license_id("MPL 2.0"), "MPL-2.0"); assert_eq!(normalize_license_id("BSD 3-Clause"), "BSD-3-Clause"); assert_eq!(normalize_license_id("BSD 2-Clause"), "BSD-2-Clause"); assert_eq!(normalize_license_id("Unknown License"), "Unknown License"); assert_eq!(normalize_license_id(" MIT "), "MIT"); } #[test] #[ignore] // Skip this test due to static initialization issues in test runner fn test_is_license_compatible_mit_project() { assert_eq!( is_license_compatible("MIT", "MIT", false), LicenseCompatibility::Compatible ); assert_eq!( is_license_compatible("BSD-2-Clause", "MIT", false), LicenseCompatibility::Compatible ); assert_eq!( is_license_compatible("BSD-3-Clause", "MIT", false), LicenseCompatibility::Compatible ); assert_eq!( is_license_compatible("Apache-2.0", "MIT", false), LicenseCompatibility::Compatible ); assert_eq!( is_license_compatible("LGPL-3.0", "MIT", false), LicenseCompatibility::Incompatible ); assert_eq!( is_license_compatible("MPL-2.0", "MIT", false), LicenseCompatibility::Incompatible ); assert_eq!( is_license_compatible("GPL-3.0", "MIT", false), LicenseCompatibility::Incompatible ); } #[test] fn test_detect_project_license_mit_file() { let temp_dir = TempDir::new().unwrap(); let license_path = temp_dir.path().join("LICENSE"); std::fs::write( &license_path, "MIT License\n\nPermission is hereby granted, free of charge...", ) .unwrap(); let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap(); assert_eq!(result, Some("MIT".to_string())); } #[test] fn test_detect_project_license_no_license() { let temp_dir = TempDir::new().unwrap(); let result = detect_project_license(temp_dir.path().to_str().unwrap()).unwrap(); assert_eq!(result, None); } #[test] fn test_is_license_ignored_with_no_license() { // Should return false when no license is provided assert!(!is_license_ignored(None)); } #[test] fn test_is_license_ignored_not_in_ignore_list() { // License not in ignore list should return false // This test assumes no ignore list is configured let result = is_license_ignored(Some("GPL-3.0")); // Since we can't easily mock the config in this context, // we just verify it doesn't panic let _ = result; } #[test] fn test_is_license_ignored_empty_license() { // Empty string should return false assert!(!is_license_ignored(Some(""))); } } feluda-1.11.1/src/main.rs000064400000000000000000000407071046102023000132360ustar 00000000000000mod cache; mod cli; mod config; mod debug; mod generate; mod languages; mod licenses; mod parser; mod reporter; mod sbom; mod table; mod utils; use clap::Parser; use cli::{print_version_info, Cli, Commands}; use debug::{log, log_debug, set_debug_mode, FeludaError, FeludaResult, LogLevel}; use generate::handle_generate_command; use licenses::{ detect_project_license, is_license_compatible, set_github_token, LicenseCompatibility, }; use parser::parse_root; use reporter::{generate_report, ReportConfig}; use sbom::handle_sbom_command; use sbom::validate::handle_sbom_validate_command; use std::env; use std::path::Path; use std::process; use table::App; use tempfile::TempDir; use utils::clone_repository; /// Configuration for the check command #[derive(Debug)] struct CheckConfig { path: String, json: bool, yaml: bool, verbose: bool, restrictive: bool, gui: bool, language: Option, ci_format: Option, output_file: Option, fail_on_restrictive: bool, incompatible: bool, fail_on_incompatible: bool, project_license: Option, gist: bool, osi: Option, strict: bool, no_local: bool, } fn main() { // Check if --version or -V is passed alone let args: Vec = env::args().collect(); if args.len() == 2 && (args[1] == "--version" || args[1] == "-V") { print_version_info(); return; } match run() { Ok(_) => {} Err(e) => { e.log(); process::exit(1); } } } fn run() -> FeludaResult<()> { let args = Cli::parse(); // Debug mode if args.debug { set_debug_mode(true); log( LogLevel::Info, &format!("Starting Feluda with args: {args:?}"), ); } // Set GitHub API token for authenticated requests set_github_token(args.github_token.clone()); // Handle repository cloning if --repo is provided let (analysis_path, _temp_dir) = match &args.repo.clone() { Some(repo_url) => { log( LogLevel::Info, &format!("Attempting to clone repository: {repo_url}"), ); let temp_dir = TempDir::new().map_err(|e| { FeludaError::TempDir(format!("Failed to create temporary directory: {e}")) })?; let repo_path = temp_dir.path(); // Clone the repository if let Err(e) = clone_repository(&args, repo_path) { log(LogLevel::Error, &format!("Repository cloning failed: {e}")); return Err(e); } log( LogLevel::Info, &format!("Repository cloned to: {}", repo_path.display()), ); (repo_path.to_path_buf(), Some(temp_dir)) } None => { let path = Path::new(&args.path).to_path_buf(); log( LogLevel::Info, &format!("Using local path for analysis: {}", path.display()), ); (path, None) } }; log( LogLevel::Info, &format!("Analysing project at: {}", analysis_path.display()), ); // Handle the command based on whether a subcommand was provided if args.is_default_command() { // Default behavior: license analysis let config = CheckConfig { path: analysis_path.to_string_lossy().to_string(), json: args.json, yaml: args.yaml, verbose: args.verbose, restrictive: args.restrictive, gui: args.gui, language: args.language, ci_format: args.ci_format, output_file: args.output_file, fail_on_restrictive: args.fail_on_restrictive, incompatible: args.incompatible, fail_on_incompatible: args.fail_on_incompatible, project_license: args.project_license, gist: args.gist, osi: args.osi, strict: args.strict, no_local: args.no_local, }; handle_check_command(config) } else { // Handle subcommands let command = args.get_command_args(); match command { Commands::Generate { path, language, project_license, } => { handle_generate_command(path, language, project_license); Ok(()) } Commands::Sbom { path, format, output, } => { // Determine which format to use match format { Some(cli::SbomCommand::Spdx { path: fmt_path, output: fmt_output, }) => { // Use the subcommand path/output if provided, otherwise use the parent command's let final_path = if fmt_path != "./" { fmt_path } else { path.clone() }; let final_output = fmt_output.or(output.clone()); handle_sbom_command(final_path, &cli::SbomFormat::Spdx, final_output) } Some(cli::SbomCommand::Cyclonedx { path: fmt_path, output: fmt_output, }) => { let final_path = if fmt_path != "./" { fmt_path } else { path.clone() }; let final_output = fmt_output.or(output.clone()); handle_sbom_command(final_path, &cli::SbomFormat::Cyclonedx, final_output) } Some(cli::SbomCommand::Validate { sbom_file, output: validation_output, json, }) => handle_sbom_validate_command(sbom_file, validation_output, json), None => { // Default: generate both formats handle_sbom_command(path, &cli::SbomFormat::All, output) } } } Commands::Cache { clear } => { handle_cache_command(clear)?; Ok(()) } } } } fn handle_check_command(config: CheckConfig) -> FeludaResult<()> { log( LogLevel::Info, &format!("Executing check command with path: {}", config.path), ); // Parse project dependencies log( LogLevel::Info, &format!("Parsing dependencies in path: {}", config.path), ); let mut project_license = config.project_license; // If no project license is provided via CLI, try to detect it if let Some(ref license) = project_license { log( LogLevel::Info, &format!("Using provided project license: {}", *license), ); } else { log( LogLevel::Info, "No project license specified, attempting to detect", ); match detect_project_license(&config.path) { Ok(Some(detected)) => { log( LogLevel::Info, &format!("Detected project license: {detected}"), ); project_license = Some(detected); } Ok(None) => { log(LogLevel::Warn, "Could not detect project license"); } Err(e) => { log( LogLevel::Error, &format!("Error detecting project license: {e}"), ); } } } // Parse and analyze dependencies let mut analyzed_data = parse_root( &config.path, config.language.as_deref(), config.strict, config.no_local, ) .map_err(|e| FeludaError::Parser(format!("Failed to parse dependencies: {e}")))?; log_debug("Analyzed dependencies", &analyzed_data); if analyzed_data.is_empty() { log(LogLevel::Warn, "No dependencies found to analyze. Exiting."); return Ok(()); } // Update each dependency with compatibility information if project license is known if let Some(ref proj_license) = project_license { log( LogLevel::Info, &format!("Checking license compatibility against project license: {proj_license}"), ); for info in &mut analyzed_data { if let Some(ref dep_license) = info.license { info.compatibility = is_license_compatible(dep_license, proj_license, config.strict); log( LogLevel::Info, &format!( "License compatibility for {} ({}): {:?}", info.name, dep_license, info.compatibility ), ); } else { info.compatibility = if config.strict { LicenseCompatibility::Incompatible } else { LicenseCompatibility::Unknown }; log( LogLevel::Info, &format!( "License compatibility for {} {} (no license info)", info.name, if config.strict { "incompatible" } else { "unknown" } ), ); } } } else { // If no project license is known, mark all as unknown compatibility log( LogLevel::Warn, "No project license specified or detected, marking all dependencies as unknown compatibility", ); for info in &mut analyzed_data { info.compatibility = LicenseCompatibility::Unknown; } } // Either run the GUI or generate a report if config.gui { let original_count = analyzed_data.len(); // Filter for restrictive and incompatible if config.restrictive || config.incompatible { if project_license.is_some() { log( LogLevel::Info, "Restrictive and incompatible mode enabled, filtering for restrictive and incompatible licenses", ); analyzed_data.retain(|info| { (config.restrictive && *info.is_restrictive()) || (config.incompatible && info.compatibility == LicenseCompatibility::Incompatible) }); log( LogLevel::Info, &format!( "Filtered for restrictive and incompatible licenses: {} of {} dependencies", analyzed_data.len(), original_count ), ); } else { log( LogLevel::Warn, "Incompatible mode enabled but no project license specified, cannot filter for incompatible licenses", ); } } else if config.restrictive { // Filter for restrictive log( LogLevel::Info, "Restrictive mode enabled, filtering for restrictive licenses", ); analyzed_data.retain(|info| *info.is_restrictive()); log( LogLevel::Info, &format!( "Filtered for restrictive licenses: {} of {} dependencies", analyzed_data.len(), original_count ), ); } else if config.incompatible { // Filter for incompatible if requested if project_license.is_some() { log( LogLevel::Info, "Incompatible mode enabled, filtering for incompatible licenses", ); analyzed_data .retain(|info| info.compatibility == LicenseCompatibility::Incompatible); log( LogLevel::Info, &format!( "Filtered for incompatible licenses: {} of {} dependencies", analyzed_data.len(), original_count ), ); } else { log( LogLevel::Warn, "Incompatible mode enabled but no project license specified, cannot filter for incompatible licenses", ); } } // Apply OSI filtering if let Some(osi_filter) = &config.osi { let before_count = analyzed_data.len(); match osi_filter { cli::OsiFilter::Approved => { analyzed_data.retain(|info| info.osi_status == licenses::OsiStatus::Approved); log( LogLevel::Info, &format!( "Filtered for OSI approved licenses: {} of {} dependencies", analyzed_data.len(), before_count ), ); } cli::OsiFilter::NotApproved => { analyzed_data .retain(|info| info.osi_status == licenses::OsiStatus::NotApproved); log( LogLevel::Info, &format!( "Filtered for non-OSI approved licenses: {} of {} dependencies", analyzed_data.len(), before_count ), ); } cli::OsiFilter::Unknown => { analyzed_data.retain(|info| info.osi_status == licenses::OsiStatus::Unknown); log( LogLevel::Info, &format!( "Filtered for unknown OSI status licenses: {} of {} dependencies", analyzed_data.len(), before_count ), ); } } } log(LogLevel::Info, "Starting TUI mode"); // Initialize the terminal color_eyre::install() .map_err(|e| FeludaError::TuiInit(format!("Failed to initialize color_eyre: {e}")))?; let terminal = ratatui::init(); log(LogLevel::Info, "Terminal initialized for TUI"); // TUI app with project license info let app_result = App::new(analyzed_data, project_license).run(terminal); ratatui::restore(); // Handle any errors from the TUI app_result.map_err(|e| FeludaError::TuiRuntime(format!("TUI error: {e}")))?; log(LogLevel::Info, "TUI session completed successfully"); } else { log(LogLevel::Info, "Generating dependency report"); // Create ReportConfig from CLI arguments let report_config = ReportConfig::new( config.json, config.yaml, config.verbose, config.restrictive, config.incompatible, config.ci_format, config.output_file, project_license, config.gist, config.osi, ); // Generate a report based on the analyzed data let (has_restrictive, has_incompatible) = generate_report(analyzed_data, report_config); log( LogLevel::Info, &format!( "Report generated, has_restrictive: {has_restrictive}, has_incompatible: {has_incompatible}" ), ); if (config.fail_on_restrictive && has_restrictive) || (config.fail_on_incompatible && has_incompatible) { log( LogLevel::Warn, "Exiting with non-zero status due to license issues", ); process::exit(1); } } log(LogLevel::Info, "Feluda completed successfully"); Ok(()) } fn handle_cache_command(clear: bool) -> FeludaResult<()> { if clear { cache::clear_github_licenses_cache()?; println!("✓ Cache cleared successfully\n"); } else { let status = cache::get_cache_status()?; status.print_status(); } Ok(()) } feluda-1.11.1/src/parser.rs000064400000000000000000000747601046102023000136140ustar 00000000000000//! Core parsing coordination and project discovery functionality use crate::cli; use crate::debug::{log, log_debug, FeludaResult, LogLevel}; use crate::languages::{ c::analyze_c_licenses, cpp::analyze_cpp_licenses, dotnet::analyze_dotnet_licenses, go::analyze_go_licenses, node::analyze_js_licenses_with_no_local, python::analyze_python_licenses, r::analyze_r_licenses, rust::analyze_rust_licenses_with_no_local, }; use crate::languages::{Language, CPP_PATHS, C_PATHS, DOTNET_PATHS, PYTHON_PATHS, R_PATHS}; use crate::licenses::{ detect_project_license, is_license_compatible, LicenseCompatibility, LicenseInfo, }; use cargo_metadata::MetadataCommand; use rayon::prelude::*; use std::path::{Path, PathBuf}; /// Project root information #[derive(Debug)] struct ProjectRoot { pub path: PathBuf, pub project_type: Language, } /// Find project files only in the root directory (not recursive) fn find_project_roots(root_path: impl AsRef) -> FeludaResult> { let mut project_roots = Vec::new(); let root = root_path.as_ref(); log( LogLevel::Info, &format!("Scanning for project files in: {}", root.display()), ); // Only check files directly in the root directory, don't recurse into subdirectories if let Ok(entries) = std::fs::read_dir(root) { for entry in entries.filter_map(|e| e.ok()) { if let Ok(file_type) = entry.file_type() { if !file_type.is_file() { continue; } } else { continue; } let path = entry.path(); let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); if let Some(project_type) = Language::from_file_name(file_name) { log( LogLevel::Info, &format!( "Found project file: {} ({:?})", path.display(), project_type ), ); project_roots.push(ProjectRoot { path: root.to_path_buf(), project_type, }); } } } log( LogLevel::Info, &format!("Found {} project roots", project_roots.len()), ); log_debug("Project roots", &project_roots); Ok(project_roots) } /// Check which C project file exists in the given path fn check_which_c_file_exists(project_path: impl AsRef) -> Option { for &path in C_PATHS.iter() { let full_path = Path::new(project_path.as_ref()).join(path); if full_path.exists() { log( LogLevel::Info, &format!("Found C project file: {}", full_path.display()), ); return Some(path.to_string()); } } log( LogLevel::Warn, &format!( "No C project file found in: {}", project_path.as_ref().display() ), ); None } /// Check which C++ project file exists in the given path fn check_which_cpp_file_exists(project_path: impl AsRef) -> Option { for &path in CPP_PATHS.iter() { let full_path = Path::new(project_path.as_ref()).join(path); if full_path.exists() { log( LogLevel::Info, &format!("Found C++ project file: {}", full_path.display()), ); return Some(path.to_string()); } } log( LogLevel::Warn, &format!( "No C++ project file found in: {}", project_path.as_ref().display() ), ); None } /// Check which Python project file exists in the given path fn check_which_python_file_exists(project_path: impl AsRef) -> Option { for &path in PYTHON_PATHS.iter() { let full_path = Path::new(project_path.as_ref()).join(path); if full_path.exists() { log( LogLevel::Info, &format!("Found Python project file: {}", full_path.display()), ); return Some(path.to_string()); } } log( LogLevel::Warn, &format!( "No Python project file found in: {}", project_path.as_ref().display() ), ); None } /// Check which R project file exists in the given path fn check_which_r_file_exists(project_path: impl AsRef) -> Option { for &path in R_PATHS.iter() { let full_path = Path::new(project_path.as_ref()).join(path); if full_path.exists() { log( LogLevel::Info, &format!("Found R project file: {}", full_path.display()), ); return Some(path.to_string()); } } log( LogLevel::Warn, &format!( "No R project file found in: {}", project_path.as_ref().display() ), ); None } fn check_which_dotnet_file_exists(project_path: impl AsRef) -> Option { for &path in DOTNET_PATHS.iter() { if path.starts_with('.') { if let Ok(entries) = std::fs::read_dir(project_path.as_ref()) { for entry in entries.filter_map(|e| e.ok()) { if let Some(file_name) = entry.file_name().to_str() { if file_name.ends_with(path) { log( LogLevel::Info, &format!("Found .NET project file: {}", entry.path().display()), ); return Some(file_name.to_string()); } } } } } else { let full_path = Path::new(project_path.as_ref()).join(path); if full_path.exists() { log( LogLevel::Info, &format!("Found .NET project file: {}", full_path.display()), ); return Some(path.to_string()); } } } log( LogLevel::Warn, &format!( "No .NET project file found in: {}", project_path.as_ref().display() ), ); None } /// Main entry point for parsing project dependencies pub fn parse_root( root_path: impl AsRef, language: Option<&str>, strict: bool, no_local: bool, ) -> FeludaResult> { let mut config = crate::config::load_config()?; config.strict = strict; parse_root_with_config(root_path, language, &config, no_local) } /// Main entry point for parsing project dependencies pub fn parse_root_with_config( root_path: impl AsRef, language: Option<&str>, config: &crate::config::FeludaConfig, no_local: bool, ) -> FeludaResult> { log( LogLevel::Info, &format!("Parsing root path: {}", root_path.as_ref().display()), ); if let Some(lang) = language { log(LogLevel::Info, &format!("Filtering by language: {lang}")); } let project_roots = find_project_roots(&root_path)?; if project_roots.is_empty() { log( LogLevel::Warn, "No project files found in the specified path", ); println!( "❌ No supported project files found.\n\ Feluda supports: C, C++, .NET, Rust, Node.js, Go, Python, R" ); return Ok(Vec::new()); } let licenses: Vec = project_roots .into_par_iter() .filter_map(|root| { if let Some(language) = language { if !matches_language(root.project_type, language) { log( LogLevel::Info, &format!( "Skipping {:?} project (language filter: {})", root.project_type, language ), ); return None; } } match parse_dependencies(&root, config, no_local) { Ok(deps) => { log( LogLevel::Info, &format!( "Found {} dependencies in {}", deps.len(), root.path.display() ), ); Some(deps) } Err(err) => { log( LogLevel::Error, &format!( "Error parsing dependencies in {}: {}", root.path.display(), err ), ); None } } }) .flatten() .collect(); log( LogLevel::Info, &format!("Total dependencies found: {}", licenses.len()), ); // Filter out ignored licenses let mut licenses = licenses; let ignored_count = licenses.len(); licenses.retain(|license| !crate::licenses::is_license_ignored(license.license.as_deref())); let filtered_count = licenses.len(); if ignored_count != filtered_count { log( LogLevel::Info, &format!( "Filtered out {} ignored licenses, {} remaining", ignored_count - filtered_count, filtered_count ), ); } // Filter out ignored dependencies based on configuration let ignored_count = licenses.len(); licenses.retain(|dep| { !config .dependencies .should_ignore_dependency(&dep.name, Some(&dep.version)) }); let filtered_count = licenses.len(); if ignored_count != filtered_count { log( LogLevel::Info, &format!( "Filtered out {} ignored dependencies, {} remaining", ignored_count - filtered_count, filtered_count ), ); } // Set license compatibility based on project license let project_license = detect_project_license(root_path.as_ref().to_str().unwrap_or("")).unwrap_or(None); set_license_compatibility(&mut licenses, &project_license); Ok(licenses) } /// Set license compatibility for all dependencies fn set_license_compatibility(licenses: &mut [LicenseInfo], project_license: &Option) { for license in licenses { license.compatibility = match (project_license, &license.license) { (Some(proj_license), Some(dep_license)) => { is_license_compatible(dep_license, proj_license, false) } _ => LicenseCompatibility::Unknown, }; } } /// Check if a project type matches the given language filter fn matches_language(project_type: Language, language: &str) -> bool { matches!( (project_type, language.to_lowercase().as_str()), (Language::C(_), "c") | (Language::Cpp(_), "cpp" | "c++") | ( Language::DotNet(_), "dotnet" | ".net" | "csharp" | "c#" | "fsharp" | "f#" ) | (Language::Rust(_), "rust") | (Language::Node(_), "node") | (Language::Go(_), "go") | (Language::Python(_), "python") | (Language::R(_), "r") ) } /// Parse dependencies based on the project type fn parse_dependencies( root: &ProjectRoot, config: &crate::config::FeludaConfig, no_local: bool, ) -> FeludaResult> { let project_path = &root.path; let project_type = root.project_type; let licenses = cli::with_spinner(&format!("🔎: {}", project_path.display()), |indicator| { match project_type { Language::Rust(_) => { let project_path = Path::new(project_path).join("Cargo.toml"); log( LogLevel::Info, &format!("Parsing Rust project: {}", project_path.display()), ); indicator.update_progress("analyzing Cargo.toml"); match MetadataCommand::new() .manifest_path(Path::new(&project_path)) .exec() { Ok(metadata) => { log( LogLevel::Info, &format!("Found {} packages in Rust project", metadata.packages.len()), ); indicator.update_progress(&format!( "found {} packages", metadata.packages.len() )); analyze_rust_licenses_with_no_local(metadata.packages, no_local) } Err(err) => { log( LogLevel::Error, &format!("Failed to fetch cargo metadata: {err}"), ); Vec::new() } } } Language::Node(_) => { let project_path = Path::new(project_path).join("package.json"); log( LogLevel::Info, &format!("Parsing Node.js project: {}", project_path.display()), ); indicator.update_progress("analyzing package.json"); match project_path.to_str() { Some(path_str) => { let deps = analyze_js_licenses_with_no_local(path_str, no_local); indicator.update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert Node.js path to string"); Vec::new() } } } Language::Go(_) => { let project_path = Path::new(project_path).join("go.mod"); log( LogLevel::Info, &format!("Parsing Go project: {}", project_path.display()), ); indicator.update_progress("analyzing go.mod"); match project_path.to_str() { Some(path_str) => { let deps = analyze_go_licenses(path_str, config); indicator.update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert Go path to string"); Vec::new() } } } Language::Python(_) => match check_which_python_file_exists(project_path) { Some(python_package_file) => { let project_path = Path::new(project_path).join(&python_package_file); log( LogLevel::Info, &format!("Parsing Python project: {}", project_path.display()), ); indicator.update_progress(&format!("analyzing {python_package_file}")); match project_path.to_str() { Some(path_str) => { let deps = analyze_python_licenses(path_str, config); indicator .update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert Python path to string"); Vec::new() } } } None => { log(LogLevel::Error, "Python package file not found"); Vec::new() } }, Language::C(_) => match check_which_c_file_exists(project_path) { Some(c_build_file) => { let project_path = Path::new(project_path).join(&c_build_file); log( LogLevel::Info, &format!("Parsing C project: {}", project_path.display()), ); indicator.update_progress(&format!("analyzing {c_build_file}")); match project_path.to_str() { Some(path_str) => { let deps = analyze_c_licenses(path_str, config); indicator .update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert C path to string"); Vec::new() } } } None => { log(LogLevel::Error, "C build file not found"); Vec::new() } }, Language::Cpp(_) => match check_which_cpp_file_exists(project_path) { Some(cpp_build_file) => { let project_path = Path::new(project_path).join(&cpp_build_file); log( LogLevel::Info, &format!("Parsing C++ project: {}", project_path.display()), ); indicator.update_progress(&format!("analyzing {cpp_build_file}")); match project_path.to_str() { Some(path_str) => { let deps = analyze_cpp_licenses(path_str, config); indicator .update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert C++ path to string"); Vec::new() } } } None => { log(LogLevel::Error, "C++ build file not found"); Vec::new() } }, Language::DotNet(_) => match check_which_dotnet_file_exists(project_path) { Some(dotnet_project_file) => { let project_path = Path::new(project_path).join(&dotnet_project_file); log( LogLevel::Info, &format!("Parsing .NET project: {}", project_path.display()), ); indicator.update_progress(&format!("analyzing {dotnet_project_file}")); match project_path.to_str() { Some(path_str) => { let deps = analyze_dotnet_licenses(path_str, config); indicator .update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert .NET path to string"); Vec::new() } } } None => { log(LogLevel::Error, ".NET project file not found"); Vec::new() } }, Language::R(_) => match check_which_r_file_exists(project_path) { Some(r_package_file) => { let project_path = Path::new(project_path).join(&r_package_file); log( LogLevel::Info, &format!("Parsing R project: {}", project_path.display()), ); indicator.update_progress(&format!("analyzing {r_package_file}")); match project_path.to_str() { Some(path_str) => { let deps = analyze_r_licenses(path_str, config); indicator .update_progress(&format!("found {} dependencies", deps.len())); deps } None => { log(LogLevel::Error, "Failed to convert R path to string"); Vec::new() } } } None => { log(LogLevel::Error, "R package file not found"); Vec::new() } }, } }); Ok(licenses) } #[cfg(test)] mod tests { use super::*; #[test] fn test_matches_language() { assert!(matches_language(Language::C(&C_PATHS), "c")); assert!(matches_language(Language::C(&C_PATHS), "C")); assert!(matches_language(Language::Cpp(&CPP_PATHS), "cpp")); assert!(matches_language(Language::Cpp(&CPP_PATHS), "c++")); assert!(matches_language(Language::Cpp(&CPP_PATHS), "CPP")); assert!(matches_language(Language::Rust("Cargo.toml"), "rust")); assert!(matches_language(Language::Rust("Cargo.toml"), "RUST")); assert!(matches_language(Language::Rust("Cargo.toml"), "Rust")); assert!(matches_language(Language::Node("package.json"), "node")); assert!(matches_language(Language::Node("package.json"), "NODE")); assert!(matches_language(Language::Node("package.json"), "Node")); assert!(matches_language(Language::Go("go.mod"), "go")); assert!(matches_language(Language::Go("go.mod"), "GO")); assert!(matches_language(Language::Go("go.mod"), "Go")); assert!(matches_language(Language::Python(&PYTHON_PATHS), "python")); assert!(matches_language(Language::Python(&PYTHON_PATHS), "PYTHON")); assert!(matches_language(Language::Python(&PYTHON_PATHS), "Python")); assert!(!matches_language(Language::Rust("Cargo.toml"), "node")); assert!(!matches_language(Language::Node("package.json"), "python")); assert!(!matches_language(Language::Go("go.mod"), "rust")); assert!(!matches_language(Language::Python(&PYTHON_PATHS), "go")); assert!(!matches_language(Language::C(&C_PATHS), "cpp")); assert!(!matches_language(Language::Cpp(&CPP_PATHS), "c")); assert!(!matches_language(Language::Rust("Cargo.toml"), "java")); assert!(!matches_language(Language::Node("package.json"), "java")); } #[test] fn test_check_which_python_file_exists() { let temp_dir = tempfile::TempDir::new().unwrap(); // Test when no Python files exist let result = check_which_python_file_exists(temp_dir.path()); assert_eq!(result, None); // Test when requirements.txt exists std::fs::write(temp_dir.path().join("requirements.txt"), "requests==2.28.1").unwrap(); let result = check_which_python_file_exists(temp_dir.path()); assert_eq!(result, Some("requirements.txt".to_string())); // Test when multiple Python files exist std::fs::write( temp_dir.path().join("pyproject.toml"), "[project]\nname = \"test\"", ) .unwrap(); std::fs::write(temp_dir.path().join("Pipfile.lock"), "{}").unwrap(); let result = check_which_python_file_exists(temp_dir.path()); assert_eq!(result, Some("requirements.txt".to_string())); } #[test] fn test_find_project_roots_empty_directory() { let temp_dir = tempfile::TempDir::new().unwrap(); let result = find_project_roots(temp_dir.path().to_str().unwrap()).unwrap(); assert!(result.is_empty()); } #[test] fn test_find_project_roots_single_project() { let temp_dir = tempfile::TempDir::new().unwrap(); let root_path = temp_dir.path(); // Create a single Rust project std::fs::write(root_path.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); let result = find_project_roots(root_path.to_str().unwrap()).unwrap(); assert_eq!(result.len(), 1); assert_eq!(result[0].project_type, Language::Rust("Cargo.toml")); assert_eq!(result[0].path, root_path); } #[test] fn test_parse_root_with_language_filter() { let temp_dir = tempfile::TempDir::new().unwrap(); let root_path = temp_dir.path(); // Create multiple project types std::fs::write(root_path.join("package.json"), r#"{"name": "test"}"#).unwrap(); std::fs::write(root_path.join("go.mod"), "module test").unwrap(); std::fs::write(root_path.join("requirements.txt"), "# No dependencies").unwrap(); // Test filtering by node let result = parse_root(root_path, Some("node"), false, false); assert!(result.is_ok()); // Test filtering by go let result = parse_root(root_path, Some("go"), false, false); assert!(result.is_ok()); // Test filtering by python let result = parse_root(root_path, Some("python"), false, false); assert!(result.is_ok()); // Test filtering by non-existent language let result = parse_root(root_path, Some("java"), false, false); assert!(result.is_ok()); let licenses = result.unwrap(); assert!(licenses.is_empty()); // Test case-insensitive filtering let result = parse_root(root_path, Some("NODE"), false, false); assert!(result.is_ok()); let result = parse_root(root_path, Some("Python"), false, false); assert!(result.is_ok()); } #[test] fn test_parse_root_no_projects() { let temp_dir = tempfile::TempDir::new().unwrap(); let result = parse_root(temp_dir.path(), None, false, false).unwrap(); assert!(result.is_empty()); } #[test] fn test_parse_root_all_languages() { let temp_dir = tempfile::TempDir::new().unwrap(); let root_path = temp_dir.path(); // Create project files for all supported languages std::fs::write( root_path.join("Cargo.toml"), "[package]\nname = \"test\"\nversion = \"0.1.0\"", ) .unwrap(); std::fs::write( root_path.join("package.json"), r#"{"name": "test", "version": "1.0.0"}"#, ) .unwrap(); std::fs::write(root_path.join("go.mod"), "module test\n\ngo 1.19").unwrap(); std::fs::write(root_path.join("requirements.txt"), "# No dependencies").unwrap(); let result = parse_root(root_path, None, false, false); assert!(result.is_ok()); } #[test] fn test_project_root_debug() { let project_root = ProjectRoot { path: std::path::PathBuf::from("/test/path"), project_type: Language::Rust("Cargo.toml"), }; let debug_str = format!("{project_root:?}"); assert!(debug_str.contains("/test/path")); assert!(debug_str.contains("Rust")); assert!(debug_str.contains("Cargo.toml")); } #[test] fn test_find_project_roots_nested_projects() { let temp_dir = tempfile::TempDir::new().unwrap(); let root_path = temp_dir.path(); // Create nested structure let rust_dir = root_path.join("rust_project"); let node_dir = root_path.join("node_project").join("nested"); std::fs::create_dir_all(&rust_dir).unwrap(); std::fs::create_dir_all(&node_dir).unwrap(); // Create project files std::fs::write(rust_dir.join("Cargo.toml"), "[package]\nname = \"test\"").unwrap(); std::fs::write(node_dir.join("package.json"), "{}").unwrap(); std::fs::write(root_path.join("go.mod"), "module test").unwrap(); let result = find_project_roots(root_path.to_str().unwrap()).unwrap(); // Only finds go.mod in root directory (non-recursive scanning) assert_eq!(result.len(), 1); let project_types: Vec<_> = result.iter().map(|r| r.project_type).collect(); assert!(project_types.contains(&Language::Go("go.mod"))); } #[test] fn test_parse_dependencies_error_handling() { let temp_dir = tempfile::TempDir::new().unwrap(); // Test with invalid Rust project (missing lib.rs) let rust_project_root = ProjectRoot { path: temp_dir.path().to_path_buf(), project_type: Language::Rust("Cargo.toml"), }; // Create Cargo.toml without lib.rs std::fs::write( temp_dir.path().join("Cargo.toml"), "[package]\nname = \"test\"\nversion = \"0.1.0\"\n[dependencies]\nserde = \"1.0\"", ) .unwrap(); let config = crate::config::FeludaConfig::default(); let result = parse_dependencies(&rust_project_root, &config, false); assert!(result.is_ok()); let licenses = result.unwrap(); assert!(licenses.is_empty()); } #[test] fn test_parse_dependencies_node_invalid_json() { let temp_dir = tempfile::TempDir::new().unwrap(); let node_project_root = ProjectRoot { path: temp_dir.path().to_path_buf(), project_type: Language::Node("package.json"), }; // Create invalid package.json std::fs::write(temp_dir.path().join("package.json"), "invalid json content").unwrap(); let config = crate::config::FeludaConfig::default(); let result = parse_dependencies(&node_project_root, &config, false); assert!(result.is_ok()); let licenses = result.unwrap(); assert!(licenses.is_empty()); } #[test] fn test_parse_dependencies_python_no_dependencies() { let temp_dir = tempfile::TempDir::new().unwrap(); let python_project_root = ProjectRoot { path: temp_dir.path().to_path_buf(), project_type: Language::Python(&PYTHON_PATHS), }; // Create empty requirements.txt std::fs::write(temp_dir.path().join("requirements.txt"), "").unwrap(); let config = crate::config::FeludaConfig::default(); let result = parse_dependencies(&python_project_root, &config, false); assert!(result.is_ok()); let licenses = result.unwrap(); assert!(licenses.is_empty()); } #[test] fn test_parse_root_invalid_path() { let result = parse_root("/definitely/nonexistent/path", None, false, false); assert!(result.is_ok()); let licenses = result.unwrap(); assert!(licenses.is_empty()); } } feluda-1.11.1/src/progress.rs000064400000000000000000000120111046102023000141410ustar 00000000000000use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::io::{self, Write}; use std::thread; use std::time::Duration; use colored::*; /// TODO: Global progress tracker for coordinating multiple concurrent operations. /// Will be used when implementing support for analyzing multiple root projects /// simultaneously with per-project progress indicators. #[allow(dead_code)] pub struct ProgressTracker { #[allow(dead_code)] total: usize, completed: Arc, #[allow(dead_code)] current_task: Arc>, running: Arc>, handle: Arc>>>, } impl ProgressTracker { /// TODO: Create a new progress tracker. Will be used when implementing /// multi-project analysis mode with detailed progress tracking per project. #[allow(dead_code)] pub fn new(total: usize) -> Self { Self { total, completed: Arc::new(AtomicUsize::new(0)), current_task: Arc::new(Mutex::new(String::new())), running: Arc::new(Mutex::new(false)), handle: Arc::new(Mutex::new(None)), } } /// TODO: Start the progress indicator thread. Will be used for displaying /// concurrent progress updates when analyzing multiple projects in parallel. #[allow(dead_code)] pub fn start(&self) { let total = self.total; *self.running.lock().unwrap() = true; let spinner_frames = vec!["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; let completed_for_thread = Arc::clone(&self.completed); let current_task_for_thread = Arc::clone(&self.current_task); let running_for_thread = Arc::clone(&self.running); let handle = thread::spawn(move || { let mut frame_idx = 0; while *running_for_thread.lock().unwrap() { frame_idx = (frame_idx + 1) % spinner_frames.len(); let completed_count = completed_for_thread.load(Ordering::Relaxed); let current = current_task_for_thread.lock().unwrap().clone(); // Clear line and show progress print!("\x1B[2K\r"); let spinner = spinner_frames[frame_idx].cyan(); let progress_text = format!("[{}/{}]", completed_count, total); print!("{} {} ", spinner, "Analyzing projects".bright_white().bold()); print!("{} ", progress_text.bright_cyan()); if !current.is_empty() { print!("({})", current.yellow()); } io::stdout().flush().unwrap(); thread::sleep(Duration::from_millis(80)); } // Final message print!("\x1B[2K\r"); println!( "{} {} {} {}", "✓".green().bold(), "Analyzed".bright_white().bold(), format!("{} projects", total).bright_cyan().bold(), "✅" ); io::stdout().flush().unwrap(); }); if let Ok(mut h) = self.handle.lock() { *h = Some(handle); } } /// TODO: Update the current task being worked on. Will be used to display /// which specific project or analysis step is currently executing. #[allow(dead_code)] pub fn set_current_task(&self, task: impl Into) { if let Ok(mut guard) = self.current_task.lock() { *guard = task.into(); } } /// TODO: Mark a task as completed. Will be called to update progress counters /// as each project analysis completes in multi-project scenarios. #[allow(dead_code)] pub fn inc_completed(&self) { self.completed.fetch_add(1, Ordering::Relaxed); } /// Stop the progress indicator pub fn stop(&self) { if let Ok(mut guard) = self.running.lock() { *guard = false; } if let Ok(mut h) = self.handle.lock() { if let Some(handle) = h.take() { let _ = handle.join(); } } } /// Get the current completion count #[allow(dead_code)] pub fn get_completed(&self) -> usize { self.completed.load(Ordering::Relaxed) } } impl Drop for ProgressTracker { fn drop(&mut self) { self.stop(); } } #[cfg(test)] mod tests { use super::*; #[test] fn test_progress_tracker_creation() { let tracker = ProgressTracker::new(10); assert_eq!(tracker.total, 10); assert_eq!(tracker.completed.load(Ordering::Relaxed), 0); } #[test] fn test_progress_tracker_increment() { let tracker = ProgressTracker::new(5); tracker.inc_completed(); tracker.inc_completed(); assert_eq!(tracker.get_completed(), 2); } #[test] fn test_progress_tracker_set_task() { let tracker = ProgressTracker::new(1); tracker.set_current_task("test task"); let task = tracker.current_task.lock().unwrap().clone(); assert_eq!(task, "test task"); } } feluda-1.11.1/src/reporter.rs000064400000000000000000001463731046102023000141620ustar 00000000000000use crate::cli::{CiFormat, OsiFilter}; use crate::debug::{log, log_debug, log_error, LogLevel}; use crate::licenses::{LicenseCompatibility, LicenseInfo, OsiStatus}; use colored::*; use std::collections::HashMap; use std::fs; // ReportConfig struct #[derive(Debug)] pub struct ReportConfig { json: bool, yaml: bool, verbose: bool, restrictive: bool, incompatible: bool, ci_format: Option, output_file: Option, project_license: Option, gist: bool, osi: Option, } impl ReportConfig { #[allow(clippy::too_many_arguments)] pub fn new( json: bool, yaml: bool, verbose: bool, restrictive: bool, incompatible: bool, ci_format: Option, output_file: Option, project_license: Option, gist: bool, osi: Option, ) -> Self { Self { json, yaml, verbose, restrictive, incompatible, ci_format, output_file, project_license, gist, osi, } } } struct TableFormatter { column_widths: Vec, headers: Vec, } impl TableFormatter { fn new(headers: Vec) -> Self { let column_widths = headers.iter().map(|h| h.len()).collect(); Self { column_widths, headers, } } fn add_row(&mut self, row: &[String]) { for (i, item) in row.iter().enumerate() { if i < self.column_widths.len() { self.column_widths[i] = self.column_widths[i].max(item.len()); } } } fn render_header(&self) -> String { let header_row = self .headers .iter() .enumerate() .map(|(i, header)| format!("{:width$}", header, width = self.column_widths[i])) .collect::>() .join(" │ "); let total_width = self.column_widths.iter().sum::() + (3 * self.column_widths.len()) - 1; format!( "┌{}┐\n│ {} │\n├{}┤", "─".repeat(total_width), header_row.bold().blue(), "─".repeat(total_width) ) } fn render_row(&self, row: &[String], is_problematic: bool) -> String { let formatted_row = row .iter() .enumerate() .map(|(i, item)| { if i < self.column_widths.len() { format!("{:width$}", item, width = self.column_widths[i]) } else { item.clone() } }) .collect::>() .join(" │ "); if is_problematic { format!("│ {} │", formatted_row.red().bold()) } else { format!("│ {} │", formatted_row.green()) } } fn render_footer(&self) -> String { let footer_width = self.column_widths.iter().sum::() + (3 * self.column_widths.len()) - 1; format!("└{}┘", "─".repeat(footer_width)) } } pub fn generate_report(data: Vec, config: ReportConfig) -> (bool, bool) { log( LogLevel::Info, &format!("Generating report with config: {config:?}"), ); let total_packages = data.len(); log( LogLevel::Info, &format!("Total packages to analyze: {total_packages}"), ); let has_restrictive = data.iter().any(|info| *info.is_restrictive()); let has_incompatible = data .iter() .any(|info| info.compatibility == LicenseCompatibility::Incompatible); log( LogLevel::Info, &format!("Has restrictive licenses: {has_restrictive}"), ); log( LogLevel::Info, &format!("Has incompatible licenses: {has_incompatible}"), ); if config.gist { log(LogLevel::Info, "Generating gist summary"); print_gist_summary(&data, total_packages, config.project_license.as_deref()); return (has_restrictive, has_incompatible); } // Filter data if in restrictive or/and incompatible mode to show only restrictive or/and incompatible licenses let mut filtered_data: Vec = if config.restrictive || config.incompatible { log( LogLevel::Info, "Restrictive or/and incompatible mode enabled, filtering restrictive or/and incompatible licenses only", ); data.into_iter() .filter(|info| { (config.restrictive && *info.is_restrictive()) || (config.incompatible && (info.compatibility == LicenseCompatibility::Incompatible)) }) .collect() } else { data }; // Apply OSI filtering if let Some(osi_filter) = &config.osi { let before_count = filtered_data.len(); match osi_filter { OsiFilter::Approved => { filtered_data.retain(|info| info.osi_status == OsiStatus::Approved); log( LogLevel::Info, &format!( "Applied OSI approved filter: {} of {} dependencies", filtered_data.len(), before_count ), ); } OsiFilter::NotApproved => { filtered_data.retain(|info| info.osi_status == OsiStatus::NotApproved); log( LogLevel::Info, &format!( "Applied OSI not-approved filter: {} of {} dependencies", filtered_data.len(), before_count ), ); } OsiFilter::Unknown => { filtered_data.retain(|info| info.osi_status == OsiStatus::Unknown); log( LogLevel::Info, &format!( "Applied OSI unknown filter: {} of {} dependencies", filtered_data.len(), before_count ), ); } } } log( LogLevel::Info, &format!("Filtered packages count: {}", filtered_data.len()), ); log_debug("Filtered license data", &filtered_data); if filtered_data.is_empty() { println!( "\n{}\n", "🎉 All dependencies passed the license check! No restrictive or incompatible licenses found." .green() .bold() ); return (false, false); } if let Some(format) = config.ci_format { match format { CiFormat::Github => output_github_format( &filtered_data, config.output_file.as_deref(), config.project_license.as_deref(), ), CiFormat::Jenkins => output_jenkins_format( &filtered_data, config.output_file.as_deref(), config.project_license.as_deref(), ), } } else if config.json { // JSON output log(LogLevel::Info, "Generating JSON output"); match serde_json::to_string_pretty(&filtered_data) { Ok(json_output) => println!("{json_output}"), Err(err) => { log_error("Failed to serialize data to JSON", &err); println!("Error: Failed to generate JSON output"); } } } else if config.yaml { // YAML output log(LogLevel::Info, "Generating YAML output"); match serde_yaml::to_string(&filtered_data) { Ok(yaml_output) => println!("{yaml_output}"), Err(err) => { log_error("Failed to serialize data to YAML", &err); println!("Error: Failed to generate YAML output"); } } } else if config.verbose { log(LogLevel::Info, "Generating verbose table"); print_verbose_table( &filtered_data, config.restrictive, config.project_license.as_deref(), ); } else { log(LogLevel::Info, "Generating summary table"); print_summary_table( &filtered_data, total_packages, config.restrictive, config.incompatible, config.project_license.as_deref(), ); } (has_restrictive, has_incompatible) } fn print_verbose_table( license_info: &[LicenseInfo], restrictive: bool, project_license: Option<&str>, ) { log(LogLevel::Info, "Printing verbose table"); let mut headers = vec![ "Name".to_string(), "Version".to_string(), "License".to_string(), "Restrictive".to_string(), ]; // Add compatibility column if project license is available if project_license.is_some() { headers.push("Compatibility".to_string()); } // Always add OSI status column in verbose mode headers.push("OSI Status".to_string()); let mut formatter = TableFormatter::new(headers); let rows: Vec<_> = license_info .iter() .map(|info| { let mut row = vec![ info.name().to_string(), info.version().to_string(), info.get_license(), info.is_restrictive().to_string(), ]; // Add compatibility if project license is available if project_license.is_some() { row.push(format!("{:?}", info.compatibility)); } // Always add OSI status in verbose mode row.push(info.osi_status().to_string()); row }) .collect(); log_debug("Table rows prepared", &rows); for row in &rows { formatter.add_row(row); } println!("\n{}", formatter.render_header()); for (i, row) in rows.iter().enumerate() { let is_restrictive = *license_info[i].is_restrictive(); let is_incompatible = *license_info[i].compatibility() == LicenseCompatibility::Incompatible; println!( "{}", formatter.render_row(row, is_restrictive || is_incompatible) ); } println!("{}\n", formatter.render_footer()); if !restrictive { print_summary_footer(license_info, project_license); } } fn print_summary_table( license_info: &[LicenseInfo], total_packages: usize, restrictive: bool, incompatible: bool, project_license: Option<&str>, ) { log(LogLevel::Info, "Printing summary table"); // Print project license if available if let Some(license) = project_license { println!( "\n{} {}", "📄".bold(), format!("Project License: {license}").bold() ); } let mut license_count: HashMap> = HashMap::new(); let mut restrictive_licenses: Vec<&LicenseInfo> = Vec::new(); let mut incompatible_licenses: Vec<&LicenseInfo> = Vec::new(); for info in license_info { let license = info.get_license(); if *info.is_restrictive() { restrictive_licenses.push(info); } else { license_count .entry(license) .or_default() .push(info.name().to_string()); } if info.compatibility == LicenseCompatibility::Incompatible { incompatible_licenses.push(info); } } log( LogLevel::Info, &format!("Found {} permissive license types", license_count.len()), ); log( LogLevel::Info, &format!( "Found {} packages with restrictive licenses", restrictive_licenses.len() ), ); log( LogLevel::Info, &format!( "Found {} packages with incompatible licenses", incompatible_licenses.len() ), ); if restrictive || incompatible { if restrictive && !restrictive_licenses.is_empty() { log( LogLevel::Info, "Restrictive mode enabled, showing only restrictive licenses", ); print_restrictive_licenses_table(&restrictive_licenses); } if incompatible && project_license.is_some() && !incompatible_licenses.is_empty() { if let Some(license) = project_license { print_incompatible_licenses_table(&incompatible_licenses, license); } } return; } // License summary let headers = vec!["License Type".to_string(), "Count".to_string()]; let mut formatter = TableFormatter::new(headers); let mut rows: Vec> = license_count .iter() .map(|(license, deps)| vec![license.clone(), deps.len().to_string()]) .collect(); for row in &rows { formatter.add_row(row); } println!( "\n{} {}\n", "🔍".bold(), "License Summary".bold().underline() ); println!("{}", formatter.render_header()); rows.sort_by(|a, b| a[0].cmp(&b[0])); for row in &rows { println!("{}", formatter.render_row(row, true)); } println!("{}", formatter.render_footer()); println!( "\n{} {}", "📦".bold(), format!("Total dependencies scanned: {total_packages}").bold() ); if !restrictive_licenses.is_empty() { print_restrictive_licenses_table(&restrictive_licenses); } else { println!( "\n{}\n", "✅ No restrictive licenses found! 🎉".green().bold() ); } // Print incompatible licenses if project license is available if project_license.is_some() && !incompatible_licenses.is_empty() { if let Some(license) = project_license { print_incompatible_licenses_table(&incompatible_licenses, license); } } else if project_license.is_some() { println!( "\n{}\n", "✅ No incompatible licenses found! 🎉".green().bold() ); } } fn print_restrictive_licenses_table(restrictive_licenses: &[&LicenseInfo]) { log( LogLevel::Info, &format!( "Printing table for {} restrictive licenses", restrictive_licenses.len() ), ); println!( "\n{} {}\n", "⚠️".bold(), "Warning: Restrictive licenses found!".yellow().bold() ); let headers = vec![ "Package".to_string(), "Version".to_string(), "License".to_string(), ]; let mut formatter = TableFormatter::new(headers); let rows: Vec<_> = restrictive_licenses .iter() .map(|info| { vec![ info.name().to_string(), info.version().to_string(), info.get_license(), ] }) .collect(); for row in &rows { formatter.add_row(row); } println!("{}", formatter.render_header()); for row in &rows { println!("{}", formatter.render_row(row, false)); } println!("{}\n", formatter.render_footer()); } fn print_incompatible_licenses_table( incompatible_licenses: &[&LicenseInfo], project_license: &str, ) { log( LogLevel::Info, &format!( "Printing table for {} incompatible licenses", incompatible_licenses.len() ), ); println!( "\n{} {}\n", "❌".bold(), format!("Warning: Licenses incompatible with {project_license} found!") .red() .bold() ); let headers = vec![ "Package".to_string(), "Version".to_string(), "License".to_string(), ]; let mut formatter = TableFormatter::new(headers); let rows: Vec<_> = incompatible_licenses .iter() .map(|info| { vec![ info.name().to_string(), info.version().to_string(), info.get_license(), ] }) .collect(); for row in &rows { formatter.add_row(row); } println!("{}", formatter.render_header()); for row in &rows { println!("{}", formatter.render_row(row, false)); } println!("{}\n", formatter.render_footer()); } fn print_summary_footer(license_info: &[LicenseInfo], project_license: Option<&str>) { log(LogLevel::Info, "Printing summary footer"); let total = license_info.len(); let restrictive_count = license_info.iter().filter(|i| *i.is_restrictive()).count(); let permissive_count = total - restrictive_count; // Calculate compatibility counts if project license is available let (compatible_count, incompatible_count, unknown_count) = if project_license.is_some() { ( license_info .iter() .filter(|i| i.compatibility == LicenseCompatibility::Compatible) .count(), license_info .iter() .filter(|i| i.compatibility == LicenseCompatibility::Incompatible) .count(), license_info .iter() .filter(|i| i.compatibility == LicenseCompatibility::Unknown) .count(), ) } else { (0, 0, 0) }; println!("{}", "🔍 License Summary:".bold()); println!( " • {} {}", permissive_count.to_string().green().bold(), "permissive licenses".green() ); println!( " • {} {}", restrictive_count.to_string().yellow().bold(), "restrictive licenses".yellow() ); // Print compatibility info if project license is available if project_license.is_some() { println!( " • {} {}", compatible_count.to_string().green().bold(), "compatible licenses".green() ); println!( " • {} {}", incompatible_count.to_string().red().bold(), "incompatible licenses".red() ); println!( " • {} {}", unknown_count.to_string().blue().bold(), "unknown compatibility".blue() ); } println!(" • {total} total dependencies"); if restrictive_count > 0 { println!("\n{} {}: Review these dependencies for compliance with your project's licensing requirements.", "⚠️".yellow().bold(), "Recommendation".yellow().bold() ); } else { println!( "\n{} {}: All dependencies have permissive licenses compatible with most projects.", "✅".green().bold(), "Status".green().bold() ); } // Add compatibility recommendation if project license is available if let Some(license) = project_license { if incompatible_count > 0 { println!("\n{} {}: Some dependencies have licenses that may be incompatible with your project's {} license. Review for legal compliance.", "❌".red().bold(), "Warning".red().bold(), license ); } } println!(); } fn output_github_format( license_info: &[LicenseInfo], output_path: Option<&str>, project_license: Option<&str>, ) { log( LogLevel::Info, "Generating GitHub Actions compatible output", ); // GitHub Actions workflow commands format let mut output = String::new(); // Add project license info if available if let Some(license) = project_license { output.push_str(&format!( "::notice title=Project License::Project is using {license} license\n" )); } // GitHub Actions workflow commands format for restrictive licenses for info in license_info { if *info.is_restrictive() { let warning = format!( "::warning title=Restrictive License::Dependency '{}@{}' has restrictive license: {}\n", info.name(), info.version(), info.get_license() ); output.push_str(&warning); log( LogLevel::Info, &format!("Added warning for restrictive license: {}", info.name()), ); } // Add incompatible license warnings if project license is available if let Some(license) = project_license { if info.compatibility == LicenseCompatibility::Incompatible { let warning = format!( "::error title=Incompatible License::Dependency '{}@{}' has license {} which may be incompatible with project license {}\n", info.name(), info.version(), info.get_license(), license ); output.push_str(&warning); log( LogLevel::Info, &format!("Added error for incompatible license: {}", info.name()), ); } } } let restrictive_count = license_info.iter().filter(|i| *i.is_restrictive()).count(); let incompatible_count = if project_license.is_some() { license_info .iter() .filter(|i| i.compatibility == LicenseCompatibility::Incompatible) .count() } else { 0 }; let summary = if project_license.is_some() { format!( "::notice title=License Check Summary::Found {} dependencies with restrictive licenses and {} dependencies with incompatible licenses out of {} total\n", restrictive_count, incompatible_count, license_info.len() ) } else { format!( "::notice title=License Check Summary::Found {} dependencies with restrictive licenses out of {} total\n", restrictive_count, license_info.len() ) }; output.push_str(&summary); log( LogLevel::Info, &format!( "Added summary: {} restrictive and {} incompatible out of {}", restrictive_count, incompatible_count, license_info.len() ), ); // Output to file or stdout if let Some(path) = output_path { log( LogLevel::Info, &format!("Writing GitHub Actions output to file: {path}"), ); match fs::write(path, &output) { Ok(_) => println!("GitHub Actions output written to: {path}"), Err(err) => { log_error( &format!("Failed to write GitHub Actions output file: {path}"), &err, ); println!("Error: Failed to write GitHub Actions output file"); println!("{output}"); } } } else { log(LogLevel::Info, "Writing GitHub Actions output to stdout"); print!("{output}"); } } fn output_jenkins_format( license_info: &[LicenseInfo], output_path: Option<&str>, project_license: Option<&str>, ) { log( LogLevel::Info, "Generating Jenkins compatible output (JUnit XML)", ); // Jenkins compatible output (JUnit XML format) let mut test_cases = Vec::new(); // Add project license info if available if let Some(license) = project_license { test_cases.push(format!( r#" Project is using {license} license "# )); } for info in license_info { let test_case_name = format!("{}-{}", info.name(), info.version()); log( LogLevel::Info, &format!("Processing test case: {test_case_name}"), ); let mut failures = Vec::new(); // Check for restrictive license if *info.is_restrictive() { failures.push(format!( r#" Dependency '{}@{}' has restrictive license: {} "#, info.name(), info.version(), info.get_license() )); log( LogLevel::Info, &format!( "Added failing test case for restrictive license: {}", info.name() ), ); } // Check for incompatible license if project license is available if let Some(license) = project_license { if info.compatibility == LicenseCompatibility::Incompatible { failures.push(format!( r#" Dependency '{}@{}' has license {} which may be incompatible with project license {} "#, info.name(), info.version(), info.get_license(), license )); log( LogLevel::Info, &format!( "Added failing test case for incompatible license: {}", info.name() ), ); } } if failures.is_empty() { test_cases.push(format!( r#" "# )); } else { test_cases.push(format!( r#" {} "#, test_case_name, failures.join("\n") )); } } let restrictive_count = license_info.iter().filter(|i| *i.is_restrictive()).count(); let incompatible_count = if project_license.is_some() { license_info .iter() .filter(|i| i.compatibility == LicenseCompatibility::Incompatible) .count() } else { 0 }; let failure_count = restrictive_count + incompatible_count; log( LogLevel::Info, &format!( "Total test cases: {}, failures: {}", license_info.len(), failure_count ), ); let junit_xml = format!( r#" {} "#, license_info.len() + (if project_license.is_some() { 1 } else { 0 }), failure_count, test_cases.join("\n") ); // Output to file or stdout if let Some(path) = output_path { log( LogLevel::Info, &format!("Writing Jenkins JUnit XML to file: {path}"), ); match fs::write(path, &junit_xml) { Ok(_) => println!("Jenkins JUnit XML output written to: {path}"), Err(err) => { log_error( &format!("Failed to write Jenkins output file: {path}"), &err, ); println!("Error: Failed to write Jenkins JUnit XML output file"); println!("{junit_xml}"); // Fallback to stdout } } } else { log(LogLevel::Info, "Writing Jenkins JUnit XML to stdout"); println!("{junit_xml}"); } } // Add gist report function to reporter.rs fn print_gist_summary( license_info: &[LicenseInfo], total_packages: usize, project_license: Option<&str>, ) { use colored::*; let restrictive_count = license_info.iter().filter(|i| *i.is_restrictive()).count(); let incompatible_count = license_info .iter() .filter(|i| i.compatibility == LicenseCompatibility::Incompatible) .count(); let project_license_display = project_license.unwrap_or("Not detected"); println!("\n{}", "🦀 FELUDA GIST".bold().cyan()); println!("{}", "━".repeat(50).cyan()); println!( "│ {:30} │ {}", "Project License".bold(), project_license_display.cyan() ); println!( "│ {:30} │ {}", "Total Dependencies Scanned".bold(), total_packages.to_string().cyan() ); println!("{}", "━".repeat(50).cyan()); let restrictive_status = if restrictive_count > 0 { format!( "{} {}", "⚠️".yellow(), restrictive_count.to_string().yellow().bold() ) } else { format!("{} {}", "✅".green(), "0".green().bold()) }; let incompatible_status = if project_license.is_some() { if incompatible_count > 0 { format!( "{} {}", "❌".red(), incompatible_count.to_string().red().bold() ) } else { format!("{} {}", "✅".green(), "0".green().bold()) } } else { format!("{} {}", "❓".blue(), "N/A".blue()) }; println!( "│ {:30} │ {}", "Restrictive dependencies".bold(), restrictive_status ); println!( "│ {:30} │ {}", "Incompatible dependencies".bold(), incompatible_status ); println!("{}", "━".repeat(50).cyan()); let overall_status = if restrictive_count > 0 || incompatible_count > 0 { format!("{} {}", "⚠️".yellow(), "NEEDS ATTENTION".yellow().bold()) } else { format!("{} {}", "✨".green(), "ALL GOOD".green().bold()) }; println!("│ {:30} │ {}", "Recommendation".bold(), overall_status); println!("{}\n", "━".repeat(50).cyan()); } #[cfg(test)] mod tests { use super::*; use crate::licenses::LicenseCompatibility; use tempfile::TempDir; fn setup() -> TempDir { tempfile::tempdir().unwrap() } fn get_test_data() -> Vec { vec![ LicenseInfo { name: "crate1".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "crate2".to_string(), version: "2.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "crate3".to_string(), version: "3.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "crate4".to_string(), version: "4.0.0".to_string(), license: Some("Unknown".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Unknown, osi_status: crate::licenses::OsiStatus::Unknown, }, ] } fn get_test_data_with_unknown_compatibility() -> Vec { vec![ LicenseInfo { name: "crate1".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Unknown, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "crate2".to_string(), version: "2.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Unknown, osi_status: crate::licenses::OsiStatus::Approved, }, ] } #[test] fn test_generate_report_empty_data() { let data = vec![]; let config = ReportConfig::new( false, false, false, false, false, None, None, None, false, None, ); let result = generate_report(data, config); assert_eq!(result, (false, false)); // No restrictive or incompatible licenses } #[test] fn test_generate_report_non_strict() { let data = get_test_data(); let config = ReportConfig::new( false, false, false, false, false, None, None, Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); // Has both restrictive and incompatible licenses } #[test] fn test_generate_report_strict() { let data = get_test_data(); let config = ReportConfig::new( false, false, false, true, false, None, None, Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); // In strict mode, still has both restrictive and incompatible } #[test] fn test_generate_report_json() { let data = get_test_data(); let config = ReportConfig::new( true, false, false, false, false, None, None, Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); } #[test] fn test_generate_report_yaml() { let data = get_test_data(); let config = ReportConfig::new( false, true, false, false, false, None, None, Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); } #[test] fn test_generate_report_verbose() { let data = get_test_data(); let config = ReportConfig::new( false, false, true, false, false, None, None, Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); } #[test] fn test_generate_report_no_project_license() { let data = get_test_data_with_unknown_compatibility(); let config = ReportConfig::new( false, false, false, false, false, None, None, None, false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, false)); // Has restrictive but no incompatible since no project license } #[test] fn test_github_output_format() { let data = get_test_data(); let temp_dir = setup(); let output_path = temp_dir.path().join("github_output.txt"); let config = ReportConfig::new( false, false, false, false, false, Some(CiFormat::Github), Some(output_path.to_str().unwrap().to_string()), Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); let content = match fs::read_to_string(&output_path) { Ok(content) => content, Err(err) => { panic!("Failed to read output file: {err}"); } }; assert!(content.contains("::warning title=Restrictive License::")); assert!(content.contains("::error title=Incompatible License::")); assert!(content.contains("::notice title=Project License::")); assert!(content.contains("::notice title=License Check Summary::")); } #[test] fn test_jenkins_output_format() { let data = get_test_data(); let temp_dir = setup(); let output_path = temp_dir.path().join("jenkins_output.xml"); let config = ReportConfig::new( false, false, false, false, false, Some(CiFormat::Jenkins), Some(output_path.to_str().unwrap().to_string()), Some("MIT".to_string()), false, None, ); let result = generate_report(data, config); assert_eq!(result, (true, true)); let content = match fs::read_to_string(&output_path) { Ok(content) => content, Err(err) => { panic!("Failed to read output file: {err}"); } }; assert!(content.contains("")); assert!(content.contains("")); assert!(content.contains(" content, Err(err) => { panic!("Failed to read output file: {err}"); } }; assert!(content.contains("")); assert!(content.contains("")); assert!(content.contains(" = test_data .iter() .filter(|info| info.compatibility == LicenseCompatibility::Incompatible) .collect(); assert!(!incompatible_licenses.is_empty()); print_incompatible_licenses_table(&incompatible_licenses, "MIT"); // If no panic, test passes } #[test] fn test_print_summary_footer_with_compatibility() { // This is primarily a visual test let license_info = get_test_data(); print_summary_footer(&license_info, Some("MIT")); // If no panic, test passes } #[test] fn test_print_summary_footer_without_compatibility() { // This is primarily a visual test let license_info = get_test_data_with_unknown_compatibility(); print_summary_footer(&license_info, None); // If no panic, test passes } #[test] fn test_report_config_default_values() { let config = ReportConfig::new( false, // json false, // yaml false, // verbose false, // strict false, // incompatible None, // ci_format None, // output_file None, // project_license false, // gist None, // osi ); assert!(!config.json); assert!(!config.yaml); assert!(!config.verbose); assert!(!config.restrictive); assert!(config.ci_format.is_none()); assert!(config.output_file.is_none()); assert!(config.project_license.is_none()); } #[test] fn test_generate_report_all_permissive() { let data = vec![ LicenseInfo { name: "package1".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "2.0.0".to_string(), license: Some("BSD-3-Clause".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let config = ReportConfig::new( false, false, false, false, false, None, None, Some("MIT".to_string()), false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(!has_restrictive); assert!(!has_incompatible); } #[test] fn test_generate_report_mixed_licenses() { let data = vec![ LicenseInfo { name: "good_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "bad_package".to_string(), version: "2.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let config = ReportConfig::new( false, false, false, false, false, None, None, Some("MIT".to_string()), false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(has_restrictive); assert!(has_incompatible); } #[test] fn test_generate_report_strict_mode_filters() { let data = vec![ LicenseInfo { name: "permissive_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "restrictive_package".to_string(), version: "2.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let config = ReportConfig::new( false, false, false, true, false, None, None, Some("MIT".to_string()), false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(has_restrictive); assert!(has_incompatible); } #[test] fn test_generate_report_json_output() { let data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let config = ReportConfig::new( true, false, false, false, false, None, None, None, false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(!has_restrictive); assert!(!has_incompatible); } #[test] fn test_generate_report_yaml_output() { let data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let config = ReportConfig::new( false, true, false, false, false, None, None, None, false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(!has_restrictive); assert!(!has_incompatible); } #[test] fn test_generate_report_verbose_output() { let data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let config = ReportConfig::new( false, false, true, false, false, None, None, Some("MIT".to_string()), false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(!has_restrictive); assert!(!has_incompatible); } #[test] fn test_github_output_format_stdout() { let data = vec![LicenseInfo { name: "restrictive_package".to_string(), version: "1.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let config = ReportConfig::new( false, false, false, false, false, Some(CiFormat::Github), None, Some("MIT".to_string()), false, None, ); let (has_restrictive, has_incompatible) = generate_report(data, config); assert!(has_restrictive); assert!(has_incompatible); } #[test] fn test_output_github_format_file_write_error() { let data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; output_github_format( &data, Some("/invalid/path/that/does/not/exist/output.txt"), Some("MIT"), ); } #[test] fn test_output_jenkins_format_file_write_error() { let data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; output_jenkins_format( &data, Some("/invalid/path/that/does/not/exist/output.xml"), Some("MIT"), ); } #[test] fn test_print_restrictive_licenses_table() { let data = [ LicenseInfo { name: "restrictive1".to_string(), version: "1.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "restrictive2".to_string(), version: "2.0.0".to_string(), license: Some("AGPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let restrictive_refs: Vec<&LicenseInfo> = data.iter().collect(); print_restrictive_licenses_table(&restrictive_refs); } #[test] fn test_table_formatter_column_width_calculation() { let headers = vec!["A".to_string(), "BB".to_string(), "CCC".to_string()]; let mut formatter = TableFormatter::new(headers); assert_eq!(formatter.column_widths[0], 1); // "A" assert_eq!(formatter.column_widths[1], 2); // "BB" assert_eq!(formatter.column_widths[2], 3); // "CCC" let row = vec!["AAAA".to_string(), "B".to_string(), "CC".to_string()]; formatter.add_row(&row); assert_eq!(formatter.column_widths[0], 4); // "AAAA" assert_eq!(formatter.column_widths[1], 2); // "BB" (header is longer) assert_eq!(formatter.column_widths[2], 3); // "CCC" (header is longer) } #[test] fn test_report_config_debug() { let config = ReportConfig::new( true, false, true, false, false, Some(CiFormat::Github), Some("test.txt".to_string()), Some("MIT".to_string()), false, None, ); let debug_str = format!("{config:?}"); assert!(debug_str.contains("ReportConfig")); assert!(debug_str.contains("json: true")); assert!(debug_str.contains("yaml: false")); assert!(debug_str.contains("Github")); } } feluda-1.11.1/src/sbom/cyclonedx.rs000064400000000000000000000465561046102023000152520ustar 00000000000000use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::debug::{log, FeludaError, FeludaResult, LogLevel}; use crate::sbom::spdx::SpdxDocument; /// CycloneDX v1.5 BOM structure #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CycloneDxBom { /// BOM format identifier (required) pub bom_format: String, // "CycloneDX" /// Specification version (required) pub spec_version: String, // "1.5" /// Serial number for the BOM (optional but recommended) #[serde(skip_serializing_if = "Option::is_none")] pub serial_number: Option, /// BOM version (optional) #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, /// Metadata about the BOM (optional) #[serde(skip_serializing_if = "Option::is_none")] pub metadata: Option, /// List of components (optional) #[serde(skip_serializing_if = "Vec::is_empty")] pub components: Vec, } /// CycloneDX metadata structure #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CycloneDxMetadata { /// Timestamp when the BOM was created (optional) #[serde(skip_serializing_if = "Option::is_none")] pub timestamp: Option>, /// Tools used to create the BOM (optional) #[serde(skip_serializing_if = "Option::is_none")] pub tools: Option, /// Authors of the BOM (optional) #[serde(skip_serializing_if = "Vec::is_empty")] pub authors: Vec, /// Component that represents the BOM (optional) #[serde(skip_serializing_if = "Option::is_none")] pub component: Option, } /// CycloneDX tools structure (v1.5 format) #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CycloneDxTools { /// Tool components #[serde(skip_serializing_if = "Vec::is_empty")] pub components: Vec, /// Tool services (optional) #[serde(skip_serializing_if = "Vec::is_empty")] pub services: Vec, } /// CycloneDX service structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CycloneDxService { /// Service name (required) pub name: String, /// Service version (optional) #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, } /// CycloneDX tool structure (individual tool entry) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CycloneDxTool { /// Component type (required for tools/components) #[serde(rename = "type")] pub component_type: String, /// Tool name (required) pub name: String, /// Tool version (optional) #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, } /// CycloneDX contact structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CycloneDxContact { /// Name of the contact #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, /// Email of the contact #[serde(skip_serializing_if = "Option::is_none")] pub email: Option, } /// CycloneDX component structure #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CycloneDxComponent { /// Component type (required) #[serde(rename = "type")] pub component_type: String, // "library", "application", "framework", etc. /// Component name (required) pub name: String, /// Component version (optional) #[serde(skip_serializing_if = "Option::is_none")] pub version: Option, /// Component description (optional) #[serde(skip_serializing_if = "Option::is_none")] pub description: Option, /// Component scope (optional) #[serde(skip_serializing_if = "Option::is_none")] pub scope: Option, // "required", "optional", "excluded" /// Component licenses (optional) #[serde(skip_serializing_if = "Vec::is_empty")] pub licenses: Vec, /// Copyright information (optional) #[serde(skip_serializing_if = "Option::is_none")] pub copyright: Option, /// Package URL (PURL) (optional) #[serde(skip_serializing_if = "Option::is_none")] pub purl: Option, /// External references (optional) #[serde(skip_serializing_if = "Vec::is_empty")] pub external_references: Vec, } /// CycloneDX license choice (either a license object or expression string) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum CycloneDxLicenseChoice { /// License object wrapper License { license: CycloneDxLicense }, /// SPDX license expression wrapper Expression { expression: String }, } /// CycloneDX license object #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CycloneDxLicense { /// SPDX license identifier (optional) #[serde(skip_serializing_if = "Option::is_none")] pub id: Option, /// License name (optional) #[serde(skip_serializing_if = "Option::is_none")] pub name: Option, /// License URL (optional) #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, } /// CycloneDX external reference structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CycloneDxExternalReference { /// Reference type (required) #[serde(rename = "type")] pub ref_type: String, // "website", "vcs", "distribution", etc. /// Reference URL (required) pub url: String, /// Reference comment (optional) #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, } impl CycloneDxBom { pub fn new() -> Self { let serial_number = format!("urn:uuid:{}", Uuid::new_v4()); Self { bom_format: "CycloneDX".to_string(), spec_version: "1.5".to_string(), serial_number: Some(serial_number), version: Some(1), metadata: Some(CycloneDxMetadata { timestamp: Some(Utc::now()), tools: Some(CycloneDxTools { components: vec![CycloneDxTool { component_type: "application".to_string(), name: "feluda".to_string(), version: Some(env!("CARGO_PKG_VERSION").to_string()), }], services: vec![], }), authors: vec![], component: None, }), components: Vec::new(), } } pub fn add_component(&mut self, component: CycloneDxComponent) { self.components.push(component); } } impl Default for CycloneDxBom { fn default() -> Self { Self::new() } } /// Convert SPDX license to CycloneDX license format fn convert_spdx_license_to_cyclonedx(spdx_license: &str) -> CycloneDxLicenseChoice { // Check if it looks like an SPDX expression (contains AND, OR, WITH) if spdx_license.contains(" AND ") || spdx_license.contains(" OR ") || spdx_license.contains(" WITH ") { CycloneDxLicenseChoice::Expression { expression: spdx_license.to_string(), } } else if spdx_license == "NOASSERTION" { // Handle NOASSERTION case CycloneDxLicenseChoice::License { license: CycloneDxLicense { id: None, name: Some("NOASSERTION".to_string()), url: None, }, } } else { // Single license identifier CycloneDxLicenseChoice::License { license: CycloneDxLicense { id: Some(spdx_license.to_string()), name: None, url: None, }, } } } /// Convert SPDX document to CycloneDX BOM pub fn convert_spdx_to_cyclonedx(spdx_doc: &SpdxDocument) -> CycloneDxBom { let mut bom = CycloneDxBom::new(); // Convert each SPDX package to CycloneDX component for spdx_package in &spdx_doc.packages { let mut component = CycloneDxComponent { component_type: "library".to_string(), // Default to library for dependencies name: spdx_package.name.clone(), version: spdx_package.version_info.clone(), description: None, scope: Some("required".to_string()), // Default scope licenses: Vec::new(), copyright: spdx_package.copyright_text.clone(), purl: None, // Could be enhanced in the future external_references: Vec::new(), }; // Convert licenses if let Some(ref license_concluded) = spdx_package.license_concluded { component .licenses .push(convert_spdx_license_to_cyclonedx(license_concluded)); } else if let Some(ref license_declared) = spdx_package.license_declared { component .licenses .push(convert_spdx_license_to_cyclonedx(license_declared)); } bom.add_component(component); } log( LogLevel::Info, &format!( "Converted {} SPDX packages to CycloneDX components", spdx_doc.packages.len() ), ); bom } pub fn generate_cyclonedx_output( spdx_doc: &SpdxDocument, output_file: Option, ) -> FeludaResult<()> { log(LogLevel::Info, "Generating CycloneDX 1.5 BOM output"); // Convert SPDX document to CycloneDX BOM let cyclonedx_bom = convert_spdx_to_cyclonedx(spdx_doc); // Serialize to JSON let json_output = serde_json::to_string_pretty(&cyclonedx_bom).map_err(|e| { FeludaError::Serialization(format!("Failed to serialize CycloneDX BOM: {e}")) })?; // Output to file or stdout if let Some(file_path) = output_file { let cyclonedx_file = if file_path.ends_with(".json") { file_path } else { format!( "{}.cyclonedx.json", file_path.trim_end_matches(".cyclonedx") ) }; std::fs::write(&cyclonedx_file, &json_output) .map_err(|e| FeludaError::FileWrite(format!("Failed to write CycloneDX file: {e}")))?; println!("🧪 CycloneDX BOM written to: {cyclonedx_file} (EXPERIMENTAL)"); log( LogLevel::Info, &format!("CycloneDX BOM written to: {cyclonedx_file}"), ); } else { println!("=== CycloneDX BOM (EXPERIMENTAL) ==="); println!("{json_output}"); } Ok(()) } #[cfg(test)] mod tests { use super::*; use crate::sbom::spdx::{SpdxDocument, SpdxPackage}; #[test] fn test_cyclonedx_bom_creation() { let bom = CycloneDxBom::new(); assert_eq!(bom.bom_format, "CycloneDX"); assert_eq!(bom.spec_version, "1.5"); assert!(bom.serial_number.is_some()); assert_eq!(bom.version, Some(1)); assert!(bom.metadata.is_some()); assert!(bom.components.is_empty()); } #[test] fn test_cyclonedx_bom_add_component() { let mut bom = CycloneDxBom::new(); let component = CycloneDxComponent { component_type: "library".to_string(), name: "test-package".to_string(), version: Some("1.0.0".to_string()), description: None, scope: Some("required".to_string()), licenses: Vec::new(), copyright: None, purl: None, external_references: Vec::new(), }; bom.add_component(component); assert_eq!(bom.components.len(), 1); assert_eq!(bom.components[0].name, "test-package"); } #[test] fn test_convert_spdx_license_to_cyclonedx() { // Test simple license let license = convert_spdx_license_to_cyclonedx("MIT"); match license { CycloneDxLicenseChoice::License { license } => { assert_eq!(license.id, Some("MIT".to_string())); assert_eq!(license.name, None); assert_eq!(license.url, None); } _ => panic!("Expected License variant"), } // Test SPDX expression let license = convert_spdx_license_to_cyclonedx("MIT OR Apache-2.0"); match license { CycloneDxLicenseChoice::Expression { expression } => { assert_eq!(expression, "MIT OR Apache-2.0"); } _ => panic!("Expected Expression variant"), } // Test NOASSERTION let license = convert_spdx_license_to_cyclonedx("NOASSERTION"); match license { CycloneDxLicenseChoice::License { license } => { assert_eq!(license.id, None); assert_eq!(license.name, Some("NOASSERTION".to_string())); assert_eq!(license.url, None); } _ => panic!("Expected License variant"), } } #[test] fn test_convert_spdx_to_cyclonedx() { let mut spdx_doc = SpdxDocument::new("test-project"); // Add a test package let package = SpdxPackage::new("test-package".to_string(), &spdx_doc.document_namespace) .with_version("1.0.0".to_string()) .with_license("MIT".to_string()); spdx_doc.add_package(package); let cyclonedx_bom = convert_spdx_to_cyclonedx(&spdx_doc); assert_eq!(cyclonedx_bom.bom_format, "CycloneDX"); assert_eq!(cyclonedx_bom.spec_version, "1.5"); assert_eq!(cyclonedx_bom.components.len(), 1); let component = &cyclonedx_bom.components[0]; assert_eq!(component.name, "test-package"); assert_eq!(component.version, Some("1.0.0".to_string())); assert_eq!(component.component_type, "library"); assert_eq!(component.scope, Some("required".to_string())); assert!(!component.licenses.is_empty()); } #[test] fn test_cyclonedx_serialization() { let bom = CycloneDxBom::new(); let json = serde_json::to_string_pretty(&bom).unwrap(); // Verify it contains required fields assert!(json.contains("\"bomFormat\": \"CycloneDX\"")); assert!(json.contains("\"specVersion\": \"1.5\"")); assert!(json.contains("\"serialNumber\"")); assert!(json.contains("\"metadata\"")); } #[test] fn test_cyclonedx_component_serialization() { let component = CycloneDxComponent { component_type: "library".to_string(), name: "test-lib".to_string(), version: Some("2.1.0".to_string()), description: Some("A test library".to_string()), scope: Some("required".to_string()), licenses: vec![CycloneDxLicenseChoice::License { license: CycloneDxLicense { id: Some("MIT".to_string()), name: None, url: None, }, }], copyright: Some("Copyright 2023 Test".to_string()), purl: None, external_references: Vec::new(), }; let json = serde_json::to_string_pretty(&component).unwrap(); // Verify serialization assert!(json.contains("\"type\": \"library\"")); assert!(json.contains("\"name\": \"test-lib\"")); assert!(json.contains("\"version\": \"2.1.0\"")); assert!(json.contains("\"scope\": \"required\"")); assert!(json.contains("\"id\": \"MIT\"")); } #[test] fn test_cyclonedx_license_choice_variants() { // Test License variant let license_variant = CycloneDxLicenseChoice::License { license: CycloneDxLicense { id: Some("Apache-2.0".to_string()), name: None, url: Some("https://opensource.org/licenses/Apache-2.0".to_string()), }, }; let json = serde_json::to_string(&license_variant).unwrap(); assert!(json.contains("\"id\":\"Apache-2.0\"")); assert!(json.contains("\"url\":\"https://opensource.org/licenses/Apache-2.0\"")); // Test Expression variant let expression_variant = CycloneDxLicenseChoice::Expression { expression: "MIT AND Apache-2.0".to_string(), }; let json = serde_json::to_string(&expression_variant).unwrap(); assert!(json.contains("\"expression\":\"MIT AND Apache-2.0\"")); } #[test] fn test_cyclonedx_metadata_with_tools() { let bom = CycloneDxBom::new(); let metadata = bom.metadata.unwrap(); assert!(metadata.timestamp.is_some()); assert!(metadata.tools.is_some()); let tools = metadata.tools.unwrap(); assert!(!tools.components.is_empty()); assert!(tools.services.is_empty()); let tool = &tools.components[0]; assert_eq!(tool.name, "feluda"); assert!(tool.version.is_some()); assert_eq!(tool.component_type, "application"); } #[test] fn test_complex_spdx_to_cyclonedx_conversion() { let mut spdx_doc = SpdxDocument::new("complex-project"); // Add multiple packages with different license formats let packages = vec![ ("simple-mit", "1.0.0", "MIT"), ("dual-license", "2.1.0", "MIT OR Apache-2.0"), ( "complex-expr", "3.0.0", "(MIT OR Apache-2.0) AND BSD-3-Clause", ), ("no-license", "0.1.0", "NOASSERTION"), ]; for (name, version, license) in packages { let package = SpdxPackage::new(name.to_string(), &spdx_doc.document_namespace) .with_version(version.to_string()) .with_license(license.to_string()); spdx_doc.add_package(package); } let cyclonedx_bom = convert_spdx_to_cyclonedx(&spdx_doc); assert_eq!(cyclonedx_bom.components.len(), 4); // Verify each component was converted correctly for component in &cyclonedx_bom.components { assert_eq!(component.component_type, "library"); assert_eq!(component.scope, Some("required".to_string())); match component.name.as_str() { "simple-mit" => { assert!(!component.licenses.is_empty()); if let CycloneDxLicenseChoice::License { license } = &component.licenses[0] { assert_eq!(license.id, Some("MIT".to_string())); } } "dual-license" => { assert!(!component.licenses.is_empty()); if let CycloneDxLicenseChoice::Expression { expression } = &component.licenses[0] { assert_eq!(expression, "MIT OR Apache-2.0"); } } "complex-expr" => { assert!(!component.licenses.is_empty()); if let CycloneDxLicenseChoice::Expression { expression } = &component.licenses[0] { assert_eq!(expression, "(MIT OR Apache-2.0) AND BSD-3-Clause"); } } "no-license" => { assert!(!component.licenses.is_empty()); if let CycloneDxLicenseChoice::License { license } = &component.licenses[0] { assert_eq!(license.name, Some("NOASSERTION".to_string())); } } _ => panic!("Unexpected component name: {}", component.name), } } } } feluda-1.11.1/src/sbom/mod.rs000064400000000000000000000060051046102023000140220ustar 00000000000000pub mod cyclonedx; pub mod spdx; pub mod validate; use crate::cli::SbomFormat; use crate::debug::{log, FeludaError, FeludaResult, LogLevel}; use crate::licenses::LicenseCompatibility; use crate::parser::parse_root; use cyclonedx::generate_cyclonedx_output; use spdx::{generate_spdx_output, SpdxDocument, SpdxPackage}; pub fn handle_sbom_command( path: String, format: &SbomFormat, output_file: Option, ) -> FeludaResult<()> { log(LogLevel::Info, &format!("Generating SBOM for path: {path}")); // Parse project dependencies using existing parser let analyzed_data = parse_root(&path, None, false, false) .map_err(|e| FeludaError::Parser(format!("Failed to parse dependencies: {e}")))?; log( LogLevel::Info, &format!("Found {} dependencies", analyzed_data.len()), ); // Extract project name from path let project_name = std::path::Path::new(&path) .file_name() .and_then(|name| name.to_str()) .unwrap_or("project"); // Convert to SPDX-compliant format let mut spdx_doc = SpdxDocument::new(project_name); for dependency in analyzed_data { let mut package = SpdxPackage::new(dependency.name.clone(), &spdx_doc.document_namespace) .with_version(dependency.version.clone()); let force_noassertion = std::env::var("FELUDA_FORCE_NOASSERTION_LICENSES") .map(|v| v.eq_ignore_ascii_case("true")) .unwrap_or(false); let license_str = if force_noassertion { log( LogLevel::Info, "Forcing all licenses to NOASSERTION due to environment variable", ); "NOASSERTION" } else { dependency.license.as_deref().unwrap_or("NOASSERTION") }; package = package.with_license(license_str.to_string()); // TODO: Store Feluda-specific data as SPDX annotations let _compatibility_info = format!( "License compatibility: {}, Restrictive: {}", match dependency.compatibility { LicenseCompatibility::Compatible => "compatible", LicenseCompatibility::Incompatible => "incompatible", LicenseCompatibility::Unknown => "unknown", }, dependency.is_restrictive ); // TODO: Add dependency relationships to SPDX when LicenseInfo supports it spdx_doc.add_package(package); } log( LogLevel::Info, &format!( "Generated SPDX document with {} packages", spdx_doc.packages.len() ), ); // Generate output based on format match format { SbomFormat::Spdx => { generate_spdx_output(&spdx_doc, output_file)?; } SbomFormat::Cyclonedx => { generate_cyclonedx_output(&spdx_doc, output_file)?; } SbomFormat::All => { generate_spdx_output(&spdx_doc, output_file.clone())?; generate_cyclonedx_output(&spdx_doc, output_file)?; } } Ok(()) } feluda-1.11.1/src/sbom/spdx.rs000064400000000000000000001651131046102023000142270ustar 00000000000000use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::debug::{log, FeludaError, FeludaResult, LogLevel}; /// Character validation for SPDX compliance /// /// This module enforces SPDX 2.3 specification character requirements across all fields. /// See: https://spdx.github.io/spdx-spec/v2.3/ /// /// SPDX imposes strict character restrictions to ensure: /// 1. JSON serialization safety - prevents JSON injection /// 2. Cross-platform compatibility - ensures data portability /// 3. Standard compliance - follows SPDX specification requirements mod spdx_charset { /// Characters forbidden in ALL SPDX fields for safety /// These characters could break JSON serialization or violate SPDX spec pub const GLOBALLY_FORBIDDEN: &[char] = &['"', '\\', '\n', '\r', '\t']; /// Valid characters for license expressions per SPDX spec /// Includes: alphanumeric, dot, hyphen, plus, parentheses, spaces #[allow(dead_code)] pub const LICENSE_VALID_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-+() "; /// Valid characters for SPDX identifiers (after SPDXRef- prefix) /// Per SPDX spec: Letters, numbers, hyphens, underscores only pub const SPDXID_VALID_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_."; /// Characters that are problematic for various reasons /// - Shell metacharacters: & | [ ] < > $ ( ) * ? ~ ` ! # /// - JSON-adjacent: { } = (could interfere with templates) /// - Special purposes: @ % ^ (reserved for future use in SPDX) pub const PROBLEMATIC_CHARS: &[char] = &[ '&', '|', '[', ']', '<', '>', '=', '*', '?', '^', '$', '%', '#', '@', '!', '~', '`', '{', '}', ]; /// Validates a string for presence of globally forbidden characters pub fn contains_forbidden_chars(s: &str) -> bool { s.chars().any(|c| GLOBALLY_FORBIDDEN.contains(&c)) } /// Validates a string contains only ASCII characters #[allow(dead_code)] pub fn is_valid_ascii(s: &str) -> bool { s.is_ascii() } /// Checks if string contains any problematic characters (more lenient) pub fn contains_problematic_chars(s: &str) -> bool { s.chars().any(|c| PROBLEMATIC_CHARS.contains(&c)) } } /// Validate if a string looks like a valid SPDX license identifier or expression fn is_valid_spdx_license_format(license: &str) -> bool { // SPDX license identifiers can only contain: // - Letters, numbers, periods, hyphens, plus signs // - Logical operators: AND, OR, WITH // - Parentheses for grouping // - Spaces for separation let allowed_chars = license .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | '(' | ')' | ' ')); if !allowed_chars { return false; } // Check for valid logical operators let normalized = license.to_uppercase(); let has_invalid_operators = normalized.contains("&&") || normalized.contains("||") || normalized.contains("&") || normalized.contains("|"); if has_invalid_operators { return false; } !license.contains("..") && !license.contains("--") && !license.trim().is_empty() } pub fn convert_to_spdx_license_expression(license: &str) -> String { // Check for force NOASSERTION environment variable let force_noassertion = std::env::var("FELUDA_FORCE_NOASSERTION_LICENSES") .map(|v| v.eq_ignore_ascii_case("true")) .unwrap_or(false); if force_noassertion { log( LogLevel::Info, &format!("Force NOASSERTION mode: converting '{license}' to NOASSERTION"), ); return "NOASSERTION".to_string(); } let trimmed = license.trim(); if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("null") || trimmed.eq_ignore_ascii_case("undefined") || trimmed.eq_ignore_ascii_case("none") || trimmed == "-" || trimmed == "n/a" || trimmed.eq_ignore_ascii_case("unlicensed") || trimmed.eq_ignore_ascii_case("proprietary") || !trimmed.is_ascii() { return "NOASSERTION".to_string(); } let result = trimmed.replace(" / ", " OR ").replace("/", " OR "); // SPDX 2.3 License Expression Character Validation // Per SPDX specification, license expressions must only contain: // - SPDX license identifiers (alphanumeric, dots, hyphens, plus signs) // - Logical operators (AND, OR, WITH as keywords) // - Parentheses for grouping // - Spaces for separation // // Forbidden characters (security & compliance): // - Double quotes and backslashes (JSON safety) // - Template characters (${}, {}) (prevent injection) // - Operators as symbols (&, |) instead of keywords (SPDX compliance) // - Brackets, angle brackets, braces (shell/SPDX reserved) // - Math operators (=, *, ?, ^) (avoid ambiguity) // - Special chars (@, %, #, !, ~, `) (reserved/problematic) // Check for globally forbidden characters if spdx_charset::contains_forbidden_chars(&result) { log( LogLevel::Trace, &format!("License '{license}' contains forbidden characters -> NOASSERTION"), ); return "NOASSERTION".to_string(); } // Check for template/injection patterns if result.contains("{}") || result.contains("${") { log( LogLevel::Trace, &format!("License '{license}' contains template patterns -> NOASSERTION"), ); return "NOASSERTION".to_string(); } // Check for problematic characters if spdx_charset::contains_problematic_chars(&result) { log( LogLevel::Trace, &format!("License '{license}' contains problematic characters -> NOASSERTION"), ); return "NOASSERTION".to_string(); } // Check structural constraints if result.len() > 100 || !is_valid_spdx_license_format(&result) || result.is_empty() || result.trim() != result || result.contains(" ") { log( LogLevel::Trace, &format!( "License '{license}' failed structural validation -> '{result}' -> NOASSERTION" ), ); return "NOASSERTION".to_string(); } let safe_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-+() "; if !result.chars().all(|c| safe_chars.contains(c)) { log( LogLevel::Trace, &format!("License '{license}' has invalid characters -> '{result}' -> NOASSERTION"), ); return "NOASSERTION".to_string(); } if license != result { log( LogLevel::Trace, &format!("License conversion: '{license}' -> '{result}'"), ); } result } /// Validates SPDX identifier format compliance /// /// SPDX 2.3 identifiers must: /// 1. Start with "SPDXRef-" prefix /// 2. Contain only ASCII alphanumeric, hyphens, underscores, and dots /// 3. Not exceed 200 characters total /// 4. Not contain forbidden characters (quotes, backslashes, control chars) /// 5. Not contain non-ASCII characters fn is_valid_spdx_id_format(spdx_id: &str) -> bool { if !spdx_id.starts_with("SPDXRef-") { return false; } if spdx_id.len() > 200 { return false; } // Check for forbidden characters if spdx_charset::contains_forbidden_chars(spdx_id) { return false; } // Check for ASCII requirement if !spdx_id.is_ascii() { return false; } // Validate characters after SPDXRef- prefix let suffix = &spdx_id[8..]; // Skip "SPDXRef-" // Suffix must not be empty if suffix.is_empty() { return false; } suffix .chars() .all(|c| spdx_charset::SPDXID_VALID_CHARS.contains(c)) } /// Sanitize string for use in SPDX identifiers /// /// This function converts arbitrary strings into valid SPDX identifiers /// by replacing non-alphanumeric characters with underscores. /// If the result is empty, falls back to hash-based generation. fn sanitize_spdx_identifier(input: &str) -> String { if input.trim().is_empty() { return String::new(); } let sanitized = input .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' }) .collect::() .split('_') .filter(|s| !s.is_empty()) .collect::>() .join("_"); if sanitized.is_empty() { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); input.hash(&mut hasher); let hash = hasher.finish(); format!("pkg_{hash:08x}").chars().take(12).collect() } else { sanitized } } /// SPDX 2.3 compliant document structure #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SpdxDocument { /// SPDX version (required) pub spdx_version: String, // "SPDX-2.3" /// Data license (required) pub data_license: String, // "CC0-1.0" /// Document SPDX identifier (required) #[serde(rename = "SPDXID")] pub spdx_id: String, // "SPDXRef-DOCUMENT" /// Document name (required) pub name: String, /// Document namespace URI (required) pub document_namespace: String, /// Creation information (required) pub creation_info: CreationInfo, /// Packages in the document #[serde(skip_serializing_if = "Vec::is_empty")] pub packages: Vec, /// Relationships between elements #[serde(skip_serializing_if = "Vec::is_empty")] pub relationships: Vec, /// Annotations for non-standard data #[serde(skip_serializing_if = "Vec::is_empty")] pub annotations: Vec, } /// SPDX creation information #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CreationInfo { /// Creation timestamp (required) pub created: DateTime, /// Creators (required, at least one) pub creators: Vec, /// License list version (optional) #[serde(skip_serializing_if = "Option::is_none")] pub license_list_version: Option, } /// SPDX 2.3 compliant package structure #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SpdxPackage { /// Package name (required) pub name: String, /// Package SPDX identifier (required) #[serde(rename = "SPDXID")] pub spdx_id: String, /// Download location (required) pub download_location: String, // URL, VCS, "NONE", or "NOASSERTION" /// Files analyzed flag (required) pub files_analyzed: bool, /// Package version (optional) #[serde(skip_serializing_if = "Option::is_none")] pub version_info: Option, /// License concluded (optional but important) #[serde(skip_serializing_if = "Option::is_none")] pub license_concluded: Option, /// License declared (optional) #[serde(skip_serializing_if = "Option::is_none")] pub license_declared: Option, /// License comments (optional) #[serde(skip_serializing_if = "Option::is_none")] pub license_comments: Option, /// Copyright text (optional but important) #[serde(skip_serializing_if = "Option::is_none")] pub copyright_text: Option, /// Package comment (optional) #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, /// External references (optional) #[serde(skip_serializing_if = "Vec::is_empty")] pub external_refs: Vec, } /// SPDX external reference #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ExternalReference { pub reference_category: String, pub reference_type: String, pub reference_locator: String, #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, } /// SPDX relationship between elements #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Relationship { #[serde(rename = "spdxElementId")] pub spdx_element_id: String, pub relationship_type: String, pub related_spdx_element: String, #[serde(skip_serializing_if = "Option::is_none")] pub comment: Option, } /// SPDX annotation for non-standard data #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Annotation { pub annotator: String, pub annotation_date: DateTime, pub annotation_type: String, #[serde(rename = "spdxIdentifierReference")] pub spdx_identifier_reference: String, pub comment: String, } impl SpdxDocument { pub fn new(project_name: &str) -> Self { let doc_id = Uuid::new_v4(); Self { spdx_version: "SPDX-2.3".to_string(), data_license: "CC0-1.0".to_string(), spdx_id: "SPDXRef-DOCUMENT".to_string(), name: format!("{}-{}", project_name, doc_id.simple()), document_namespace: format!("https://anirudha.dev/feluda/spdx/{doc_id}"), creation_info: CreationInfo { created: Utc::now(), creators: vec![format!("Tool: Feluda-{}", env!("CARGO_PKG_VERSION"))], license_list_version: None, }, packages: Vec::new(), relationships: Vec::new(), annotations: Vec::new(), } } pub fn add_package(&mut self, package: SpdxPackage) { // Add relationship: document describes package let relationship = Relationship { spdx_element_id: self.spdx_id.clone(), relationship_type: "DESCRIBES".to_string(), related_spdx_element: package.spdx_id.clone(), comment: None, }; self.packages.push(package); self.relationships.push(relationship); } #[allow(dead_code)] pub fn add_annotation(&mut self, spdx_ref: String, comment: String, annotation_type: String) { let annotation = Annotation { annotator: format!("Tool: Feluda-{}", env!("CARGO_PKG_VERSION")), annotation_date: Utc::now(), annotation_type, spdx_identifier_reference: spdx_ref, comment, }; self.annotations.push(annotation); } } impl SpdxPackage { #[allow(dead_code)] pub fn new(name: String, _document_namespace: &str) -> Self { let sanitized_name = sanitize_spdx_identifier(&name); Self { name, spdx_id: format!("SPDXRef-Package-{sanitized_name}"), download_location: "NOASSERTION".to_string(), files_analyzed: false, version_info: None, license_concluded: None, license_declared: None, license_comments: None, copyright_text: Some("NOASSERTION".to_string()), comment: None, external_refs: Vec::new(), } } pub fn with_version(mut self, version: String) -> Self { self.version_info = Some(version.clone()); use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let combined_input = format!("{}_{}", self.name, version); let mut hasher = DefaultHasher::new(); combined_input.hash(&mut hasher); let hash = hasher.finish(); self.spdx_id = format!("SPDXRef-Package-pkg{hash:016x}"); log( LogLevel::Trace, &format!( "Generated SPDX ID '{}' for package '{}' version '{version}'", self.spdx_id, self.name ), ); self } pub fn with_license(mut self, license: String) -> Self { let spdx_license = convert_to_spdx_license_expression(&license); if license != spdx_license { log( LogLevel::Trace, &format!("Converted license '{license}' to SPDX format: '{spdx_license}'"), ); } let final_license = if spdx_license.trim().is_empty() { "NOASSERTION".to_string() } else { spdx_license }; self.license_declared = Some(final_license.clone()); self.license_concluded = Some(final_license); self } /// Sets the download location with SPDX validation /// /// Per SPDX 2.3 spec, download location must be: /// - A valid URL (http://, https://, ftp://, etc.) /// - A VCS location (git+https://, svn://, etc.) /// - The literal string "NOASSERTION" if location is unknown /// - The literal string "NONE" if no download location is available /// /// Special characters and non-ASCII characters are not allowed in URLs. #[allow(dead_code)] pub fn with_download_location(mut self, location: String) -> Self { // Validate download location let validated = if location.trim().is_empty() || location.eq_ignore_ascii_case("noassertion") || location.eq_ignore_ascii_case("none") { location } else if spdx_charset::contains_forbidden_chars(&location) || !location.is_ascii() { log( LogLevel::Warn, &format!( "Download location '{location}' contains invalid characters, using NOASSERTION" ), ); "NOASSERTION".to_string() } else { location }; self.download_location = validated; self } /// Sets the copyright text with SPDX validation /// /// Copyright text should be in the format: /// "(C) Year Name" or "Copyright Year Name" /// Or the literal string "NOASSERTION" if not available /// /// Per SPDX spec: "This field may contain multiple copyrights separated by newlines" /// However, we enforce single-line format for JSON safety. #[allow(dead_code)] pub fn with_copyright(mut self, copyright: String) -> Self { // Validate copyright text let validated = if copyright.trim().is_empty() { "NOASSERTION".to_string() } else if spdx_charset::contains_forbidden_chars(©right) || !copyright.is_ascii() { log( LogLevel::Warn, "Copyright text contains invalid characters, using NOASSERTION", ); "NOASSERTION".to_string() } else if copyright.len() > 1000 { log( LogLevel::Warn, "Copyright text exceeds 1000 character limit", ); "NOASSERTION".to_string() } else { copyright }; self.copyright_text = Some(validated); self } /// Sets a comment with SPDX validation /// /// Comments can provide additional information about the package /// but must conform to SPDX character restrictions. #[allow(dead_code)] pub fn with_comment(mut self, comment: String) -> Self { // Validate comment let validated = if comment.trim().is_empty() { None } else if spdx_charset::contains_forbidden_chars(&comment) || !comment.is_ascii() { log( LogLevel::Warn, "Comment contains invalid characters, skipping", ); None } else if comment.len() > 500 { // Comments have reasonable length limit log(LogLevel::Warn, "Comment exceeds 500 character limit"); None } else { Some(comment) }; self.comment = validated; self } /// Adds an external reference for package metadata /// /// External references link a package to external sources of information. /// Per SPDX 2.3 spec, common reference categories include: /// - "SECURITY_OTHER" for security-related references /// - "PACKAGE_MANAGER" for package manager records /// - "OTHER" for miscellaneous references /// /// Example: /// ```ignore /// package.add_external_ref( /// "PACKAGE_MANAGER".to_string(), /// "npm".to_string(), /// "lodash@4.17.21".to_string() /// ); /// ``` #[allow(dead_code)] pub fn add_external_ref(mut self, category: String, ref_type: String, locator: String) -> Self { // Validate external reference fields let is_valid = !spdx_charset::contains_forbidden_chars(&category) && category.is_ascii() && !spdx_charset::contains_forbidden_chars(&ref_type) && ref_type.is_ascii() && !spdx_charset::contains_forbidden_chars(&locator) && locator.is_ascii(); if is_valid && category.len() <= 100 && ref_type.len() <= 100 && locator.len() <= 500 { let external_ref = ExternalReference { reference_category: category, reference_type: ref_type, reference_locator: locator, comment: None, }; self.external_refs.push(external_ref); } else { log( LogLevel::Warn, "External reference validation failed, skipping", ); } self } } impl Default for SpdxDocument { fn default() -> Self { Self::new("project") } } fn validate_and_sanitize_spdx_package(package: &mut SpdxPackage) -> bool { let mut needs_fix = false; // SPDX Identifier Validation // =========================== // Validates the SPDX ID conforms to SPDX 2.3 specification: // - Must start with "SPDXRef-" prefix (required by spec) // - Must be 200 characters or less // - Must contain only ASCII alphanumeric, hyphens, underscores, dots // - Must not contain forbidden characters (quotes, backslashes, newlines, etc.) if !is_valid_spdx_id_format(&package.spdx_id) { use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; let mut hasher = DefaultHasher::new(); package.name.hash(&mut hasher); if let Some(ref version) = package.version_info { version.hash(&mut hasher); } let hash = hasher.finish(); let old_id = package.spdx_id.clone(); package.spdx_id = format!("SPDXRef-Package-pkg{hash:016x}"); log( LogLevel::Trace, &format!( "Regenerated invalid SPDX ID '{}' → '{}'", old_id, package.spdx_id ), ); needs_fix = true; } // Package Name Validation // ======================= // SPDX 2.3 spec requires package names to be: // - Non-empty (required field) // - ASCII characters only (for portability) // - No control characters or special characters // - Maximum 500 characters (reasonable limit) if package.name.is_empty() || spdx_charset::contains_forbidden_chars(&package.name) || !package.name.is_ascii() || package.name.len() > 500 { let old_name = package.name.clone(); package.name = package .name .chars() .filter(|&c| c.is_ascii_graphic() && !spdx_charset::GLOBALLY_FORBIDDEN.contains(&c)) .take(500) .collect(); if package.name.is_empty() { package.name = "unknown-package".to_string(); } log( LogLevel::Trace, &format!("Sanitized package name '{}' → '{}'", old_name, package.name), ); needs_fix = true; } // Download Location Validation // ============================= // SPDX 2.3 spec allows: // - Valid URLs (http://, https://, ftp://, etc.) // - VCS locations (git+https://, svn://, etc.) // - Literal "NOASSERTION" (if location is unknown) // - Literal "NONE" (if no download location exists) // // Must be ASCII-only and not exceed 1000 characters if spdx_charset::contains_forbidden_chars(&package.download_location) || !package.download_location.is_ascii() || package.download_location.len() > 1000 { package.download_location = "NOASSERTION".to_string(); needs_fix = true; } // Package Version Validation // ========================== // Version strings should follow semantic versioning convention // (e.g., "1.0.0", "2.5.3-alpha", etc.) // // Must be ASCII-only and not exceed 200 characters if let Some(ref mut version) = package.version_info { if spdx_charset::contains_forbidden_chars(version) || !version.is_ascii() || version.len() > 200 { let old_version = version.clone(); *version = version .chars() .filter(|&c| c.is_ascii_graphic() && !spdx_charset::GLOBALLY_FORBIDDEN.contains(&c)) .take(200) .collect(); if version.is_empty() { log( LogLevel::Trace, &format!( "Removed invalid version '{old_version}' (became empty after sanitization)" ), ); package.version_info = None; } else { log( LogLevel::Trace, &format!("Sanitized version '{old_version}' → '{version}'"), ); } needs_fix = true; } } // License Field Validation // ======================== // Both licenseConcluded and licenseDeclared must conform to SPDX spec: // - Either a valid SPDX license expression (e.g., "MIT", "Apache-2.0 OR MIT") // - Or the literal string "NOASSERTION" // - Or the literal string "NONE" (rarely used) // // Must be ASCII-only and not exceed 200 characters let validate_license = |license_opt: &mut Option, _field_name: &str| -> bool { if let Some(ref mut license) = license_opt { if license.trim().is_empty() || spdx_charset::contains_forbidden_chars(license) || license.len() > 200 || !license.is_ascii() || !is_valid_spdx_license_format(license) { *license = "NOASSERTION".to_string(); return true; } } false }; needs_fix |= validate_license(&mut package.license_declared, "license_declared"); needs_fix |= validate_license(&mut package.license_concluded, "license_concluded"); if package.license_declared.is_none() { package.license_declared = Some("NOASSERTION".to_string()); needs_fix = true; } if package.license_concluded.is_none() { package.license_concluded = Some("NOASSERTION".to_string()); needs_fix = true; } // Copyright Text Validation // ========================== // Copyright information should follow format: // "(C) Year Name" or "Copyright Year Name" // // However, SPDX spec allows flexibility here. The key requirement is: // - ASCII-only characters // - Maximum 1000 characters // - Not empty (required field per SPDX spec) if let Some(ref mut copyright) = package.copyright_text { if spdx_charset::contains_forbidden_chars(copyright) || !copyright.is_ascii() || copyright.len() > 1000 { *copyright = "NOASSERTION".to_string(); needs_fix = true; } } else { // Copyright is a required field in SPDX 2.3 package.copyright_text = Some("NOASSERTION".to_string()); needs_fix = true; } needs_fix } pub fn generate_spdx_output( spdx_doc: &SpdxDocument, output_file: Option, ) -> FeludaResult<()> { log(LogLevel::Info, "Generating SPDX 2.3 compliant output"); let mut safe_doc = spdx_doc.clone(); let mut total_fixes = 0; for package in &mut safe_doc.packages { if validate_and_sanitize_spdx_package(package) { total_fixes += 1; } } if total_fixes > 0 { log( LogLevel::Warn, &format!("Applied sanitization fixes to {total_fixes} packages"), ); } let json_output = serde_json::to_string_pretty(&safe_doc).map_err(|e| { FeludaError::Serialization(format!("Failed to serialize SPDX document: {e}")) })?; if json_output.contains("\\n") || json_output.contains("\\r") { return Err(FeludaError::InvalidData( "SPDX JSON contains invalid escaped characters".to_string(), )); } if let Some(file_path) = output_file { let spdx_file = if file_path.ends_with(".json") { file_path } else { format!("{}.spdx.json", file_path.trim_end_matches(".spdx")) }; std::fs::write(&spdx_file, &json_output) .map_err(|e| FeludaError::FileWrite(format!("Failed to write SPDX file: {e}")))?; println!("SPDX SBOM written to: {spdx_file}"); log( LogLevel::Info, &format!("SPDX SBOM written to: {spdx_file}"), ); } else { println!("=== SPDX SBOM ==="); println!("{json_output}"); } Ok(()) } #[cfg(test)] mod tests { use super::*; use serial_test::serial; #[test] #[serial] fn test_convert_to_spdx_license_expression() { // Ensure environment variable is not set std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test simple license assert_eq!(convert_to_spdx_license_expression("MIT"), "MIT"); // Test slash-separated licenses assert_eq!( convert_to_spdx_license_expression("MIT/Apache-2.0"), "MIT OR Apache-2.0" ); assert_eq!( convert_to_spdx_license_expression("Apache-2.0/MIT"), "Apache-2.0 OR MIT" ); // Test spaced slash-separated licenses assert_eq!( convert_to_spdx_license_expression("MIT / Apache-2.0"), "MIT OR Apache-2.0" ); assert_eq!( convert_to_spdx_license_expression("Apache-2.0 / MIT"), "Apache-2.0 OR MIT" ); // Test Unlicense case (now allowed) assert_eq!( convert_to_spdx_license_expression("Unlicense/MIT"), "Unlicense OR MIT" ); // Test already SPDX-compliant licenses assert_eq!( convert_to_spdx_license_expression("MIT OR Apache-2.0"), "MIT OR Apache-2.0" ); } #[test] fn test_spdx_package_unique_ids() { // Test that packages with same name but different versions get unique SPDX IDs let package1 = SpdxPackage::new("getrandom".to_string(), "https://example.com/test") .with_version("0.2.16".to_string()); let package2 = SpdxPackage::new("getrandom".to_string(), "https://example.com/test") .with_version("0.3.3".to_string()); // Both should use hash-based IDs assert!(package1.spdx_id.starts_with("SPDXRef-Package-pkg")); assert!(package2.spdx_id.starts_with("SPDXRef-Package-pkg")); assert_ne!(package1.spdx_id, package2.spdx_id); // Test that version sanitization works let package3 = SpdxPackage::new("test-package".to_string(), "https://example.com/test") .with_version("1.0.0-beta".to_string()); // Now uses hash-based ID for safety assert!(package3.spdx_id.starts_with("SPDXRef-Package-pkg")); } #[test] fn test_sanitize_spdx_identifier() { // Test normal cases assert_eq!(sanitize_spdx_identifier("lodash"), "lodash"); assert_eq!(sanitize_spdx_identifier("lodash-utils"), "lodash_utils"); // Test complex package names assert_eq!( sanitize_spdx_identifier("lodash.castarray"), "lodash_castarray" ); assert_eq!(sanitize_spdx_identifier("@types/node"), "types_node"); assert_eq!(sanitize_spdx_identifier("package@1.2.3"), "package_1_2_3"); // Test edge cases assert_eq!(sanitize_spdx_identifier("@babel/core"), "babel_core"); assert_eq!( sanitize_spdx_identifier("package-name-with.dots"), "package_name_with_dots" ); assert_eq!(sanitize_spdx_identifier("123-package"), "123_package"); // Test empty and special cases assert_eq!(sanitize_spdx_identifier(""), ""); assert_eq!(sanitize_spdx_identifier("a__b__c"), "a_b_c"); // Test cases that would previously result in empty strings let special_only = sanitize_spdx_identifier("___"); assert!(!special_only.is_empty()); assert!(special_only.starts_with("pkg_")); let symbols_only = sanitize_spdx_identifier("@#$%^&*()"); assert!(!symbols_only.is_empty()); assert!(symbols_only.starts_with("pkg_")); let unicode_only = sanitize_spdx_identifier("你好世界"); assert!(!unicode_only.is_empty()); // Unicode characters are non-ASCII-alphanumeric, so should use hash fallback assert!(unicode_only.starts_with("pkg_")); // Test that hash-based IDs are consistent let hash1 = sanitize_spdx_identifier("___"); let hash2 = sanitize_spdx_identifier("___"); assert_eq!(hash1, hash2); } #[test] #[serial] fn test_license_expression_edge_cases() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test empty license assert_eq!(convert_to_spdx_license_expression(""), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression(" "), "NOASSERTION"); // Test null/undefined cases assert_eq!(convert_to_spdx_license_expression("null"), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression("NULL"), "NOASSERTION"); assert_eq!( convert_to_spdx_license_expression("undefined"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("UNDEFINED"), "NOASSERTION" ); // Test other invalid patterns assert_eq!(convert_to_spdx_license_expression("none"), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression("NONE"), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression("-"), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression("n/a"), "NOASSERTION"); // Test template/interpolation patterns assert_eq!(convert_to_spdx_license_expression("MIT{}"), "NOASSERTION"); assert_eq!( convert_to_spdx_license_expression("${LICENSE}"), "NOASSERTION" ); // Test problematic characters that could break JSON assert_eq!( convert_to_spdx_license_expression("MIT\"quote"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT\\backslash"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT\nnewline"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT\ttab"), "NOASSERTION" ); assert_eq!(convert_to_spdx_license_expression("MIT&AND"), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression("MIT|OR"), "NOASSERTION"); assert_eq!( convert_to_spdx_license_expression("MIT[bracket]"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT{brace}"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT"), "NOASSERTION" ); // Test additional invalid patterns assert_eq!( convert_to_spdx_license_expression("unlicensed"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("proprietary"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT--invalid"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT..invalid"), "NOASSERTION" ); // Test very long license strings let long_license = "A".repeat(250); assert_eq!( convert_to_spdx_license_expression(&long_license), "NOASSERTION" ); // Test normal cases (ensure environment variable is not set) std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); assert_eq!(convert_to_spdx_license_expression("MIT"), "MIT"); assert_eq!( convert_to_spdx_license_expression("MIT OR Apache-2.0"), "MIT OR Apache-2.0" ); // Test cargo-style separators assert_eq!( convert_to_spdx_license_expression("MIT/Apache-2.0"), "MIT OR Apache-2.0" ); assert_eq!( convert_to_spdx_license_expression("MIT / Apache-2.0"), "MIT OR Apache-2.0" ); } #[test] #[serial] fn test_complex_package_names() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test the specific lodash.castarray case let package = SpdxPackage::new("lodash.castarray".to_string(), "https://example.com/test") .with_version("4.4.0".to_string()); // Now uses hash-based ID for safety assert!(package.spdx_id.starts_with("SPDXRef-Package-pkg")); // Test @types packages let types_package = SpdxPackage::new("@types/node".to_string(), "https://example.com/test") .with_version("18.15.0".to_string()); // Now uses hash-based ID for safety assert!(types_package.spdx_id.starts_with("SPDXRef-Package-pkg")); // Test esbuild platform-specific packages let esbuild_package = SpdxPackage::new( "esbuild-linux-ppc64".to_string(), "https://example.com/test", ) .with_version("0.19.4".to_string()) .with_license("MIT".to_string()); // Now uses hash-based ID for safety assert!(esbuild_package.spdx_id.starts_with("SPDXRef-Package-pkg")); assert_eq!(esbuild_package.license_concluded, Some("MIT".to_string())); // Test package with no license (should get NOASSERTION) let no_license_package = SpdxPackage::new("some-package".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("".to_string()); assert_eq!( no_license_package.license_concluded, Some("NOASSERTION".to_string()) ); } #[test] #[serial] fn test_extreme_edge_case_packages() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test package with only special characters let special_package = SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test") .with_version("!!!".to_string()) .with_license("null".to_string()); // Should not be empty and should not use UUID fallback assert!(!special_package.spdx_id.is_empty()); assert!(special_package.spdx_id.starts_with("SPDXRef-Package-pkg")); assert!(!special_package.spdx_id.contains("SPDXRef-Package-947d52c7")); // Not a UUID assert_eq!( special_package.license_concluded, Some("NOASSERTION".to_string()) ); // Test package with unicode characters let unicode_package = SpdxPackage::new("你好世界".to_string(), "https://example.com/test") .with_version("版本1.0".to_string()) .with_license("MIT".to_string()); assert!(!unicode_package.spdx_id.is_empty()); assert!(unicode_package.spdx_id.starts_with("SPDXRef-Package-pkg")); assert_eq!(unicode_package.license_concluded, Some("MIT".to_string())); } #[test] fn test_spdx_id_hash_fallback_logic() { // Test case where name is normal but version needs hash fallback let normal_name_weird_version = SpdxPackage::new( "micromark-util-symbol".to_string(), "https://example.com/test", ) .with_version("@#$%^&*()".to_string()); // Should use single hash for entire package+version combination assert!(normal_name_weird_version .spdx_id .starts_with("SPDXRef-Package-pkg")); assert!(normal_name_weird_version.spdx_id.len() <= 50); // Reasonable length for hash-based ID // Test case where name needs hash but version is normal let weird_name_normal_version = SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test") .with_version("2.0.1".to_string()); assert!(weird_name_normal_version .spdx_id .starts_with("SPDXRef-Package-pkg")); // Test case where both need hash fallback let weird_name_weird_version = SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test") .with_version("!!!".to_string()); assert!(weird_name_weird_version .spdx_id .starts_with("SPDXRef-Package-pkg")); // Test consistency - same package should get same ID let duplicate = SpdxPackage::new("@#$%^&*()".to_string(), "https://example.com/test") .with_version("!!!".to_string()); assert_eq!(weird_name_weird_version.spdx_id, duplicate.spdx_id); } #[test] #[serial] fn test_micromark_util_symbol_case() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test the specific case from the error message let micromark_package = SpdxPackage::new( "micromark-util-symbol".to_string(), "https://example.com/test", ) .with_version("2.0.1".to_string()) .with_license("MIT".to_string()); // Now uses hash-based ID for safety assert!(micromark_package.spdx_id.starts_with("SPDXRef-Package-pkg")); assert_eq!(micromark_package.license_concluded, Some("MIT".to_string())); // Test with a problematic version that would cause the original error let micromark_with_weird_version = SpdxPackage::new( "micromark-util-symbol".to_string(), "https://example.com/test", ) .with_version("!!!".to_string()) // Pure symbols, no alphanumeric .with_license("MIT".to_string()); // Should use single hash fallback, not concatenate hashes assert!(micromark_with_weird_version .spdx_id .starts_with("SPDXRef-Package-pkg")); assert!(micromark_with_weird_version.spdx_id.len() <= 50); // Reasonable length for hash-based ID } #[test] #[serial] fn test_license_concluded_vs_declared() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test normal license handling let mit_package = SpdxPackage::new("test-package".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("MIT".to_string()); assert_eq!(mit_package.license_declared, Some("MIT".to_string())); assert_eq!(mit_package.license_concluded, Some("MIT".to_string())); // Test NOASSERTION license handling let no_license_package = SpdxPackage::new("test-package".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("".to_string()); assert_eq!( no_license_package.license_declared, Some("NOASSERTION".to_string()) ); assert_eq!( no_license_package.license_concluded, Some("NOASSERTION".to_string()) ); // Test problematic license gets converted to NOASSERTION let bad_license_package = SpdxPackage::new("test-package".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("MIT\"with-quotes".to_string()); assert_eq!( bad_license_package.license_declared, Some("NOASSERTION".to_string()) ); assert_eq!( bad_license_package.license_concluded, Some("NOASSERTION".to_string()) ); // Test cargo-style license conversion let cargo_license_package = SpdxPackage::new("test-package".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("MIT/Apache-2.0".to_string()); assert_eq!( cargo_license_package.license_declared, Some("MIT OR Apache-2.0".to_string()) ); assert_eq!( cargo_license_package.license_concluded, Some("MIT OR Apache-2.0".to_string()) ); } #[test] fn test_spdx_license_format_validation() { // Test valid SPDX license formats assert!(super::is_valid_spdx_license_format("MIT")); assert!(super::is_valid_spdx_license_format("Apache-2.0")); assert!(super::is_valid_spdx_license_format("GPL-3.0+")); assert!(super::is_valid_spdx_license_format("MIT OR Apache-2.0")); assert!(super::is_valid_spdx_license_format( "(MIT OR Apache-2.0) AND GPL-2.0" )); assert!(super::is_valid_spdx_license_format("NOASSERTION")); // Test invalid SPDX license formats assert!(!super::is_valid_spdx_license_format("MIT&Apache")); assert!(!super::is_valid_spdx_license_format("MIT|Apache")); assert!(!super::is_valid_spdx_license_format("MIT&&Apache")); assert!(!super::is_valid_spdx_license_format("MIT||Apache")); assert!(!super::is_valid_spdx_license_format("MIT--invalid")); assert!(!super::is_valid_spdx_license_format("MIT..invalid")); assert!(!super::is_valid_spdx_license_format("MIT@invalid")); assert!(!super::is_valid_spdx_license_format("MIT#invalid")); assert!(!super::is_valid_spdx_license_format("")); assert!(!super::is_valid_spdx_license_format(" ")); } #[test] #[serial] fn test_ultra_conservative_license_validation() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test the enhanced validation catches more edge cases assert_eq!( convert_to_spdx_license_expression("MIT=invalid"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT*wildcard"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT?question"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT^caret"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT$dollar"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT%percent"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT#hash"), "NOASSERTION" ); assert_eq!(convert_to_spdx_license_expression("MIT@at"), "NOASSERTION"); assert_eq!( convert_to_spdx_license_expression("MIT!exclaim"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT~tilde"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT`backtick"), "NOASSERTION" ); // Test non-ASCII characters assert_eq!( convert_to_spdx_license_expression("MIT©copyright"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT™trademark"), "NOASSERTION" ); assert_eq!( convert_to_spdx_license_expression("MIT®registered"), "NOASSERTION" ); // Test shorter length limit let long_license = "A".repeat(101); assert_eq!( convert_to_spdx_license_expression(&long_license), "NOASSERTION" ); // Test character whitelist assert_eq!( convert_to_spdx_license_expression("MIT_underscore"), "NOASSERTION" ); // underscore not in whitelist assert_eq!( convert_to_spdx_license_expression("MIT:colon"), "NOASSERTION" ); // colon not in whitelist assert_eq!( convert_to_spdx_license_expression("MIT;semicolon"), "NOASSERTION" ); // semicolon not in whitelist assert_eq!( convert_to_spdx_license_expression("MIT,comma"), "NOASSERTION" ); // comma not in whitelist } #[test] #[serial] fn test_force_noassertion_mode() { // Test normal mode first std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); assert_eq!(convert_to_spdx_license_expression("MIT"), "MIT"); assert_eq!( convert_to_spdx_license_expression("Apache-2.0"), "Apache-2.0" ); // Test the ultra-conservative fallback mode via environment variable std::env::set_var("FELUDA_FORCE_NOASSERTION_LICENSES", "true"); assert_eq!(convert_to_spdx_license_expression("MIT"), "NOASSERTION"); assert_eq!( convert_to_spdx_license_expression("Apache-2.0"), "NOASSERTION" ); assert_eq!(convert_to_spdx_license_expression("GPL-3.0"), "NOASSERTION"); assert_eq!(convert_to_spdx_license_expression(""), "NOASSERTION"); // Clean up std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); } #[test] fn test_spdx_document_license_safety_net() { // Test that the JSON generation safety net catches problematic licenses let mut doc = SpdxDocument::new("test"); // Create a package with a problematic license that somehow got through let mut package = SpdxPackage::new("test-package".to_string(), &doc.document_namespace); package.license_declared = Some("MIT\"with-quotes".to_string()); // This should be caught package.license_concluded = Some("Apache\\with-backslash".to_string()); // This should be caught doc.add_package(package); // The generate_spdx_output function should fix these problematic licenses // We can't easily test this without a full integration test, but the logic is there assert_eq!(doc.packages.len(), 1); assert!(doc.packages[0].license_declared.is_some()); assert!(doc.packages[0].license_concluded.is_some()); } #[test] fn test_spdx_id_format_validation() { // Test valid SPDX IDs assert!(super::is_valid_spdx_id_format("SPDXRef-DOCUMENT")); assert!(super::is_valid_spdx_id_format("SPDXRef-Package-123")); assert!(super::is_valid_spdx_id_format("SPDXRef-File-src.main.rs")); assert!(super::is_valid_spdx_id_format( "SPDXRef-Package-pkg0123456789abcdef" )); // Test invalid SPDX IDs assert!(!super::is_valid_spdx_id_format("")); // Empty assert!(!super::is_valid_spdx_id_format("SPDX-DOCUMENT")); // Missing "Ref-" part assert!(!super::is_valid_spdx_id_format("SPDXRef-")); // No suffix assert!(!super::is_valid_spdx_id_format("SPDXRef-Package\"quoted")); // Contains quote assert!(!super::is_valid_spdx_id_format( "SPDXRef-Package\\backslash" )); // Contains backslash assert!(!super::is_valid_spdx_id_format( "SPDXRef-Package\nwithNewline" )); // Contains newline // Test length limits let valid_long = format!("SPDXRef-{}", "a".repeat(192)); // 8 + 192 = 200 chars assert!(super::is_valid_spdx_id_format(&valid_long)); let invalid_long = format!("SPDXRef-{}", "a".repeat(193)); // 8 + 193 = 201 chars assert!(!super::is_valid_spdx_id_format(&invalid_long)); } #[test] #[serial] fn test_package_metadata_validation() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test package with valid metadata let package = SpdxPackage::new("valid-package".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("MIT".to_string()) .with_copyright("(C) 2024 Example Corp".to_string()) .with_download_location("https://github.com/example/repo".to_string()) .with_comment("A valid test package".to_string()); assert!(!package.name.is_empty()); assert_eq!(package.version_info, Some("1.0.0".to_string())); assert_eq!(package.license_concluded, Some("MIT".to_string())); assert!(package.copyright_text.is_some()); // Test package sanitization of invalid metadata let mut package_with_issues = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_version("1.0.0".to_string()) .with_license("MIT".to_string()); // Manually set invalid values that should be caught by validation package_with_issues.name = "package\"with-quote".to_string(); package_with_issues.download_location = "https://example.com\nmalicious".to_string(); // Run validation assert!(super::validate_and_sanitize_spdx_package( &mut package_with_issues )); // Verify problematic content was removed/fixed assert!(!package_with_issues.name.contains('"')); assert_eq!(package_with_issues.download_location, "NOASSERTION"); } #[test] #[serial] fn test_download_location_validation() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test valid download locations let package1 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_download_location("https://github.com/example/repo".to_string()); assert_eq!( package1.download_location, "https://github.com/example/repo" ); let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_download_location("NOASSERTION".to_string()); assert_eq!(package2.download_location, "NOASSERTION"); // Test invalid location (non-ASCII) let package3 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_download_location("https://example.com/文件".to_string()); assert_eq!(package3.download_location, "NOASSERTION"); // Test invalid location (too long) - Note: direct field mutation bypasses validation // For validation during generation, use validate_and_sanitize_spdx_package let mut package4 = SpdxPackage::new("test".to_string(), "https://example.com/test"); package4.download_location = format!("https://example.com/{}", "a".repeat(2000)); // Validate should catch the long location assert!(super::validate_and_sanitize_spdx_package(&mut package4)); assert_eq!(package4.download_location, "NOASSERTION"); } #[test] #[serial] fn test_copyright_validation() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test valid copyright let package1 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_copyright("(C) 2024 Example Corp".to_string()); assert_eq!( package1.copyright_text, Some("(C) 2024 Example Corp".to_string()) ); // Test empty copyright (should convert to NOASSERTION) let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_copyright("".to_string()); assert_eq!(package2.copyright_text, Some("NOASSERTION".to_string())); // Test copyright with forbidden characters let package3 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_copyright("(C) 2024 Corp\"with-quotes".to_string()); assert_eq!(package3.copyright_text, Some("NOASSERTION".to_string())); } #[test] #[serial] fn test_external_ref_validation() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test valid external reference let package = SpdxPackage::new("test".to_string(), "https://example.com/test") .add_external_ref( "PACKAGE_MANAGER".to_string(), "npm".to_string(), "lodash@4.17.21".to_string(), ); assert_eq!(package.external_refs.len(), 1); assert_eq!( package.external_refs[0].reference_category, "PACKAGE_MANAGER" ); assert_eq!(package.external_refs[0].reference_type, "npm"); assert_eq!(package.external_refs[0].reference_locator, "lodash@4.17.21"); // Test invalid external reference (forbidden characters) let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test") .add_external_ref( "SECURITY_OTHER".to_string(), "cpe23".to_string(), "cpe:2.3:a:vendor:product:1.0\"malicious".to_string(), ); // Should be skipped due to invalid characters assert_eq!(package2.external_refs.len(), 0); } #[test] #[serial] fn test_comment_validation() { std::env::remove_var("FELUDA_FORCE_NOASSERTION_LICENSES"); // Test valid comment let package1 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_comment("This is a valid comment".to_string()); assert_eq!( package1.comment, Some("This is a valid comment".to_string()) ); // Test empty comment (should be skipped) let package2 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_comment("".to_string()); assert_eq!(package2.comment, None); // Test comment with forbidden characters let package3 = SpdxPackage::new("test".to_string(), "https://example.com/test") .with_comment("Comment with\nnewline".to_string()); assert_eq!(package3.comment, None); } #[test] fn test_charset_validation_helpers() { // Test globally forbidden characters assert!(spdx_charset::contains_forbidden_chars("test\"quote")); assert!(spdx_charset::contains_forbidden_chars("test\\backslash")); assert!(spdx_charset::contains_forbidden_chars("test\nnewline")); assert!(!spdx_charset::contains_forbidden_chars("test-string")); // Test ASCII validation assert!(spdx_charset::is_valid_ascii("test")); assert!(!spdx_charset::is_valid_ascii("test™")); assert!(!spdx_charset::is_valid_ascii("test©")); // Test problematic characters assert!(spdx_charset::contains_problematic_chars("test&symbol")); assert!(spdx_charset::contains_problematic_chars("test|pipe")); assert!(spdx_charset::contains_problematic_chars("test[bracket]")); assert!(!spdx_charset::contains_problematic_chars("test-string")); } } feluda-1.11.1/src/sbom/validate/cyclonedx_validator.rs000064400000000000000000000162621046102023000210770ustar 00000000000000use super::parser; use super::reporter::{ValidationIssue, ValidationReport}; use crate::debug::FeludaResult; use serde_json::Value as JsonValue; pub fn validate(json: &JsonValue) -> FeludaResult { let mut report = ValidationReport::new("CycloneDX"); let obj = match json.as_object() { Some(o) => o, None => { report.add_issue(ValidationIssue::error( "CycloneDX BOM must be a JSON object", )); return Ok(report); } }; validate_required_fields(&mut report, obj); validate_bom_format(&mut report, obj); validate_spec_version(&mut report, obj); validate_components(&mut report, obj); validate_metadata(&mut report, obj); Ok(report) } fn validate_required_fields( report: &mut ValidationReport, obj: &serde_json::Map, ) { let required_fields = ["bomFormat", "specVersion"]; for field in required_fields { if !obj.contains_key(field) { report.add_issue( ValidationIssue::error(format!("Missing required field: {field}")) .with_field(field), ); } } } fn validate_bom_format(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(format) = parser::get_string(&json_obj, "bomFormat") { if format != "CycloneDX" { report.add_issue( ValidationIssue::error(format!( "Invalid bomFormat: '{format}'. Expected 'CycloneDX'" )) .with_field("bomFormat"), ); } } } fn validate_spec_version(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(spec_version) = parser::get_string(&json_obj, "specVersion") { let valid_versions = ["1.0", "1.1", "1.2", "1.3", "1.4", "1.5"]; if !valid_versions.contains(&spec_version.as_str()) { report.add_issue( ValidationIssue::warning(format!( "Unknown or unsupported specVersion: {spec_version}" )) .with_field("specVersion"), ); } } } fn validate_components(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(components) = parser::get_array(&json_obj, "components") { if components.is_empty() { report.add_issue(ValidationIssue::info( "No components defined in CycloneDX BOM", )); } for (idx, component) in components.iter().enumerate() { validate_component(report, component, idx); } } } fn validate_component(report: &mut ValidationReport, component: &JsonValue, index: usize) { if let Some(comp_obj) = component.as_object() { let comp_json = JsonValue::Object(comp_obj.clone()); let component_name = parser::get_string(&comp_json, "name").unwrap_or_else(|| format!("Component[{index}]")); if !parser::has_key(&comp_json, "type") { report.add_issue( ValidationIssue::error(format!( "Component '{component_name}': missing 'type' field" )) .with_field("type"), ); } else if let Some(comp_type) = parser::get_string(&comp_json, "type") { let valid_types = [ "application", "framework", "library", "container", "operating-system", "device", "firmware", "file", "install", "archive", "filing-system", "media", "other", ]; if !valid_types.contains(&comp_type.as_str()) { report.add_issue( ValidationIssue::warning(format!( "Component '{component_name}': unknown component type '{comp_type}'" )) .with_field("type"), ); } } if parser::has_key(&comp_json, "version") { if let Some(version) = parser::get_string(&comp_json, "version") { if version.is_empty() { report.add_issue( ValidationIssue::warning(format!( "Component '{component_name}': version cannot be empty" )) .with_field("version"), ); } } } if let Some(licenses) = parser::get_array(&comp_json, "licenses") { for license in licenses { if let Some(license_obj) = license.as_object() { let license_json = JsonValue::Object(license_obj.clone()); if !parser::has_key(&license_json, "license") && !parser::has_key(&license_json, "expression") { report.add_issue( ValidationIssue::warning( format!( "Component '{component_name}': license must have either 'license' or 'expression' field" ), ) .with_field("licenses"), ); } } } } } } fn validate_metadata(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(metadata) = parser::get_object(&json_obj, "metadata") { if let Some(_meta_obj) = metadata.as_object() { if parser::has_key(&metadata, "timestamp") { if let Some(timestamp) = parser::get_string(&metadata, "timestamp") { if !parser::is_valid_iso_datetime(×tamp) { report.add_issue( ValidationIssue::warning(format!( "Metadata: invalid timestamp format '{timestamp}'. Expected ISO 8601 format" )) .with_field("metadata.timestamp"), ); } } } if parser::has_key(&metadata, "tools") { if let Some(tools) = parser::get_array(&metadata, "tools") { for tool in tools { if let Some(tool_obj) = tool.as_object() { let tool_json = JsonValue::Object(tool_obj.clone()); if !parser::has_key(&tool_json, "name") { report.add_issue( ValidationIssue::warning("Tool entry missing 'name' field") .with_field("metadata.tools[].name"), ); } } } } } } } } feluda-1.11.1/src/sbom/validate/mod.rs000064400000000000000000000040531046102023000156140ustar 00000000000000use crate::debug::{log, FeludaError, FeludaResult, LogLevel}; use serde_json::Value as JsonValue; use std::fs; mod cyclonedx_validator; mod parser; mod reporter; mod spdx_validator; #[derive(Debug, Clone, Copy, PartialEq)] enum SbomType { Spdx, CycloneDx, } fn detect_sbom_type(content: &str) -> FeludaResult { let json: JsonValue = serde_json::from_str(content) .map_err(|e| FeludaError::Validation(format!("Failed to parse JSON: {e}")))?; if let Some(obj) = json.as_object() { if obj.contains_key("spdxVersion") || obj.contains_key("SPDXID") { return Ok(SbomType::Spdx); } if obj.contains_key("bomFormat") || obj.contains_key("specVersion") { return Ok(SbomType::CycloneDx); } } Err(FeludaError::Validation( "Could not detect SBOM type. File is neither SPDX nor CycloneDX.".to_string(), )) } pub fn handle_sbom_validate_command( sbom_file: String, output: Option, json_output: bool, ) -> FeludaResult<()> { log( LogLevel::Info, &format!("Validating SBOM file: {sbom_file}"), ); let content = fs::read_to_string(&sbom_file) .map_err(|_| FeludaError::Validation(format!("Failed to read SBOM file: {sbom_file}")))?; log(LogLevel::Info, "Parsing SBOM file"); let json: JsonValue = serde_json::from_str(&content) .map_err(|e| FeludaError::Validation(format!("Invalid JSON: {e}")))?; log(LogLevel::Info, "Detecting SBOM type"); let sbom_type = detect_sbom_type(&content)?; log( LogLevel::Info, &format!("Detected SBOM type: {sbom_type:?}"), ); let validation_report = match sbom_type { SbomType::Spdx => { log(LogLevel::Info, "Running SPDX validation"); spdx_validator::validate(&json)? } SbomType::CycloneDx => { log(LogLevel::Info, "Running CycloneDX validation"); cyclonedx_validator::validate(&json)? } }; validation_report.write_output(json_output, output)?; Ok(()) } feluda-1.11.1/src/sbom/validate/parser.rs000064400000000000000000000012561046102023000163330ustar 00000000000000use serde_json::Value as JsonValue; pub fn get_string(obj: &JsonValue, key: &str) -> Option { obj.get(key).and_then(|v| v.as_str()).map(|s| s.to_string()) } pub fn get_array(obj: &JsonValue, key: &str) -> Option> { obj.get(key).and_then(|v| v.as_array()).cloned() } pub fn get_object(obj: &JsonValue, key: &str) -> Option { obj.get(key).cloned() } pub fn has_key(obj: &JsonValue, key: &str) -> bool { obj.get(key).is_some() } pub fn is_valid_iso_datetime(datetime: &str) -> bool { chrono::DateTime::parse_from_rfc3339(datetime).is_ok() || chrono::NaiveDateTime::parse_from_str(datetime, "%Y-%m-%dT%H:%M:%SZ").is_ok() } feluda-1.11.1/src/sbom/validate/reporter.rs000064400000000000000000000125721046102023000167040ustar 00000000000000use crate::debug::{FeludaError, FeludaResult}; use serde::{Deserialize, Serialize}; use std::fs; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum IssueSeverity { Error, Warning, Info, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidationIssue { pub severity: IssueSeverity, pub message: String, pub field: Option, pub line: Option, } impl ValidationIssue { pub fn error(message: impl Into) -> Self { Self { severity: IssueSeverity::Error, message: message.into(), field: None, line: None, } } pub fn warning(message: impl Into) -> Self { Self { severity: IssueSeverity::Warning, message: message.into(), field: None, line: None, } } pub fn info(message: impl Into) -> Self { Self { severity: IssueSeverity::Info, message: message.into(), field: None, line: None, } } pub fn with_field(mut self, field: impl Into) -> Self { self.field = Some(field.into()); self } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ValidationReport { pub sbom_type: String, pub is_valid: bool, pub issues: Vec, pub error_count: usize, pub warning_count: usize, pub info_count: usize, } impl ValidationReport { pub fn new(sbom_type: impl Into) -> Self { Self { sbom_type: sbom_type.into(), is_valid: true, issues: Vec::new(), error_count: 0, warning_count: 0, info_count: 0, } } pub fn add_issue(&mut self, issue: ValidationIssue) { match issue.severity { IssueSeverity::Error => { self.error_count += 1; self.is_valid = false; } IssueSeverity::Warning => self.warning_count += 1, IssueSeverity::Info => self.info_count += 1, } self.issues.push(issue); } pub fn write_output(&self, json: bool, output: Option) -> FeludaResult<()> { let output_string = if json { serde_json::to_string_pretty(&self).map_err(|e| { FeludaError::Serialization(format!("Failed to serialize report: {e}")) })? } else { self.format_text() }; if let Some(path) = output { fs::write(&path, &output_string).map_err(|e| { FeludaError::FileWrite(format!("Failed to write report to {path}: {e}")) })?; println!("Report written to: {path}"); } else { println!("{output_string}"); } Ok(()) } fn format_text(&self) -> String { use owo_colors::OwoColorize; let mut output = String::new(); output.push_str(&format!("\n{}\n", "━".repeat(60)).bold().to_string()); let status_icon = if self.is_valid { format!("{}", "✓".green()) } else { format!("{}", "✗".red()) }; output.push_str(&format!( "{} {} SBOM Validation Report\n", "".bold(), status_icon )); output.push_str(&format!("{}\n", "━".repeat(60)).bold().to_string()); output.push_str(&format!("SBOM Type: {}\n", self.sbom_type.bright_cyan())); output.push_str(&format!( "Status: {}\n", if self.is_valid { "VALID".green().to_string() } else { "INVALID".red().to_string() } )); output.push_str("\nIssues Summary:\n"); output.push_str(&format!( " Errors: {}\n", if self.error_count > 0 { format!("{}", self.error_count).red().to_string() } else { format!("{}", self.error_count).green().to_string() } )); output.push_str(&format!( " Warnings: {}\n", if self.warning_count > 0 { format!("{}", self.warning_count).yellow().to_string() } else { format!("{}", self.warning_count).green().to_string() } )); output.push_str(&format!(" Info: {}\n", self.info_count.bright_blue())); if !self.issues.is_empty() { output.push_str("\nDetailed Issues:\n"); output.push_str(&format!("{}\n", "─".repeat(60))); for issue in &self.issues { let severity_str = match issue.severity { IssueSeverity::Error => "[ERROR]".red().to_string(), IssueSeverity::Warning => "[WARN]".yellow().to_string(), IssueSeverity::Info => "[INFO]".blue().to_string(), }; output.push_str(&format!("{severity_str} {}\n", issue.message)); if let Some(ref field) = issue.field { output.push_str(&format!(" Field: {}\n", field.bright_black())); } if let Some(line) = issue.line { output.push_str(&format!(" Line: {line}\n")); } } output.push_str(&format!("{}\n", "─".repeat(60))); } output } } feluda-1.11.1/src/sbom/validate/spdx_validator.rs000064400000000000000000000123441046102023000200620ustar 00000000000000use super::parser; use super::reporter::{ValidationIssue, ValidationReport}; use crate::debug::FeludaResult; use serde_json::Value as JsonValue; pub fn validate(json: &JsonValue) -> FeludaResult { let mut report = ValidationReport::new("SPDX"); let obj = match json.as_object() { Some(o) => o, None => { report.add_issue(ValidationIssue::error( "SPDX document must be a JSON object", )); return Ok(report); } }; validate_required_fields(&mut report, obj); validate_spdx_version(&mut report, obj); validate_document_name(&mut report, obj); validate_namespace(&mut report, obj); validate_packages(&mut report, obj); Ok(report) } fn validate_required_fields( report: &mut ValidationReport, obj: &serde_json::Map, ) { let required_fields = [ "spdxVersion", "dataLicense", "SPDXID", "name", "documentNamespace", "creationInfo", ]; for field in required_fields { if !obj.contains_key(field) { report.add_issue( ValidationIssue::error(format!("Missing required field: {field}")) .with_field(field), ); } } } fn validate_spdx_version(report: &mut ValidationReport, obj: &serde_json::Map) { if let Some(version) = parser::get_string(&JsonValue::Object(obj.clone()), "spdxVersion") { if !version.starts_with("SPDX-") { report.add_issue( ValidationIssue::warning(format!( "Invalid SPDX version format: {version}. Expected format: SPDX-X.Y" )) .with_field("spdxVersion"), ); } let supported_versions = ["SPDX-2.2", "SPDX-2.3"]; if !supported_versions.iter().any(|v| version.starts_with(v)) { report.add_issue( ValidationIssue::info(format!("SPDX version {version} may not be fully supported")) .with_field("spdxVersion"), ); } } } fn validate_document_name(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(name) = parser::get_string(&json_obj, "name") { if name.is_empty() { report.add_issue( ValidationIssue::error("Document name cannot be empty").with_field("name"), ); } } } fn validate_namespace(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(namespace) = parser::get_string(&json_obj, "documentNamespace") { if namespace.is_empty() { report.add_issue( ValidationIssue::error("Document namespace cannot be empty") .with_field("documentNamespace"), ); } else if !namespace.starts_with("https://") && !namespace.starts_with("http://") { report.add_issue( ValidationIssue::warning("Document namespace should be a valid URI") .with_field("documentNamespace"), ); } } } fn validate_packages(report: &mut ValidationReport, obj: &serde_json::Map) { let json_obj = JsonValue::Object(obj.clone()); if let Some(packages) = parser::get_array(&json_obj, "packages") { if packages.is_empty() { report.add_issue(ValidationIssue::warning("No packages defined in SBOM")); } for (idx, package) in packages.iter().enumerate() { validate_package(report, package, idx); } } } fn validate_package(report: &mut ValidationReport, package: &JsonValue, index: usize) { if let Some(pkg_obj) = package.as_object() { let pkg_json = JsonValue::Object(pkg_obj.clone()); let package_name = parser::get_string(&pkg_json, "name").unwrap_or_else(|| format!("Package[{index}]")); if !parser::has_key(&pkg_json, "SPDXID") { report.add_issue( ValidationIssue::error(format!("Package '{package_name}': missing SPDXID")) .with_field("SPDXID"), ); } else if let Some(spdx_id) = parser::get_string(&pkg_json, "SPDXID") { if !spdx_id.starts_with("SPDXRef-") { report.add_issue( ValidationIssue::warning(format!( "Package '{package_name}': SPDXID should start with 'SPDXRef-'" )) .with_field("SPDXID"), ); } } if !parser::has_key(&pkg_json, "downloadLocation") { report.add_issue( ValidationIssue::error(format!( "Package '{package_name}': missing downloadLocation" )) .with_field("downloadLocation"), ); } if !parser::has_key(&pkg_json, "filesAnalyzed") { report.add_issue( ValidationIssue::info(format!( "Package '{package_name}': filesAnalyzed not specified" )) .with_field("filesAnalyzed"), ); } } } feluda-1.11.1/src/table.rs000064400000000000000000001677271046102023000134150ustar 00000000000000use crate::debug::{log, log_debug, LogLevel}; use crate::licenses::{LicenseCompatibility, LicenseInfo}; use color_eyre::Result; use ratatui::{ crossterm::event::{self, Event, KeyCode, KeyEventKind}, layout::{Constraint, Layout, Margin, Rect}, style::{self, Color, Modifier, Style, Stylize}, text::Text, widgets::{ Block, BorderType, Cell, HighlightSpacing, Paragraph, Row, Scrollbar, ScrollbarOrientation, ScrollbarState, Table, TableState, }, DefaultTerminal, Frame, }; use style::palette::tailwind; use unicode_width::UnicodeWidthStr; const INFO_TEXT: [&str; 3] = [ "(Esc) quit | (↑) move up | (↓) move down | (←) move left | (→) move right", "(r) restrictive | (i) incompatible | (c) compatible | (a) osi-approved | (n) osi-not-approved | (u) osi-unknown | (x) clear filters | (s) sort mode", "(In sort mode: ←→ select column, Enter toggle sort, Esc/q exit sort)", ]; const ITEM_HEIGHT: usize = 4; // ============================================================================ // KEY BINDINGS CONFIGURATION // ============================================================================ // All GUI key bindings for normal and sorting modes are centrally defined here. // This makes it easy to view, manage, and modify keybindings in one place. /// Normal mode key bindings #[allow(dead_code)] pub mod keybindings_normal { use ratatui::crossterm::event::KeyCode; /// Quit the application pub const QUIT: &[KeyCode] = &[KeyCode::Esc]; pub const QUIT_CHAR: char = 'q'; /// Navigation keys pub const MOVE_DOWN: &[KeyCode] = &[KeyCode::Down]; pub const MOVE_DOWN_CHAR: char = 'j'; pub const MOVE_UP: &[KeyCode] = &[KeyCode::Up]; pub const MOVE_UP_CHAR: char = 'k'; pub const MOVE_RIGHT: &[KeyCode] = &[KeyCode::Right]; pub const MOVE_RIGHT_CHAR: char = 'l'; pub const MOVE_LEFT: &[KeyCode] = &[KeyCode::Left]; pub const MOVE_LEFT_CHAR: char = 'h'; /// Filter keys pub const FILTER_RESTRICTIVE: char = 'r'; pub const FILTER_INCOMPATIBLE: char = 'i'; pub const FILTER_COMPATIBLE: char = 'c'; pub const FILTER_OSI_APPROVED: char = 'a'; pub const FILTER_OSI_NOT_APPROVED: char = 'n'; pub const FILTER_OSI_UNKNOWN: char = 'u'; pub const FILTER_CLEAR_ALL: char = 'x'; /// Sort mode pub const ENTER_SORT_MODE: char = 's'; } /// Sort mode key bindings #[allow(dead_code)] pub mod keybindings_sort { use ratatui::crossterm::event::KeyCode; /// Navigate between columns pub const SELECT_PREV_COLUMN: &[KeyCode] = &[KeyCode::Left]; pub const SELECT_PREV_COLUMN_CHAR: char = 'h'; pub const SELECT_NEXT_COLUMN: &[KeyCode] = &[KeyCode::Right]; pub const SELECT_NEXT_COLUMN_CHAR: char = 'l'; /// Apply sort pub const APPLY_SORT: KeyCode = KeyCode::Enter; /// Exit sort mode pub const EXIT_SORT_MODE: &[KeyCode] = &[KeyCode::Esc]; pub const EXIT_SORT_MODE_CHAR: char = 'q'; } const TABLE_COLOUR: tailwind::Palette = tailwind::RED; #[derive(Debug, Clone, Default)] struct FilterState { show_restrictive_only: bool, show_incompatible_only: bool, show_compatible_only: bool, show_osi_approved_only: bool, show_osi_not_approved_only: bool, show_osi_unknown_only: bool, } impl FilterState { fn is_any_active(&self) -> bool { self.show_restrictive_only || self.show_incompatible_only || self.show_compatible_only || self.show_osi_approved_only || self.show_osi_not_approved_only || self.show_osi_unknown_only } fn clear_all(&mut self) { self.show_restrictive_only = false; self.show_incompatible_only = false; self.show_compatible_only = false; self.show_osi_approved_only = false; self.show_osi_not_approved_only = false; self.show_osi_unknown_only = false; } fn matches(&self, item: &LicenseInfo) -> bool { if !self.is_any_active() { return true; } let mut matches = true; // If any restrictive filter is active, check it if self.show_restrictive_only && !item.is_restrictive { matches = false; } if self.show_incompatible_only || self.show_compatible_only { let compat_match = match item.compatibility { LicenseCompatibility::Incompatible => self.show_incompatible_only, LicenseCompatibility::Compatible => self.show_compatible_only, LicenseCompatibility::Unknown => false, }; if !compat_match { matches = false; } } if self.show_osi_approved_only || self.show_osi_not_approved_only || self.show_osi_unknown_only { let osi_match = match item.osi_status { crate::licenses::OsiStatus::Approved => self.show_osi_approved_only, crate::licenses::OsiStatus::NotApproved => self.show_osi_not_approved_only, crate::licenses::OsiStatus::Unknown => self.show_osi_unknown_only, }; if !osi_match { matches = false; } } matches } } struct TableColors { buffer_bg: Color, header_bg: Color, header_fg: Color, row_fg: Color, selected_row_style_fg: Color, selected_column_style_fg: Color, selected_cell_style_fg: Color, normal_row_color: Color, alt_row_color: Color, footer_border_color: Color, compatible_color: Color, incompatible_color: Color, unknown_color: Color, osi_approved_color: Color, osi_not_approved_color: Color, osi_unknown_color: Color, } impl TableColors { const fn new(color: &tailwind::Palette) -> Self { Self { buffer_bg: tailwind::SLATE.c950, header_bg: color.c900, header_fg: tailwind::SLATE.c200, row_fg: tailwind::SLATE.c200, selected_row_style_fg: color.c400, selected_column_style_fg: color.c400, selected_cell_style_fg: color.c600, normal_row_color: tailwind::SLATE.c950, alt_row_color: tailwind::SLATE.c900, footer_border_color: color.c400, compatible_color: tailwind::GREEN.c500, incompatible_color: tailwind::RED.c500, unknown_color: tailwind::YELLOW.c500, osi_approved_color: tailwind::BLUE.c500, osi_not_approved_color: tailwind::ORANGE.c500, osi_unknown_color: tailwind::GRAY.c500, } } } /// Column sorting direction #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SortDirection { Ascending, Descending, } /// Represents which column is currently being sorted #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub enum SortColumn { Name, Version, License, Restrictive, Compatibility, OsiStatus, } impl SortColumn { /// Get all available sort columns in order pub fn all() -> &'static [SortColumn] { &[ SortColumn::Name, SortColumn::Version, SortColumn::License, SortColumn::Restrictive, SortColumn::Compatibility, SortColumn::OsiStatus, ] } /// Get display name for the column pub fn display_name(&self) -> &'static str { match self { SortColumn::Name => "Name", SortColumn::Version => "Version", SortColumn::License => "License", SortColumn::Restrictive => "Restrictive", SortColumn::Compatibility => "Compatibility", SortColumn::OsiStatus => "OSI Status", } } } /// Application mode #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AppMode { Normal, Sorting, } pub struct App { state: TableState, items: Vec, longest_item_lens: (u16, u16, u16, u16, u16, u16), // Name, Version, License, Restrictive, Compatibility, OSI Status scroll_state: ScrollbarState, colors: TableColors, project_license: Option, filters: FilterState, sort_column: Option, sort_direction: SortDirection, mode: AppMode, sort_column_selection: usize, // Index in SortColumn::all() } impl App { pub fn new(license_data: Vec, project_license: Option) -> Self { log(LogLevel::Info, "Initializing TUI application"); log_debug("License data for TUI", &license_data); log( LogLevel::Info, &format!("Project license: {project_license:?}"), ); let data_vec = license_data; Self { state: TableState::default().with_selected(0), longest_item_lens: constraint_len_calculator(&data_vec), scroll_state: ScrollbarState::new((data_vec.len().saturating_sub(1)) * ITEM_HEIGHT), colors: TableColors::new(&TABLE_COLOUR), items: data_vec, project_license, filters: FilterState::default(), sort_column: None, sort_direction: SortDirection::Ascending, mode: AppMode::Normal, sort_column_selection: 0, } } fn get_filtered_items(&self) -> Vec<&LicenseInfo> { self.items .iter() .filter(|item| self.filters.matches(item)) .collect() } fn update_scroll_state(&mut self) { let filtered_count = self.get_filtered_items().len(); self.scroll_state = ScrollbarState::new((filtered_count.saturating_sub(1)) * ITEM_HEIGHT); } pub fn next_row(&mut self) { let filtered_count = self.get_filtered_items().len(); let i = match self.state.selected() { Some(i) => { if i >= filtered_count.saturating_sub(1) { 0 } else { i + 1 } } None => 0, }; self.state.select(Some(i)); self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); log(LogLevel::Info, &format!("Selected row: {i}")); } pub fn previous_row(&mut self) { let filtered_count = self.get_filtered_items().len(); let i = match self.state.selected() { Some(i) => { if i == 0 { filtered_count.saturating_sub(1) } else { i - 1 } } None => 0, }; self.state.select(Some(i)); self.scroll_state = self.scroll_state.position(i * ITEM_HEIGHT); log(LogLevel::Info, &format!("Selected row: {i}")); } pub fn next_column(&mut self) { self.state.select_next_column(); log(LogLevel::Info, "Selected next column"); } pub fn previous_column(&mut self) { self.state.select_previous_column(); log(LogLevel::Info, "Selected previous column"); } pub fn toggle_restrictive_filter(&mut self) { self.filters.show_restrictive_only = !self.filters.show_restrictive_only; log( LogLevel::Info, &format!("Restrictive filter: {}", self.filters.show_restrictive_only), ); self.update_scroll_state(); self.state.select(Some(0)); } pub fn toggle_incompatible_filter(&mut self) { self.filters.show_incompatible_only = !self.filters.show_incompatible_only; log( LogLevel::Info, &format!( "Incompatible filter: {}", self.filters.show_incompatible_only ), ); self.update_scroll_state(); self.state.select(Some(0)); } pub fn toggle_compatible_filter(&mut self) { self.filters.show_compatible_only = !self.filters.show_compatible_only; log( LogLevel::Info, &format!("Compatible filter: {}", self.filters.show_compatible_only), ); self.update_scroll_state(); self.state.select(Some(0)); } pub fn toggle_osi_approved_filter(&mut self) { self.filters.show_osi_approved_only = !self.filters.show_osi_approved_only; log( LogLevel::Info, &format!( "OSI Approved filter: {}", self.filters.show_osi_approved_only ), ); self.update_scroll_state(); self.state.select(Some(0)); } pub fn toggle_osi_not_approved_filter(&mut self) { self.filters.show_osi_not_approved_only = !self.filters.show_osi_not_approved_only; log( LogLevel::Info, &format!( "OSI Not Approved filter: {}", self.filters.show_osi_not_approved_only ), ); self.update_scroll_state(); self.state.select(Some(0)); } pub fn toggle_osi_unknown_filter(&mut self) { self.filters.show_osi_unknown_only = !self.filters.show_osi_unknown_only; log( LogLevel::Info, &format!("OSI Unknown filter: {}", self.filters.show_osi_unknown_only), ); self.update_scroll_state(); self.state.select(Some(0)); } pub fn clear_filters(&mut self) { self.filters.clear_all(); log(LogLevel::Info, "All filters cleared"); self.update_scroll_state(); self.state.select(Some(0)); } /// Enter sort mode pub fn enter_sort_mode(&mut self) { self.mode = AppMode::Sorting; // Start selection at current sort column if one exists, otherwise first column self.sort_column_selection = if let Some(col) = self.sort_column { SortColumn::all() .iter() .position(|&c| c == col) .unwrap_or(0) } else { 0 }; log(LogLevel::Info, "Entered sort mode"); } /// Exit sort mode without applying changes pub fn exit_sort_mode(&mut self) { self.mode = AppMode::Normal; log(LogLevel::Info, "Exited sort mode"); } /// Move to next column in sort selection pub fn next_sort_column(&mut self) { if self.sort_column_selection < SortColumn::all().len().saturating_sub(1) { self.sort_column_selection += 1; log( LogLevel::Info, &format!("Sort column selection: {}", self.sort_column_selection), ); } } /// Move to previous column in sort selection pub fn previous_sort_column(&mut self) { if self.sort_column_selection > 0 { self.sort_column_selection -= 1; log( LogLevel::Info, &format!("Sort column selection: {}", self.sort_column_selection), ); } } /// Apply sort on currently selected column pub fn apply_current_sort(&mut self) { let column = SortColumn::all()[self.sort_column_selection]; // If clicking the same column, toggle direction; otherwise set new column with ascending if self.sort_column == Some(column) { self.sort_direction = match self.sort_direction { SortDirection::Ascending => SortDirection::Descending, SortDirection::Descending => SortDirection::Ascending, }; } else { self.sort_column = Some(column); self.sort_direction = SortDirection::Ascending; } self.apply_sort(); self.exit_sort_mode(); log( LogLevel::Info, &format!( "Sorted by {:?} in {:?} direction", self.sort_column, self.sort_direction ), ); } /// Compare two version strings, handling 'v' prefix and semantic versioning fn compare_versions(a: &str, b: &str, ascending: bool) -> std::cmp::Ordering { // Remove 'v' prefix if present let a_version = a.trim_start_matches('v'); let b_version = b.trim_start_matches('v'); match ( semver::Version::parse(a_version), semver::Version::parse(b_version), ) { // Both are valid semantic versions - compare semantically (Ok(v_a), Ok(v_b)) => v_a.cmp(&v_b), // One is valid semver, one isn't (Ok(_), Err(_)) => { // In ascending: semver comes first (Less) // In descending: semver comes last (Greater) if ascending { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater } } (Err(_), Ok(_)) => { if ascending { std::cmp::Ordering::Greater } else { std::cmp::Ordering::Less } } // Neither are valid semver - compare as strings (Err(_), Err(_)) => a_version.cmp(b_version), } } /// Apply the current sort to the items fn apply_sort(&mut self) { if let Some(column) = self.sort_column { let ascending = self.sort_direction == SortDirection::Ascending; match column { SortColumn::Name => { self.items.sort_by(|a, b| { let ord = a.name.cmp(&b.name); if ascending { ord } else { ord.reverse() } }); } SortColumn::Version => { self.items .sort_by(|a, b| Self::compare_versions(&a.version, &b.version, ascending)); } SortColumn::License => { self.items.sort_by(|a, b| { let ord = a.get_license().cmp(&b.get_license()); if ascending { ord } else { ord.reverse() } }); } SortColumn::Restrictive => { self.items.sort_by(|a, b| { let ord = a.is_restrictive.cmp(&b.is_restrictive); if ascending { ord } else { ord.reverse() } }); } SortColumn::Compatibility => { self.items.sort_by(|a, b| { let ord = format!("{:?}", a.compatibility).cmp(&format!("{:?}", b.compatibility)); if ascending { ord } else { ord.reverse() } }); } SortColumn::OsiStatus => { self.items.sort_by(|a, b| { let ord = format!("{:?}", a.osi_status).cmp(&format!("{:?}", b.osi_status)); if ascending { ord } else { ord.reverse() } }); } } // Reset selection to top when sorting self.state.select(Some(0)); self.scroll_state = ScrollbarState::new((self.items.len().saturating_sub(1)) * ITEM_HEIGHT); } } pub fn set_colors(&mut self) { self.colors = TableColors::new(&TABLE_COLOUR); } pub fn run(mut self, mut terminal: DefaultTerminal) -> Result<()> { log(LogLevel::Info, "Starting TUI application loop"); loop { // Render the current state terminal.draw(|frame| self.draw(frame))?; // Handle input events if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { match self.mode { AppMode::Normal => match key.code { // Quit KeyCode::Esc => { log(LogLevel::Info, "Quitting TUI application"); return Ok(()); } KeyCode::Char(c) if c == keybindings_normal::QUIT_CHAR => { log(LogLevel::Info, "Quitting TUI application"); return Ok(()); } // Navigation KeyCode::Down => self.next_row(), KeyCode::Char(c) if c == keybindings_normal::MOVE_DOWN_CHAR => { self.next_row() } KeyCode::Up => self.previous_row(), KeyCode::Char(c) if c == keybindings_normal::MOVE_UP_CHAR => { self.previous_row() } KeyCode::Right => self.next_column(), KeyCode::Char(c) if c == keybindings_normal::MOVE_RIGHT_CHAR => { self.next_column() } KeyCode::Left => self.previous_column(), KeyCode::Char(c) if c == keybindings_normal::MOVE_LEFT_CHAR => { self.previous_column() } // Filters KeyCode::Char(c) if c == keybindings_normal::FILTER_RESTRICTIVE => { self.toggle_restrictive_filter() } KeyCode::Char(c) if c == keybindings_normal::FILTER_INCOMPATIBLE => { self.toggle_incompatible_filter() } KeyCode::Char(c) if c == keybindings_normal::FILTER_COMPATIBLE => { self.toggle_compatible_filter() } KeyCode::Char(c) if c == keybindings_normal::FILTER_OSI_APPROVED => { self.toggle_osi_approved_filter() } KeyCode::Char(c) if c == keybindings_normal::FILTER_OSI_NOT_APPROVED => { self.toggle_osi_not_approved_filter() } KeyCode::Char(c) if c == keybindings_normal::FILTER_OSI_UNKNOWN => { self.toggle_osi_unknown_filter() } KeyCode::Char(c) if c == keybindings_normal::FILTER_CLEAR_ALL => { self.clear_filters() } // Sort mode KeyCode::Char(c) if c == keybindings_normal::ENTER_SORT_MODE => { self.enter_sort_mode() } _ => {} }, AppMode::Sorting => match key.code { // Navigate columns KeyCode::Left => self.previous_sort_column(), KeyCode::Char(c) if c == keybindings_sort::SELECT_PREV_COLUMN_CHAR => { self.previous_sort_column() } KeyCode::Right => self.next_sort_column(), KeyCode::Char(c) if c == keybindings_sort::SELECT_NEXT_COLUMN_CHAR => { self.next_sort_column() } // Apply sort KeyCode::Enter => self.apply_current_sort(), // Exit sort mode KeyCode::Esc => self.exit_sort_mode(), KeyCode::Char(c) if c == keybindings_sort::EXIT_SORT_MODE_CHAR => { self.exit_sort_mode() } _ => {} }, } } } } } fn draw(&mut self, frame: &mut Frame) { // Add space for filter bar if filters are active let vertical = if self.filters.is_any_active() { Layout::vertical([ Constraint::Length(3), Constraint::Min(5), Constraint::Length(5), ]) } else { Layout::vertical([ Constraint::Length(0), Constraint::Min(5), Constraint::Length(5), ]) }; let rects = vertical.split(frame.area()); self.set_colors(); if self.filters.is_any_active() { self.render_filter_bar(frame, rects[0]); } self.render_table(frame, rects[1]); self.render_scrollbar(frame, rects[1]); self.render_footer(frame, rects[2]); } fn render_table(&mut self, frame: &mut Frame, area: Rect) { log(LogLevel::Info, "Rendering table"); let header_style = Style::default() .fg(self.colors.header_fg) .bg(self.colors.header_bg); let selected_row_style = Style::default() .add_modifier(Modifier::REVERSED) .fg(self.colors.selected_row_style_fg); let selected_col_style = Style::default().fg(self.colors.selected_column_style_fg); let selected_cell_style = Style::default() .add_modifier(Modifier::REVERSED) .fg(self.colors.selected_cell_style_fg); // Add Compatibility and OSI Status columns to header // Add sort indicators to column headers if sorting is active let header = SortColumn::all() .iter() .map(|col| { let mut display_name = col.display_name().to_string(); // Add sort direction indicator if this column is sorted if let Some(sort_col) = self.sort_column { if sort_col == *col { let direction = match self.sort_direction { SortDirection::Ascending => " ↑", SortDirection::Descending => " ↓", }; display_name.push_str(direction); } } Cell::from(display_name) }) .collect::() .style(header_style) .height(1); // Use filtered items instead of all items let filtered_items = self.get_filtered_items(); let filtered_count = filtered_items.len(); let total_count = self.items.len(); let rows = filtered_items.iter().enumerate().map(|(i, data)| { let color = match i % 2 { 0 => self.colors.normal_row_color, _ => self.colors.alt_row_color, }; // Style compatibility text based on its value let compatibility_text = match data.compatibility { LicenseCompatibility::Compatible => { Text::from(format!("\n{}\n", "Compatible")).fg(self.colors.compatible_color) } LicenseCompatibility::Incompatible => { Text::from(format!("\n{}\n", "Incompatible")).fg(self.colors.incompatible_color) } LicenseCompatibility::Unknown => { Text::from(format!("\n{}\n", "Unknown")).fg(self.colors.unknown_color) } }; // Style OSI status text based on its value let osi_status_text = match data.osi_status { crate::licenses::OsiStatus::Approved => { Text::from(format!("\n{}\n", "approved")).fg(self.colors.osi_approved_color) } crate::licenses::OsiStatus::NotApproved => { Text::from(format!("\n{}\n", "not-approved")) .fg(self.colors.osi_not_approved_color) } crate::licenses::OsiStatus::Unknown => { Text::from(format!("\n{}\n", "unknown")).fg(self.colors.osi_unknown_color) } }; let row = Row::new([ Cell::from(Text::from(format!("\n{}\n", data.name))), Cell::from(Text::from(format!("\n{}\n", data.version))), Cell::from(Text::from(format!("\n{}\n", data.get_license()))), Cell::from(Text::from(format!("\n{}\n", data.is_restrictive()))), Cell::from(compatibility_text), Cell::from(osi_status_text), ]) .style(Style::new().fg(self.colors.row_fg).bg(color)) .height(4); row }); let bar = " █ "; let t = Table::new( rows, [ // + 1 is for padding. Constraint::Length(self.longest_item_lens.0 + 1), Constraint::Min(self.longest_item_lens.1 + 1), Constraint::Min(self.longest_item_lens.2), Constraint::Min(self.longest_item_lens.3), Constraint::Min(self.longest_item_lens.4), // Compatibility column Constraint::Min(self.longest_item_lens.5), // OSI Status column ], ) .header(header) .row_highlight_style(selected_row_style) .column_highlight_style(selected_col_style) .cell_highlight_style(selected_cell_style) .highlight_symbol(Text::from(vec![ "".into(), bar.into(), bar.into(), "".into(), ])) .bg(self.colors.buffer_bg) .highlight_spacing(HighlightSpacing::Always); frame.render_stateful_widget(t, area, &mut self.state); log( LogLevel::Info, &format!( "Table rendered with {filtered_count} rows (filtered from {total_count} total)" ), ); } fn render_filter_bar(&self, frame: &mut Frame, area: Rect) { let mut filter_tags = Vec::new(); if self.filters.show_restrictive_only { filter_tags.push("Restrictive"); } if self.filters.show_incompatible_only { filter_tags.push("Incompatible"); } if self.filters.show_compatible_only { filter_tags.push("Compatible"); } if self.filters.show_osi_approved_only { filter_tags.push("OSI-Approved"); } if self.filters.show_osi_not_approved_only { filter_tags.push("OSI-NotApproved"); } if self.filters.show_osi_unknown_only { filter_tags.push("OSI-Unknown"); } let filter_text = format!("Active Filters: {}", filter_tags.join(", ")); let filtered_count = self.get_filtered_items().len(); let filter_info = format!( "{} | Showing {} of {} licenses", filter_text, filtered_count, self.items.len() ); let filter_paragraph = Paragraph::new(Text::from(filter_info)) .style( Style::new() .fg(self.colors.footer_border_color) .bg(self.colors.buffer_bg) .add_modifier(Modifier::BOLD), ) .centered() .block( Block::bordered() .border_type(BorderType::Rounded) .border_style(Style::new().fg(self.colors.footer_border_color)), ); frame.render_widget(filter_paragraph, area); } fn render_scrollbar(&mut self, frame: &mut Frame, area: Rect) { frame.render_stateful_widget( Scrollbar::default() .orientation(ScrollbarOrientation::VerticalRight) .begin_symbol(None) .end_symbol(None), area.inner(Margin { vertical: 1, horizontal: 1, }), &mut self.scroll_state, ); } fn render_footer(&self, frame: &mut Frame, area: Rect) { if self.mode == AppMode::Sorting { // Show sort mode UI let mut column_display = String::new(); for (idx, col) in SortColumn::all().iter().enumerate() { if idx == self.sort_column_selection { column_display.push_str(&format!("[>{}< ] ", col.display_name())); } else { column_display.push_str(&format!(" {} ", col.display_name())); } } let current_sort = if let Some(col) = self.sort_column { let dir = match self.sort_direction { SortDirection::Ascending => "↑", SortDirection::Descending => "↓", }; format!("Current: {} {}", col.display_name(), dir) } else { "Current: None".to_string() }; let footer_text = format!("Sort Mode\n{column_display}\n{current_sort}"); let info_footer = Paragraph::new(Text::from(footer_text)) .style( Style::new() .fg(self.colors.header_fg) .bg(self.colors.header_bg), ) .centered() .block( Block::bordered() .border_type(BorderType::Double) .border_style(Style::new().fg(self.colors.selected_row_style_fg)), ); frame.render_widget(info_footer, area); } else { // Normal mode footer // Add sort indicator if a column is being sorted let sort_indicator = if let Some(column) = self.sort_column { let direction = match self.sort_direction { SortDirection::Ascending => "↑", SortDirection::Descending => "↓", }; format!(" | Sort: {} {}", column.display_name(), direction) } else { String::new() }; // Add project license information to footer if available let license_text = if let Some(ref license) = self.project_license { format!("Project: {license}") } else { "Project: Unknown".to_string() }; let footer_text = format!("{license_text} | {}{sort_indicator}", INFO_TEXT[0]); let help_text = format!("\n{}\n{}", INFO_TEXT[1], INFO_TEXT[2]); let info_footer = Paragraph::new(Text::from(format!("{footer_text}{help_text}"))) .style( Style::new() .fg(self.colors.row_fg) .bg(self.colors.buffer_bg), ) .centered() .block( Block::bordered() .border_type(BorderType::Double) .border_style(Style::new().fg(self.colors.footer_border_color)), ); frame.render_widget(info_footer, area); } } } fn constraint_len_calculator(items: &[LicenseInfo]) -> (u16, u16, u16, u16, u16, u16) { log(LogLevel::Info, "Calculating column widths for table"); let name_len = items .iter() .map(LicenseInfo::name) .map(UnicodeWidthStr::width) .max() .unwrap_or(0); let version_len = items .iter() .map(LicenseInfo::version) .map(UnicodeWidthStr::width) .max() .unwrap_or(0); let license_len = items .iter() .map(|info| info.get_license()) .map(|s| s.width()) .max() .unwrap_or(0); let restricted_len = "true".width().max("false".width()); // Calculate width for the Compatibility column let compatibility_len = ["Compatible", "Incompatible", "Unknown"] .iter() .map(|s| s.width()) .max() .unwrap_or(0); // Calculate width for the OSI Status column let osi_status_len = ["approved", "not-approved", "unknown"] .iter() .map(|s| s.width()) .max() .unwrap_or(0); #[allow(clippy::cast_possible_truncation)] let result = ( name_len as u16, version_len as u16, license_len as u16, restricted_len as u16, compatibility_len as u16, osi_status_len as u16, ); log(LogLevel::Info, &format!("Table column widths: {result:?}")); result } #[cfg(test)] mod tests { use super::*; #[test] fn test_app_new() { let test_data = vec![LicenseInfo { name: "test_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let app = App::new(test_data.clone(), Some("MIT".to_string())); assert_eq!(app.items.len(), 1); assert_eq!(app.project_license, Some("MIT".to_string())); assert_eq!(app.state.selected(), Some(0)); let app_no_license = App::new(test_data, None); assert!(app_no_license.project_license.is_none()); } #[test] fn test_app_new_empty_data() { let test_data = vec![]; let app = App::new(test_data, Some("Apache-2.0".to_string())); assert_eq!(app.items.len(), 0); assert_eq!(app.project_license, Some("Apache-2.0".to_string())); assert_eq!(app.state.selected(), Some(0)); } #[test] fn test_app_navigation() { let test_data = vec![ LicenseInfo { name: "package1".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "2.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package3".to_string(), version: "3.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); assert_eq!(app.state.selected(), Some(0)); app.next_row(); assert_eq!(app.state.selected(), Some(1)); app.next_row(); assert_eq!(app.state.selected(), Some(2)); app.next_row(); assert_eq!(app.state.selected(), Some(0)); app.previous_row(); assert_eq!(app.state.selected(), Some(2)); app.previous_row(); assert_eq!(app.state.selected(), Some(1)); app.previous_row(); assert_eq!(app.state.selected(), Some(0)); app.previous_row(); assert_eq!(app.state.selected(), Some(2)); app.next_column(); app.previous_column(); } #[test] fn test_app_navigation_single_item() { let test_data = vec![LicenseInfo { name: "single_package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let mut app = App::new(test_data, None); assert_eq!(app.state.selected(), Some(0)); app.next_row(); assert_eq!(app.state.selected(), Some(0)); app.previous_row(); assert_eq!(app.state.selected(), Some(0)); } #[test] fn test_app_navigation_empty_list() { let test_data = vec![]; let mut app = App::new(test_data, None); assert_eq!(app.state.selected(), Some(0)); app.next_row(); assert_eq!(app.state.selected(), Some(0)); app.previous_row(); assert_eq!(app.state.selected(), Some(0)); } #[test] fn test_constraint_len_calculator() { let test_data = vec![ LicenseInfo { name: "very_long_package_name_that_exceeds_normal_length".to_string(), version: "1.0.0-beta.1+build.123".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "short".to_string(), version: "2.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let (name_len, version_len, license_len, restricted_len, compatibility_len, _osi_len) = constraint_len_calculator(&test_data); assert_eq!( name_len, "very_long_package_name_that_exceeds_normal_length".len() as u16 ); assert_eq!(version_len, "1.0.0-beta.1+build.123".len() as u16); assert_eq!(license_len, "Apache-2.0".len() as u16); assert_eq!(restricted_len, "false".len() as u16); assert_eq!(compatibility_len, "Incompatible".len() as u16); } #[test] fn test_constraint_len_calculator_empty() { let test_data = vec![]; let (name_len, version_len, license_len, restricted_len, compatibility_len, _osi_len) = constraint_len_calculator(&test_data); assert_eq!(name_len, 0); assert_eq!(version_len, 0); assert_eq!(license_len, 0); assert_eq!(restricted_len, "false".len() as u16); assert_eq!(compatibility_len, "Incompatible".len() as u16); } #[test] fn test_constraint_len_calculator_unicode() { let test_data = vec![LicenseInfo { name: "package_with_émojis_🚀_and_ünïcödé".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let (name_len, _, _, _, _, _) = constraint_len_calculator(&test_data); assert!(name_len > 0); } #[test] fn test_constraint_len_calculator_all_compatibility_types() { let test_data = vec![ LicenseInfo { name: "compatible".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "incompatible".to_string(), version: "1.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "unknown".to_string(), version: "1.0.0".to_string(), license: Some("Custom".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Unknown, osi_status: crate::licenses::OsiStatus::Unknown, }, ]; let (_, _, _, _, compatibility_len, _) = constraint_len_calculator(&test_data); assert_eq!(compatibility_len, "Incompatible".len() as u16); } #[test] fn test_constraint_len_calculator_restrictive_values() { let test_data = vec![ LicenseInfo { name: "package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: true, // true compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "1.0.0".to_string(), license: Some("Apache".to_string()), is_restrictive: false, // false compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let (_, _, _, restricted_len, _, _) = constraint_len_calculator(&test_data); assert_eq!(restricted_len, "false".len() as u16); } #[test] fn test_item_height_constant() { assert_eq!(ITEM_HEIGHT, 4); } #[test] fn test_info_text_constant() { assert_eq!(INFO_TEXT.len(), 3); assert!(INFO_TEXT[0].contains("Esc")); assert!(INFO_TEXT[0].contains("quit")); assert!(INFO_TEXT[0].contains("move up")); assert!(INFO_TEXT[0].contains("move down")); assert!(INFO_TEXT[1].contains("restrictive")); assert!(INFO_TEXT[1].contains("incompatible")); assert!(INFO_TEXT[1].contains("compatible")); assert!(INFO_TEXT[1].contains("sort mode")); assert!(INFO_TEXT[2].contains("sort mode")); assert!(INFO_TEXT[2].contains("Enter")); } #[test] fn test_app_longest_item_lens_calculation() { let test_data = vec![ LicenseInfo { name: "short".to_string(), version: "1.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "much_longer_name".to_string(), version: "1.0.0-beta".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let app = App::new(test_data, None); assert_eq!(app.longest_item_lens.0, "much_longer_name".len() as u16); assert_eq!(app.longest_item_lens.1, "1.0.0-beta".len() as u16); assert_eq!(app.longest_item_lens.2, "Apache-2.0".len() as u16); assert_eq!(app.longest_item_lens.3, "false".len() as u16); assert_eq!(app.longest_item_lens.4, "Incompatible".len() as u16); } #[test] fn test_sort_by_name() { let test_data = vec![ LicenseInfo { name: "zebra".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "apple".to_string(), version: "2.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "banana".to_string(), version: "3.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); app.enter_sort_mode(); // SortColumn::Name is at index 0, so no navigation needed app.apply_current_sort(); assert_eq!(app.items[0].name, "apple"); assert_eq!(app.items[1].name, "banana"); assert_eq!(app.items[2].name, "zebra"); assert_eq!(app.sort_column, Some(SortColumn::Name)); assert_eq!(app.sort_direction, SortDirection::Ascending); assert_eq!(app.mode, AppMode::Normal); } #[test] fn test_sort_by_name_descending() { let test_data = vec![ LicenseInfo { name: "apple".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "zebra".to_string(), version: "2.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); app.enter_sort_mode(); app.apply_current_sort(); // First sort ascending app.enter_sort_mode(); app.apply_current_sort(); // Toggle to descending assert_eq!(app.items[0].name, "zebra"); assert_eq!(app.items[1].name, "apple"); assert_eq!(app.sort_direction, SortDirection::Descending); } #[test] fn test_sort_by_restrictive() { let test_data = vec![ LicenseInfo { name: "package1".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "2.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); app.enter_sort_mode(); // Navigate to Restrictive column (index 3) app.next_sort_column(); // 1 app.next_sort_column(); // 2 app.next_sort_column(); // 3 app.apply_current_sort(); // False comes before True in ascending order assert!(!app.items[0].is_restrictive); assert!(app.items[1].is_restrictive); assert_eq!(app.sort_column, Some(SortColumn::Restrictive)); } #[test] fn test_sort_mode_navigation() { let test_data = vec![LicenseInfo { name: "test".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let mut app = App::new(test_data, None); assert_eq!(app.mode, AppMode::Normal); app.enter_sort_mode(); assert_eq!(app.mode, AppMode::Sorting); assert_eq!(app.sort_column_selection, 0); app.next_sort_column(); assert_eq!(app.sort_column_selection, 1); app.previous_sort_column(); assert_eq!(app.sort_column_selection, 0); app.exit_sort_mode(); assert_eq!(app.mode, AppMode::Normal); } #[test] fn test_sort_direction_toggle() { let test_data = vec![LicenseInfo { name: "package".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let mut app = App::new(test_data, None); // First sort should be Ascending app.enter_sort_mode(); app.apply_current_sort(); assert_eq!(app.sort_direction, SortDirection::Ascending); // Second sort on same column should toggle to Descending app.enter_sort_mode(); app.apply_current_sort(); assert_eq!(app.sort_direction, SortDirection::Descending); // Third sort should toggle back to Ascending app.enter_sort_mode(); app.apply_current_sort(); assert_eq!(app.sort_direction, SortDirection::Ascending); } #[test] fn test_sort_column_change() { let test_data = vec![ LicenseInfo { name: "zebra".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "apple".to_string(), version: "5.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); // Sort by Name app.enter_sort_mode(); app.apply_current_sort(); assert_eq!(app.items[0].name, "apple"); assert_eq!(app.sort_direction, SortDirection::Ascending); // Change to sort by Version - should reset to Ascending app.enter_sort_mode(); app.next_sort_column(); // Navigate to Version (index 1) app.apply_current_sort(); assert_eq!(app.sort_column, Some(SortColumn::Version)); assert_eq!(app.sort_direction, SortDirection::Ascending); } #[test] fn test_initial_sort_state() { let test_data = vec![LicenseInfo { name: "test".to_string(), version: "1.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }]; let app = App::new(test_data, None); assert_eq!(app.sort_column, None); assert_eq!(app.sort_direction, SortDirection::Ascending); assert_eq!(app.mode, AppMode::Normal); assert_eq!(app.sort_column_selection, 0); } #[test] fn test_sort_by_version_with_v_prefix() { let test_data = vec![ LicenseInfo { name: "package1".to_string(), version: "v3.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "v1.0.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package3".to_string(), version: "v2.5.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); app.enter_sort_mode(); // Navigate to Version column (index 1) app.next_sort_column(); app.apply_current_sort(); // Should be sorted as v1.0.0, v2.5.0, v3.0.0 assert_eq!(app.items[0].version, "v1.0.0"); assert_eq!(app.items[1].version, "v2.5.0"); assert_eq!(app.items[2].version, "v3.0.0"); assert_eq!(app.sort_column, Some(SortColumn::Version)); assert_eq!(app.sort_direction, SortDirection::Ascending); } #[test] fn test_sort_by_version_mixed_prefix() { let test_data = vec![ LicenseInfo { name: "package1".to_string(), version: "3.0.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "v1.5.0".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package3".to_string(), version: "v2.0.0".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); app.enter_sort_mode(); // Navigate to Version column (index 1) app.next_sort_column(); app.apply_current_sort(); // Should be sorted as v1.5.0, v2.0.0, 3.0.0 (semantic versions first, then non-semantic) assert_eq!(app.items[0].version, "v1.5.0"); assert_eq!(app.items[1].version, "v2.0.0"); assert_eq!(app.items[2].version, "3.0.0"); } #[test] fn test_sort_by_version_descending() { let test_data = vec![ LicenseInfo { name: "package1".to_string(), version: "v10.14.0".to_string(), license: Some("MIT".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package2".to_string(), version: "0.14".to_string(), license: Some("Apache-2.0".to_string()), is_restrictive: false, compatibility: LicenseCompatibility::Compatible, osi_status: crate::licenses::OsiStatus::Approved, }, LicenseInfo { name: "package3".to_string(), version: "2015.7".to_string(), license: Some("GPL-3.0".to_string()), is_restrictive: true, compatibility: LicenseCompatibility::Incompatible, osi_status: crate::licenses::OsiStatus::Approved, }, ]; let mut app = App::new(test_data, None); app.enter_sort_mode(); // Navigate to Version column (index 1) app.next_sort_column(); app.apply_current_sort(); // First sort on Version (ascending) // Enter sort mode again - it should remember we're on Version app.enter_sort_mode(); // We should already be on Version (index 1), so no need to navigate app.apply_current_sort(); // Toggle to Descending // In descending: non-semantic versions come BEFORE semantic versions // String order reversed: "2015.7" < "0.14" when reversed assert_eq!(app.items[0].version, "0.14"); assert_eq!(app.items[1].version, "2015.7"); assert_eq!(app.items[2].version, "v10.14.0"); assert_eq!(app.sort_direction, SortDirection::Descending); } } feluda-1.11.1/src/utils.rs000064400000000000000000000514441046102023000134520ustar 00000000000000use crate::cli::Cli; use crate::debug::{log, FeludaError, FeludaResult, LogLevel}; use git2::Cred; use std::path::Path; use std::sync::atomic::{AtomicUsize, Ordering}; fn ssh_to_https_url(repo_url: &str) -> Option { if repo_url.is_empty() || repo_url.len() < "git@github.com:a/b".len() { return None; } if !repo_url.starts_with("git@github.com:") { return None; } let repo_path = &repo_url["git@github.com:".len()..]; if !is_valid_github_repo_path(repo_path) { return None; } Some(format!("https://github.com/{repo_path}")) } fn is_valid_github_repo_path(repo_path: &str) -> bool { if repo_path.is_empty() { return false; } let parts: Vec<&str> = repo_path.split('/').collect(); if parts.len() != 2 { return false; } let (user_or_org, repo_name) = (parts[0], parts[1]); if user_or_org.is_empty() || repo_name.is_empty() { return false; } if !is_valid_github_username(user_or_org) { return false; } let repo_name_clean = repo_name.strip_suffix(".git").unwrap_or(repo_name); if !is_valid_github_repo_name(repo_name_clean) { return false; } true } fn is_valid_github_username(username: &str) -> bool { if username.is_empty() || username.len() > 39 { return false; } if username.starts_with('-') || username.ends_with('-') { return false; } username .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-') } fn is_valid_github_repo_name(repo_name: &str) -> bool { if repo_name.is_empty() || repo_name.len() > 100 { return false; } if repo_name == "." || repo_name == ".." { return false; } repo_name .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) } fn validate_ssh_key(key_path: &Path) -> Result<(), git2::Error> { if !key_path.exists() { log( LogLevel::Error, &format!("SSH key file not found: {}", key_path.display()), ); return Err(git2::Error::from_str("SSH key file not found")); } if key_path .extension() .map(|ext| ext == "pub") .unwrap_or(false) { log( LogLevel::Error, &format!( "Invalid SSH key: {} is a public key (.pub)", key_path.display() ), ); return Err(git2::Error::from_str( "Public key provided instead of private key", )); } Ok(()) } pub fn clone_repository(args: &Cli, dest_path: &Path) -> FeludaResult<()> { let token = &args.token; let ssh_key = &args.ssh_key; let ssh_passphrase = &args.ssh_passphrase; let repo_url = &args.repo.as_deref().unwrap(); log( LogLevel::Info, &format!( "Initializing clone of {} to {}", repo_url, dest_path.display() ), ); let auth_attempts = AtomicUsize::new(0); const MAX_AUTH_ATTEMPTS: usize = 5; let mut callbacks = git2::RemoteCallbacks::new(); callbacks.credentials(|url, username_from_url, allowed_types| { let attempts = auth_attempts.fetch_add(1, Ordering::SeqCst); if attempts >= MAX_AUTH_ATTEMPTS { log(LogLevel::Error, "Max authentication attempts reached"); return Err(git2::Error::from_str("Too many authentication attempts")); } log( LogLevel::Info, &format!("Credentials callback for URL: {url}, username: {username_from_url:?}"), ); if allowed_types.is_ssh_key() { log(LogLevel::Info, "Attempting SSH authentication"); if let Some(key_path) = ssh_key { let key_path = Path::new(&key_path); validate_ssh_key(key_path)?; log( LogLevel::Info, &format!("Using custom SSH key at: {}", key_path.display()), ); Cred::ssh_key( username_from_url.unwrap_or("git"), None, key_path, ssh_passphrase.as_deref(), ) } else { log(LogLevel::Info, "Trying SSH agent"); match Cred::ssh_key_from_agent(username_from_url.unwrap_or("git")) { Ok(cred) => { log(LogLevel::Info, "Using SSH agent credentials"); Ok(cred) } Err(e) => { log( LogLevel::Warn, &format!("SSH agent failed: {e}, trying default key"), ); Err(e) } } } } else if allowed_types.is_user_pass_plaintext() && token.is_some() { log(LogLevel::Info, "Using HTTPS token authentication"); Cred::userpass_plaintext("x-access-token", token.as_deref().unwrap()) } else { log(LogLevel::Info, "Using default credentials for HTTPS"); Cred::default() } }); let mut fetch_options = git2::FetchOptions::new(); fetch_options.remote_callbacks(callbacks); let mut builder = git2::build::RepoBuilder::new(); builder.fetch_options(fetch_options); log( LogLevel::Info, &format!("Cloning {} into {}", repo_url, dest_path.display()), ); match builder.clone(repo_url, dest_path) { Ok(_) => { log(LogLevel::Info, "Clone successful"); Ok(()) } Err(e) => { if repo_url.starts_with("git@") { if let Some(https_url) = ssh_to_https_url(repo_url) { log( LogLevel::Warn, &format!("SSH clone failed: {e}, trying HTTPS: {https_url}"), ); let mut https_callbacks = git2::RemoteCallbacks::new(); https_callbacks.credentials(|_url, _username, allowed_types| { if allowed_types.is_user_pass_plaintext() && token.is_some() { log(LogLevel::Info, "Using HTTPS token authentication"); Cred::userpass_plaintext("x-access-token", token.as_deref().unwrap()) } else { log(LogLevel::Info, "Using default credentials for HTTPS"); Cred::default() } }); let mut https_fetch_options = git2::FetchOptions::new(); https_fetch_options.remote_callbacks(https_callbacks); let mut https_builder = git2::build::RepoBuilder::new(); https_builder.fetch_options(https_fetch_options); log( LogLevel::Info, &format!("Cloning {} into {}", https_url, dest_path.display()), ); return match https_builder.clone(&https_url, dest_path) { Ok(_) => { log(LogLevel::Info, "HTTPS clone successful"); Ok(()) } Err(e) => { log(LogLevel::Error, &format!("HTTPS clone failed: {e}")); Err(FeludaError::RepositoryClone(format!( "Failed to clone repository: {e}" ))) } }; } } log(LogLevel::Error, &format!("Failed to clone repository: {e}")); Err(FeludaError::RepositoryClone(format!( "Failed to clone repository: {e}" ))) } } } #[cfg(test)] mod tests { use super::*; use std::fs::File; use tempfile::TempDir; #[test] fn test_ssh_to_https_url_github_ssh() { let url = "git@github.com:anistark/feluda.git"; let result = ssh_to_https_url(url); assert_eq!( result, Some("https://github.com/anistark/feluda.git".to_string()) ); } #[test] fn test_ssh_to_https_url_non_github() { let url = "git@gitlab.com:user/repo.git"; let result = ssh_to_https_url(url); assert_eq!(result, None); } #[test] fn test_ssh_to_https_url_non_ssh() { let url = "https://github.com/anistark/feluda.git"; let result = ssh_to_https_url(url); assert_eq!(result, None); } #[test] fn test_validate_ssh_key_exists() { let temp_dir = TempDir::new().unwrap(); let key_path = temp_dir.path().join("id_rsa"); File::create(&key_path).unwrap(); let result = validate_ssh_key(&key_path); assert!(result.is_ok()); } #[test] fn test_validate_ssh_key_not_exists() { let temp_dir = TempDir::new().unwrap(); let key_path = temp_dir.path().join("id_rsa"); let result = validate_ssh_key(&key_path); assert!(result.is_err()); assert_eq!(result.unwrap_err().to_string(), "SSH key file not found"); } #[test] fn test_validate_ssh_key_public_key() { let temp_dir = TempDir::new().unwrap(); let key_path = temp_dir.path().join("id_rsa.pub"); File::create(&key_path).unwrap(); let result = validate_ssh_key(&key_path); assert!(result.is_err()); assert_eq!( result.unwrap_err().to_string(), "Public key provided instead of private key" ); } #[test] fn test_ssh_to_https_url_various_formats() { // Test standard GitHub SSH assert_eq!( ssh_to_https_url("git@github.com:user/repo.git"), Some("https://github.com/user/repo.git".to_string()) ); // Test without .git extension assert_eq!( ssh_to_https_url("git@github.com:user/repo"), Some("https://github.com/user/repo".to_string()) ); // Test with organization assert_eq!( ssh_to_https_url("git@github.com:organization/project.git"), Some("https://github.com/organization/project.git".to_string()) ); // Test HTTPS URL assert_eq!(ssh_to_https_url("https://github.com/user/repo.git"), None); // Test GitLab SSH assert_eq!(ssh_to_https_url("git@gitlab.com:user/repo.git"), None); // Test other SSH formats that aren't GitHub assert_eq!(ssh_to_https_url("git@bitbucket.org:user/repo.git"), None); assert_eq!(ssh_to_https_url("git@codeberg.org:user/repo.git"), None); // Test malformed SSH URL assert_eq!(ssh_to_https_url("invalid-ssh-format"), None); assert_eq!(ssh_to_https_url("git@github.com"), None); // These now return None with improved validation assert_eq!(ssh_to_https_url("git@github.com:"), None); // Test empty string assert_eq!(ssh_to_https_url(""), None); // Test with special characters in repo name assert_eq!( ssh_to_https_url("git@github.com:user/repo-with-dashes.git"), Some("https://github.com/user/repo-with-dashes.git".to_string()) ); assert_eq!( ssh_to_https_url("git@github.com:user/repo_name.git"), Some("https://github.com/user/repo_name.git".to_string()) ); // Test case sensitivity assert_eq!(ssh_to_https_url("git@GitHub.com:user/repo.git"), None); assert_eq!(ssh_to_https_url("git@GITHUB.COM:user/repo.git"), None); } #[test] fn test_validate_ssh_key_scenarios() { let temp_dir = tempfile::TempDir::new().unwrap(); // Test valid private key let private_key_path = temp_dir.path().join("id_rsa"); std::fs::File::create(&private_key_path).unwrap(); assert!(validate_ssh_key(&private_key_path).is_ok()); // Test public key rejection let public_key_path = temp_dir.path().join("id_rsa.pub"); std::fs::File::create(&public_key_path).unwrap(); let result = validate_ssh_key(&public_key_path); assert!(result.is_err()); assert!(result .unwrap_err() .message() .contains("Public key provided")); // Test non-existent key let missing_key_path = temp_dir.path().join("missing_key"); let result = validate_ssh_key(&missing_key_path); assert!(result.is_err()); assert!(result .unwrap_err() .message() .contains("SSH key file not found")); // Test different private key types let ed25519_key_path = temp_dir.path().join("id_ed25519"); std::fs::File::create(&ed25519_key_path).unwrap(); assert!(validate_ssh_key(&ed25519_key_path).is_ok()); let ecdsa_key_path = temp_dir.path().join("id_ecdsa"); std::fs::File::create(&ecdsa_key_path).unwrap(); assert!(validate_ssh_key(&ecdsa_key_path).is_ok()); // Test key with no extension let no_ext_key_path = temp_dir.path().join("ssh_key"); std::fs::File::create(&no_ext_key_path).unwrap(); assert!(validate_ssh_key(&no_ext_key_path).is_ok()); // Test various public key extensions let pub_variations = [ "key.pub", "id_rsa.pub", "id_ed25519.pub", "id_ecdsa.pub", "custom.pub", ]; for pub_key_name in &pub_variations { let pub_key_path = temp_dir.path().join(pub_key_name); std::fs::File::create(&pub_key_path).unwrap(); let result = validate_ssh_key(&pub_key_path); assert!(result.is_err()); assert!(result .unwrap_err() .message() .contains("Public key provided")); } } #[test] fn test_clone_repository_error_handling() { let temp_dir = tempfile::TempDir::new().unwrap(); // Create CLI args with invalid repository let args = Cli { debug: false, command: None, path: "./".to_string(), repo: Some("invalid-repo-url".to_string()), token: None, ssh_key: None, ssh_passphrase: None, github_token: None, json: false, yaml: false, verbose: false, restrictive: false, gui: false, language: None, ci_format: None, output_file: None, fail_on_restrictive: false, incompatible: false, fail_on_incompatible: false, project_license: None, gist: false, osi: None, strict: false, no_local: false, }; let result = clone_repository(&args, temp_dir.path()); assert!(result.is_err()); } #[test] fn test_validate_ssh_key_permissions() { let temp_dir = tempfile::TempDir::new().unwrap(); // Create a key file let key_path = temp_dir.path().join("test_key"); std::fs::write(&key_path, "fake key content").unwrap(); // Test that the file exists and validation passes assert!(validate_ssh_key(&key_path).is_ok()); // Test validation after the file is deleted std::fs::remove_file(&key_path).unwrap(); let result = validate_ssh_key(&key_path); assert!(result.is_err()); assert!(result .unwrap_err() .message() .contains("SSH key file not found")); } #[test] fn test_clone_repository_debug_mode() { let temp_dir = tempfile::TempDir::new().unwrap(); let args = Cli { debug: true, command: None, path: "./".to_string(), repo: Some("https://github.com/nonexistent/repo.git".to_string()), token: None, ssh_key: None, ssh_passphrase: None, github_token: None, json: false, yaml: false, verbose: false, restrictive: false, gui: false, language: None, ci_format: None, output_file: None, fail_on_restrictive: false, incompatible: false, fail_on_incompatible: false, project_license: None, gist: false, osi: None, strict: false, no_local: false, }; // Enable debug mode for this test crate::debug::set_debug_mode(true); let result = clone_repository(&args, temp_dir.path()); assert!(result.is_err()); // Reset debug mode crate::debug::set_debug_mode(false); } #[test] fn test_ssh_to_https_url_case_sensitivity() { // Test that the function handles case correctly assert_eq!(ssh_to_https_url("git@GitHub.com:user/repo.git"), None); assert_eq!(ssh_to_https_url("git@GITHUB.COM:user/repo.git"), None); // Test correct case assert_eq!( ssh_to_https_url("git@github.com:user/repo.git"), Some("https://github.com/user/repo.git".to_string()) ); } #[test] fn test_clone_repository_empty_repo_url() { let temp_dir = tempfile::TempDir::new().unwrap(); let args = Cli { debug: false, command: None, path: "./".to_string(), repo: Some("".to_string()), token: None, ssh_key: None, ssh_passphrase: None, github_token: None, json: false, yaml: false, verbose: false, restrictive: false, gui: false, language: None, ci_format: None, output_file: None, fail_on_restrictive: false, incompatible: false, fail_on_incompatible: false, project_license: None, gist: false, osi: None, strict: false, no_local: false, }; let result = clone_repository(&args, temp_dir.path()); assert!(result.is_err()); } #[test] fn test_ssh_to_https_url_validation() { // Test valid cases assert_eq!( ssh_to_https_url("git@github.com:microsoft/vscode.git"), Some("https://github.com/microsoft/vscode.git".to_string()) ); assert_eq!( ssh_to_https_url("git@github.com:user-name/repo-name.git"), Some("https://github.com/user-name/repo-name.git".to_string()) ); assert_eq!( ssh_to_https_url("git@github.com:user123/repo_name.config.git"), Some("https://github.com/user123/repo_name.config.git".to_string()) ); // Test invalid usernames (start/end with hyphen) assert_eq!(ssh_to_https_url("git@github.com:-user/repo.git"), None); assert_eq!(ssh_to_https_url("git@github.com:user-/repo.git"), None); // Test invalid repository names assert_eq!(ssh_to_https_url("git@github.com:user/.git"), None); assert_eq!(ssh_to_https_url("git@github.com:user/..git"), None); // Test too many path components assert_eq!(ssh_to_https_url("git@github.com:user/repo/extra"), None); // Test very long usernames/repos let long_username = "a".repeat(40); assert_eq!( ssh_to_https_url(&format!("git@github.com:{long_username}/repo.git")), None ); let long_repo = "a".repeat(101); assert_eq!( ssh_to_https_url(&format!("git@github.com:user/{long_repo}.git")), None ); // Test empty components assert_eq!(ssh_to_https_url("git@github.com:/repo.git"), None); assert_eq!(ssh_to_https_url("git@github.com:user/"), None); } #[test] fn test_ssh_to_https_url_special_characters() { // Valid special characters in repository names assert_eq!( ssh_to_https_url("git@github.com:user/my-project.js.git"), Some("https://github.com/user/my-project.js.git".to_string()) ); assert_eq!( ssh_to_https_url("git@github.com:user/config_file.json"), Some("https://github.com/user/config_file.json".to_string()) ); // Invalid characters in usernames assert_eq!( ssh_to_https_url("git@github.com:user-name/repo.git"), Some("https://github.com/user-name/repo.git".to_string()) ); // underscores in usernames should be invalid assert_eq!(ssh_to_https_url("git@github.com:user_name/repo.git"), None); // Invalid characters assert_eq!(ssh_to_https_url("git@github.com:user@name/repo.git"), None); assert_eq!(ssh_to_https_url("git@github.com:user/repo@name.git"), None); assert_eq!(ssh_to_https_url("git@github.com:user name/repo.git"), None); } }