From 3fd8807fd8589dc9c82fd98ea638a45a392934a4 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:21:23 +0000 Subject: [PATCH 1/4] cli: add --test flag to evaluate rules against a URL (dry-run)\n\n- Add --test flag to CLI with URL argument\n- Build rule engine from --js/--js-file/--script and evaluate GET URL\n- Exit 0 on ALLOW, 1 on DENY; print result and optional context\n- Update clap to allow --test without a command\n- Add tests for allow/deny behavior\n\nFixes #30\n\nCo-authored-by: ammario <7416144+ammario@users.noreply.github.com> --- src/main.rs | 36 ++++++++++++++++++++++++++++++++---- tests/test_flag.rs | 21 +++++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 tests/test_flag.rs diff --git a/src/main.rs b/src/main.rs index 29a01ce8..224453d2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,14 +1,16 @@ use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Parser, ArgAction}; use httpjail::jail::{JailConfig, create_jail}; use httpjail::proxy::ProxyServer; -use httpjail::rules::RuleEngine; +use httpjail::rules::{RuleEngine, Action}; use httpjail::rules::script::ScriptRuleEngine; use httpjail::rules::v8_js::V8JsRuleEngine; +use hyper::Method; use std::fs::OpenOptions; use std::os::unix::process::ExitStatusExt; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use tokio::task::spawn_blocking; use tracing::{debug, info, warn}; #[derive(Parser, Debug)] @@ -79,8 +81,17 @@ struct Args { )] server: bool, + /// Evaluate rule against a URL and exit (dry-run) + #[arg( + long = "test", + value_name = "URL", + conflicts_with = "server", + conflicts_with = "cleanup" + )] + test: Option, + /// Command and arguments to execute - #[arg(trailing_var_arg = true, required_unless_present_any = ["cleanup", "server"])] + #[arg(trailing_var_arg = true, required_unless_present_any = ["cleanup", "server", "test"])] command: Vec, } @@ -323,6 +334,23 @@ async fn main() -> Result<()> { RuleEngine::from_trait(js_engine, request_log) }; + // Handle test (dry-run) mode: evaluate the rule against a URL and exit + if let Some(test_url) = &args.test { + let eval = rule_engine.evaluate_with_context(Method::GET, test_url).await; + match eval.action { + Action::Allow => { + println!("ALLOW GET {}", test_url); + if let Some(ctx) = eval.context { println!("{}", ctx); } + std::process::exit(0); + } + Action::Deny => { + println!("DENY GET {}", test_url); + if let Some(ctx) = eval.context { println!("{}", ctx); } + std::process::exit(1); + } + } + } + // Parse bind configuration from env vars // Supports both "port" and "ip:port" formats fn parse_bind_config(env_var: &str) -> (Option, Option) { @@ -477,4 +505,4 @@ async fn main() -> Result<()> { } Ok(()) -} +} \ No newline at end of file diff --git a/tests/test_flag.rs b/tests/test_flag.rs new file mode 100644 index 00000000..6620bae6 --- /dev/null +++ b/tests/test_flag.rs @@ -0,0 +1,21 @@ +use assert_cmd::prelude::*; +use predicates::prelude::*; +use std::process::Command; + +#[test] +fn test_httpjail_test_flag_allow() { + let mut cmd = Command::cargo_bin("httpjail").unwrap(); + cmd.arg("--js").arg("true").arg("--test").arg("https://example.com"); + cmd.assert() + .success() + .stdout(predicate::str::contains("ALLOW GET https://example.com")); +} + +#[test] +fn test_httpjail_test_flag_deny() { + let mut cmd = Command::cargo_bin("httpjail").unwrap(); + cmd.arg("--js").arg("false").arg("--test").arg("https://example.com"); + cmd.assert() + .failure() + .stdout(predicate::str::contains("DENY GET https://example.com")); +} From bb36a41d1611597150d3c3f66efa29206e956dd5 Mon Sep 17 00:00:00 2001 From: "blink-so[bot]" <211532188+blink-so[bot]@users.noreply.github.com> Date: Thu, 11 Sep 2025 20:26:21 +0000 Subject: [PATCH 2/4] style: run rustfmt and remove unused imports for clippy\n\n- Remove unused imports ArgAction and spawn_blocking\n- Apply rustfmt formatting changes\n\nCo-authored-by: ammario <7416144+ammario@users.noreply.github.com> --- src/main.rs | 19 ++++++++++++------- tests/test_flag.rs | 10 ++++++++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/main.rs b/src/main.rs index 224453d2..822ed612 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,16 +1,15 @@ use anyhow::{Context, Result}; -use clap::{Parser, ArgAction}; +use clap::Parser; use httpjail::jail::{JailConfig, create_jail}; use httpjail::proxy::ProxyServer; -use httpjail::rules::{RuleEngine, Action}; use httpjail::rules::script::ScriptRuleEngine; use httpjail::rules::v8_js::V8JsRuleEngine; +use httpjail::rules::{Action, RuleEngine}; use hyper::Method; use std::fs::OpenOptions; use std::os::unix::process::ExitStatusExt; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; -use tokio::task::spawn_blocking; use tracing::{debug, info, warn}; #[derive(Parser, Debug)] @@ -336,16 +335,22 @@ async fn main() -> Result<()> { // Handle test (dry-run) mode: evaluate the rule against a URL and exit if let Some(test_url) = &args.test { - let eval = rule_engine.evaluate_with_context(Method::GET, test_url).await; + let eval = rule_engine + .evaluate_with_context(Method::GET, test_url) + .await; match eval.action { Action::Allow => { println!("ALLOW GET {}", test_url); - if let Some(ctx) = eval.context { println!("{}", ctx); } + if let Some(ctx) = eval.context { + println!("{}", ctx); + } std::process::exit(0); } Action::Deny => { println!("DENY GET {}", test_url); - if let Some(ctx) = eval.context { println!("{}", ctx); } + if let Some(ctx) = eval.context { + println!("{}", ctx); + } std::process::exit(1); } } @@ -505,4 +510,4 @@ async fn main() -> Result<()> { } Ok(()) -} \ No newline at end of file +} diff --git a/tests/test_flag.rs b/tests/test_flag.rs index 6620bae6..2766f99a 100644 --- a/tests/test_flag.rs +++ b/tests/test_flag.rs @@ -5,7 +5,10 @@ use std::process::Command; #[test] fn test_httpjail_test_flag_allow() { let mut cmd = Command::cargo_bin("httpjail").unwrap(); - cmd.arg("--js").arg("true").arg("--test").arg("https://example.com"); + cmd.arg("--js") + .arg("true") + .arg("--test") + .arg("https://example.com"); cmd.assert() .success() .stdout(predicate::str::contains("ALLOW GET https://example.com")); @@ -14,7 +17,10 @@ fn test_httpjail_test_flag_allow() { #[test] fn test_httpjail_test_flag_deny() { let mut cmd = Command::cargo_bin("httpjail").unwrap(); - cmd.arg("--js").arg("false").arg("--test").arg("https://example.com"); + cmd.arg("--js") + .arg("false") + .arg("--test") + .arg("https://example.com"); cmd.assert() .failure() .stdout(predicate::str::contains("DENY GET https://example.com")); From 9bf5f13c9c6a47e8301df124dc7859a6fc273558 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Thu, 11 Sep 2025 20:28:21 -0500 Subject: [PATCH 3/4] feat: support HTTP method in --test flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow --test to accept "[METHOD] URL" format where METHOD is optional. When two words are provided, the first is interpreted as the HTTP method. Defaults to GET when only URL is provided for backward compatibility. Supported methods: GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, PATCH, TRACE Examples: --test "https://example.com" # defaults to GET --test "POST https://api.example.com" # uses POST method --test "DELETE https://example.com/user" # uses DELETE method 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/main.rs | 45 +++++++++++++++++++++++++++++++++++++----- tests/test_flag.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 5 deletions(-) diff --git a/src/main.rs b/src/main.rs index 822ed612..b0444059 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,9 +81,17 @@ struct Args { server: bool, /// Evaluate rule against a URL and exit (dry-run) + /// Format: [METHOD] URL + /// If METHOD is provided, it must be separated from URL by a space. + /// Valid methods: GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, PATCH, TRACE + /// If METHOD is not provided, defaults to GET. + /// Examples: + /// --test "https://example.com" (defaults to GET) + /// --test "POST https://api.example.com" + /// --test "DELETE https://example.com/resource" #[arg( long = "test", - value_name = "URL", + value_name = "[METHOD] URL", conflicts_with = "server", conflicts_with = "cleanup" )] @@ -334,20 +342,47 @@ async fn main() -> Result<()> { }; // Handle test (dry-run) mode: evaluate the rule against a URL and exit - if let Some(test_url) = &args.test { + if let Some(test_arg) = &args.test { + // Parse the test argument: if it contains two words, the first is the method + let (method, url) = if let Some(space_pos) = test_arg.find(' ') { + let method_str = &test_arg[..space_pos]; + let url = &test_arg[space_pos + 1..].trim(); + + // Parse the method string + let method = match method_str.to_uppercase().as_str() { + "GET" => Method::GET, + "POST" => Method::POST, + "PUT" => Method::PUT, + "DELETE" => Method::DELETE, + "HEAD" => Method::HEAD, + "OPTIONS" => Method::OPTIONS, + "CONNECT" => Method::CONNECT, + "PATCH" => Method::PATCH, + "TRACE" => Method::TRACE, + _ => { + eprintln!("Invalid HTTP method: {}", method_str); + std::process::exit(1); + } + }; + (method, url.to_string()) + } else { + // Single word: default to GET + (Method::GET, test_arg.clone()) + }; + let eval = rule_engine - .evaluate_with_context(Method::GET, test_url) + .evaluate_with_context(method.clone(), &url) .await; match eval.action { Action::Allow => { - println!("ALLOW GET {}", test_url); + println!("ALLOW {} {}", method, url); if let Some(ctx) = eval.context { println!("{}", ctx); } std::process::exit(0); } Action::Deny => { - println!("DENY GET {}", test_url); + println!("DENY {} {}", method, url); if let Some(ctx) = eval.context { println!("{}", ctx); } diff --git a/tests/test_flag.rs b/tests/test_flag.rs index 2766f99a..65385093 100644 --- a/tests/test_flag.rs +++ b/tests/test_flag.rs @@ -25,3 +25,52 @@ fn test_httpjail_test_flag_deny() { .failure() .stdout(predicate::str::contains("DENY GET https://example.com")); } + +#[test] +fn test_httpjail_test_flag_with_post_method() { + let mut cmd = Command::cargo_bin("httpjail").unwrap(); + cmd.arg("--js") + .arg("r.method === 'POST'") + .arg("--test") + .arg("POST https://example.com/api"); + cmd.assert().success().stdout(predicate::str::contains( + "ALLOW POST https://example.com/api", + )); +} + +#[test] +fn test_httpjail_test_flag_with_delete_method() { + let mut cmd = Command::cargo_bin("httpjail").unwrap(); + cmd.arg("--js") + .arg("r.method === 'DELETE'") + .arg("--test") + .arg("DELETE https://example.com/resource"); + cmd.assert().success().stdout(predicate::str::contains( + "ALLOW DELETE https://example.com/resource", + )); +} + +#[test] +fn test_httpjail_test_flag_with_method_deny() { + let mut cmd = Command::cargo_bin("httpjail").unwrap(); + cmd.arg("--js") + .arg("r.method === 'GET'") + .arg("--test") + .arg("POST https://example.com"); + cmd.assert() + .failure() + .stdout(predicate::str::contains("DENY POST https://example.com")); +} + +#[test] +fn test_httpjail_test_flag_default_get() { + // When no method is specified, it should default to GET + let mut cmd = Command::cargo_bin("httpjail").unwrap(); + cmd.arg("--js") + .arg("r.method === 'GET'") + .arg("--test") + .arg("https://example.com"); + cmd.assert() + .success() + .stdout(predicate::str::contains("ALLOW GET https://example.com")); +} From 193b646a911cc81b98bcb68aa944e9b7f873e2c4 Mon Sep 17 00:00:00 2001 From: Ammar Bandukwala Date: Thu, 11 Sep 2025 20:29:23 -0500 Subject: [PATCH 4/4] simplify --test help --- src/main.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main.rs b/src/main.rs index b0444059..23619e21 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,14 +81,6 @@ struct Args { server: bool, /// Evaluate rule against a URL and exit (dry-run) - /// Format: [METHOD] URL - /// If METHOD is provided, it must be separated from URL by a space. - /// Valid methods: GET, POST, PUT, DELETE, HEAD, OPTIONS, CONNECT, PATCH, TRACE - /// If METHOD is not provided, defaults to GET. - /// Examples: - /// --test "https://example.com" (defaults to GET) - /// --test "POST https://api.example.com" - /// --test "DELETE https://example.com/resource" #[arg( long = "test", value_name = "[METHOD] URL",