diff --git a/Cargo.lock b/Cargo.lock index 8f2aa6c..5a65b98 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,18 +61,115 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "antlr4rust" +version = "0.3.0-rc2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d240d49ee89063f90fa0cb18aead41a5893cd544a1785983dc3bf5c3d5faa58b" +dependencies = [ + "better_any", + "bit-set", + "byteorder", + "lazy_static", + "murmur3", + "once_cell", + "parking_lot", + "typed-arena", + "uuid", +] + [[package]] name = "anyhow" version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "better_any" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4372b9543397a4b86050cc5e7ee36953edf4bac9518e8a774c2da694977fb6e4" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "cel-interpreter" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76c07820046cc8239526fceec6df147a979ae48644dbad274fc3ce38ab0973b" +dependencies = [ + "cel-parser", + "chrono", + "nom", + "paste", + "regex", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cel-parser" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "546fb134998490c5c47fc7a29c7535e725d2e403f172040e8f263d0b318bff5f" +dependencies = [ + "antlr4rust", + "lazy_static", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "num-traits", + "serde", +] + [[package]] name = "clap" version = "4.5.57" @@ -202,18 +299,118 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[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.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "murmur3" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a198f9589efc03f544388dfc4a19fe8af4323662b62f598b8dcfdac62c14771c" +dependencies = [ + "byteorder", +] + +[[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 = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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 = "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 = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -253,6 +450,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.2" @@ -322,10 +528,11 @@ name = "runok" version = "0.1.0" dependencies = [ "anyhow", + "cel-interpreter", "clap", "rstest", "serde", - "thiserror", + "thiserror 2.0.18", ] [[package]] @@ -337,6 +544,18 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.27" @@ -379,6 +598,12 @@ 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 = "strsim" version = "0.11.1" @@ -396,13 +621,33 @@ dependencies = [ "unicode-ident", ] +[[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.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[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", ] [[package]] @@ -446,6 +691,12 @@ dependencies = [ "winnow", ] +[[package]] +name = "typed-arena" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -458,6 +709,61 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 6fca0b8..e0dfb0f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" [dependencies] anyhow = "=1.0.101" +cel-interpreter = "=0.10.0" clap = { version = "=4.5.57", features = ["derive"] } serde = { version = "=1.0.228", features = ["derive"] } thiserror = "=2.0.18" diff --git a/src/rules/expr_evaluator.rs b/src/rules/expr_evaluator.rs new file mode 100644 index 0000000..216822b --- /dev/null +++ b/src/rules/expr_evaluator.rs @@ -0,0 +1,259 @@ +use std::collections::HashMap; + +use super::ExprError; + +/// Context for CEL expression evaluation, providing access to +/// environment variables, parsed flags, positional arguments, and path lists. +pub struct ExprContext { + pub env: HashMap, + pub flags: HashMap>, + pub args: Vec, + pub paths: HashMap>, +} + +/// Evaluates a CEL expression against a given context, returning a boolean result. +pub fn evaluate(expr: &str, context: &ExprContext) -> Result { + let program = + cel_interpreter::Program::compile(expr).map_err(|e| ExprError::Parse(e.to_string()))?; + + let mut cel_context = cel_interpreter::Context::default(); + + cel_context.add_variable_from_value("env", context.env.clone()); + + // Convert Option flags to CEL-compatible values. + // None values become null in CEL. + let flags_value: HashMap = context + .flags + .iter() + .map(|(k, v)| { + let val = match v { + Some(s) => cel_interpreter::Value::String(s.clone().into()), + None => cel_interpreter::Value::Null, + }; + (k.clone(), val) + }) + .collect(); + cel_context.add_variable_from_value("flags", flags_value); + + cel_context.add_variable_from_value("args", context.args.clone()); + + cel_context + .add_variable("paths", &context.paths) + .map_err(|e| ExprError::Eval(e.to_string()))?; + + let result = program + .execute(&cel_context) + .map_err(|e| ExprError::Eval(e.to_string()))?; + + match result { + cel_interpreter::Value::Bool(b) => Ok(b), + other => Err(ExprError::TypeError(format!("{:?}", other))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + fn empty_context() -> ExprContext { + ExprContext { + env: HashMap::new(), + flags: HashMap::new(), + args: Vec::new(), + paths: HashMap::new(), + } + } + + // === Environment variable access === + + #[rstest] + #[case("env.AWS_PROFILE == 'prod'", "AWS_PROFILE", "prod", true)] + #[case("env.AWS_PROFILE == 'prod'", "AWS_PROFILE", "dev", false)] + #[case("env.HOME == '/home/user'", "HOME", "/home/user", true)] + fn env_variable_access( + #[case] expr: &str, + #[case] key: &str, + #[case] value: &str, + #[case] expected: bool, + ) { + let context = ExprContext { + env: HashMap::from([(key.to_string(), value.to_string())]), + ..empty_context() + }; + assert_eq!(evaluate(expr, &context).unwrap(), expected); + } + + // === Flag access === + + #[rstest] + #[case("flags.method == 'POST'", "method", Some("POST"), true)] + #[case("flags.method == 'POST'", "method", Some("GET"), false)] + #[case("flags.method == 'POST'", "method", None, false)] + fn flag_access( + #[case] expr: &str, + #[case] key: &str, + #[case] value: Option<&str>, + #[case] expected: bool, + ) { + let context = ExprContext { + flags: HashMap::from([(key.to_string(), value.map(|s| s.to_string()))]), + ..empty_context() + }; + assert_eq!(evaluate(expr, &context).unwrap(), expected); + } + + // === Argument access === + + #[rstest] + #[case("args[0] == 'build'", vec!["build", "--release"], true)] + #[case("args[0] == 'test'", vec!["build", "--release"], false)] + #[case("args[1] == '--release'", vec!["build", "--release"], true)] + fn args_index_access(#[case] expr: &str, #[case] args: Vec<&str>, #[case] expected: bool) { + let context = ExprContext { + args: args.into_iter().map(|s| s.to_string()).collect(), + ..empty_context() + }; + assert_eq!(evaluate(expr, &context).unwrap(), expected); + } + + #[test] + fn args_starts_with() { + let context = ExprContext { + args: vec!["https://prod.example.com/api".to_string()], + ..empty_context() + }; + assert!(evaluate("args[0].startsWith('https://prod')", &context).unwrap()); + assert!(!evaluate("args[0].startsWith('http://dev')", &context).unwrap()); + } + + // === Path list access === + + #[test] + fn paths_access() { + let context = ExprContext { + paths: HashMap::from([( + "sensitive".to_string(), + vec![".env".to_string(), ".envrc".to_string()], + )]), + ..empty_context() + }; + let result = evaluate("size(paths.sensitive) == 2", &context); + assert!(result.unwrap()); + } + + #[test] + fn paths_contains_check() { + let context = ExprContext { + paths: HashMap::from([( + "sensitive".to_string(), + vec![ + ".env".to_string(), + ".envrc".to_string(), + "~/.ssh/**".to_string(), + ], + )]), + ..empty_context() + }; + // CEL's `in` operator checks if a value exists in a list + assert!(evaluate("'.env' in paths.sensitive", &context).unwrap()); + assert!(!evaluate("'.bashrc' in paths.sensitive", &context).unwrap()); + } + + // === Logical operators === + + #[test] + fn logical_and() { + let context = ExprContext { + env: HashMap::from([ + ("A".to_string(), "x".to_string()), + ("B".to_string(), "y".to_string()), + ]), + ..empty_context() + }; + assert!(evaluate("env.A == 'x' && env.B == 'y'", &context).unwrap()); + assert!(!evaluate("env.A == 'x' && env.B == 'z'", &context).unwrap()); + } + + #[test] + fn logical_or() { + let context = ExprContext { + env: HashMap::from([("A".to_string(), "x".to_string())]), + ..empty_context() + }; + assert!(evaluate("env.A == 'x' || env.A == 'z'", &context).unwrap()); + assert!(!evaluate("env.A == 'y' || env.A == 'z'", &context).unwrap()); + } + + #[test] + fn logical_not() { + let context = ExprContext { + env: HashMap::from([("A".to_string(), "x".to_string())]), + ..empty_context() + }; + assert!(evaluate("!(env.A == 'y')", &context).unwrap()); + assert!(!evaluate("!(env.A == 'x')", &context).unwrap()); + } + + // === Combined conditions === + + #[test] + fn combined_conditions() { + let context = ExprContext { + env: HashMap::from([("AWS_PROFILE".to_string(), "prod".to_string())]), + flags: HashMap::from([("method".to_string(), Some("POST".to_string()))]), + args: vec!["https://prod.example.com/api".to_string()], + paths: HashMap::new(), + }; + assert!( + evaluate( + "flags.method == 'POST' && args[0].startsWith('https://prod')", + &context + ) + .unwrap() + ); + assert!( + evaluate( + "env.AWS_PROFILE == 'prod' && flags.method == 'POST'", + &context + ) + .unwrap() + ); + } + + // === Error cases === + + #[test] + fn parse_error_on_invalid_expression() { + let result = evaluate("@@@ invalid", &empty_context()); + assert!(result.is_err()); + match result.unwrap_err() { + ExprError::Parse(_) => {} + other => panic!("expected ExprError::Parse, got {:?}", other), + } + } + + #[test] + fn eval_error_on_undeclared_reference() { + let result = evaluate("missing.var == 'x'", &empty_context()); + assert!(result.is_err()); + match result.unwrap_err() { + ExprError::Eval(_) => {} + other => panic!("expected ExprError::Eval, got {:?}", other), + } + } + + #[test] + fn type_error_on_non_bool_result() { + let context = ExprContext { + env: HashMap::from([("A".to_string(), "x".to_string())]), + ..empty_context() + }; + let result = evaluate("env.A", &context); + assert!(result.is_err()); + match result.unwrap_err() { + ExprError::TypeError(_) => {} + other => panic!("expected ExprError::TypeError, got {:?}", other), + } + } +} diff --git a/src/rules/mod.rs b/src/rules/mod.rs index eb32edd..b6b8dac 100644 --- a/src/rules/mod.rs +++ b/src/rules/mod.rs @@ -1,4 +1,5 @@ mod error; +pub mod expr_evaluator; pub mod pattern_lexer; pub mod pattern_parser;