use std::net::IpAddr;
use clap::Subcommand;
use super::style::Palette;
use super::{CliError, resolve_profile};
use crate::config::Config;
use crate::filter::explain::{
Subject, explain, explanation_json, policy_json, render_explanation, render_policy,
};
use crate::sqlite::order::Identifier;
#[derive(Subcommand)]
pub enum FilterCommand {
Show {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
json: bool,
},
Explain {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
client_ip: Option<IpAddr>,
#[arg(long = "identifier")]
identifiers: Vec<String>,
#[arg(long, default_value = "/newOrder")]
path: String,
#[arg(long, default_value = "explain")]
account_id: String,
#[arg(long)]
json: bool,
},
}
pub async fn run_filter_command(
command: FilterCommand,
palette: Palette,
config: &Config,
) -> Result<(), CliError> {
match command {
FilterCommand::Show { profile, json } => {
let (name, policy) = build(config, profile.as_deref())?;
if json {
print_json(&policy_json(&name, &policy))?;
} else {
print!("{}", render_policy(&name, &policy, palette));
}
Ok(())
}
FilterCommand::Explain {
profile,
client_ip,
identifiers,
path,
account_id,
json,
} => {
let (name, policy) = build(config, profile.as_deref())?;
let subject = Subject {
client_ip,
account_id,
identifiers: identifiers.iter().map(Identifier::dns).collect(),
path,
eab: None,
};
let explanation = explain(&policy, &subject).await;
if json {
print_json(&explanation_json(&name, &subject, &explanation))?;
} else {
print!(
"{}",
render_explanation(&name, &subject, &explanation, palette)
);
}
Ok(())
}
}
}
fn print_json(value: &serde_json::Value) -> Result<(), CliError> {
println!(
"{}",
serde_json::to_string_pretty(value)
.map_err(|error| CliError::failed(format!("cannot render JSON: {error}")))?
);
Ok(())
}
fn build(
config: &Config,
wanted: Option<&str>,
) -> Result<(String, crate::filter::FilterPolicy), CliError> {
let profile = resolve_profile(config, wanted)?;
let sections = &profile.sections;
let resolver = crate::dns::HickoryResolver::from_system_uncached()
.map_err(|error| CliError::failed(format!("cannot build a resolver: {error}")))?;
let proxies = crate::proxy::OutboundProxies::from_config(&config.proxy)
.map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
let inventory = crate::ipam::from_config(
§ions.ipam,
crate::http_client::Outbound::new(
std::sync::Arc::new(resolver),
std::sync::Arc::new(proxies),
),
)
.map_err(|error| CliError::failed(format!("profile `{}`: {error}", profile.name)))?;
let policy = crate::filter::build::build(
§ions.filter,
&config.dns,
inventory,
sections.eab.enabled,
)
.map_err(|error| CliError::failed(format!("profile `{}`: {error}", profile.name)))?;
Ok((profile.name, policy))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::ENV_LOCK;
fn load(body: &str) -> Config {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = crate::testutil::TempDir::new("cli-filter");
std::fs::write(dir.join("config.toml"), body).unwrap();
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
}
let config = Config::load().expect("the configuration must load");
unsafe {
std::env::remove_var("ACME_PROXY_CONFIG");
}
config
}
const ONE_PROFILE: &str = r#"
[profiles.default]
[profiles.default.filter]
rules = ["mgmt"]
rule.mgmt.when = "net"
rule.mgmt.then = "allow"
check.net.type = "allowed_ip"
check.net.allow = ["10.0.0.0/8"]
"#;
async fn run(config: &Config, command: FilterCommand) -> Result<(), CliError> {
run_filter_command(command, Palette::plain(), config).await
}
#[tokio::test]
async fn show_prints_the_policy_of_the_only_profile() {
let config = load(ONE_PROFILE);
assert!(
run(
&config,
FilterCommand::Show {
profile: None,
json: false
}
)
.await
.is_ok()
);
}
#[tokio::test]
async fn show_accepts_the_profile_by_name() {
let config = load(ONE_PROFILE);
assert!(
run(
&config,
FilterCommand::Show {
profile: Some("default".to_string()),
json: false
}
)
.await
.is_ok()
);
}
#[tokio::test]
async fn an_unknown_profile_is_refused_by_name() {
let config = load(ONE_PROFILE);
let error = run(
&config,
FilterCommand::Show {
profile: Some("nope".to_string()),
json: false,
},
)
.await
.unwrap_err();
assert!(
error.message.contains("no profile named `nope`"),
"{}",
error.message
);
}
#[tokio::test]
async fn several_profiles_require_naming_one() {
let config = load(
r#"
[profiles.a]
[profiles.b]
"#,
);
let error = run(
&config,
FilterCommand::Show {
profile: None,
json: false,
},
)
.await
.unwrap_err();
assert!(error.message.contains("--profile"), "{}", error.message);
}
#[tokio::test]
async fn a_broken_policy_is_reported_rather_than_printed() {
let config = load(
r#"
[profiles.default]
[profiles.default.filter]
rules = ["broken"]
rule.broken.when = "net and )"
rule.broken.then = "allow"
check.net.type = "allowed_ip"
check.net.allow = ["10.0.0.0/8"]
"#,
);
for json in [false, true] {
let error = run(
&config,
FilterCommand::Show {
profile: None,
json,
},
)
.await
.unwrap_err();
assert!(error.message.contains("at column"), "{}", error.message);
}
}
#[tokio::test]
async fn show_runs_in_both_output_shapes() {
let config = load(ONE_PROFILE);
for json in [false, true] {
assert!(
run(
&config,
FilterCommand::Show {
profile: None,
json
}
)
.await
.is_ok(),
"show --json={json} must succeed"
);
}
}
fn explain_of(profile: Option<String>, ip: &str, names: &[&str], json: bool) -> FilterCommand {
FilterCommand::Explain {
profile,
client_ip: Some(ip.parse().unwrap()),
identifiers: names.iter().map(std::string::ToString::to_string).collect(),
path: "/newOrder".to_string(),
account_id: "explain".to_string(),
json,
}
}
#[tokio::test]
async fn explain_runs_in_both_output_shapes() {
let config = load(ONE_PROFILE);
for json in [false, true] {
assert!(
run(
&config,
explain_of(None, "10.0.0.5", &["a.example.com"], json)
)
.await
.is_ok(),
"json = {json}"
);
}
}
#[tokio::test]
async fn explain_works_on_a_refused_address_too() {
let config = load(ONE_PROFILE);
assert!(
run(&config, explain_of(None, "203.0.113.9", &[], false))
.await
.is_ok()
);
}
#[tokio::test]
async fn explain_handles_a_policy_with_no_rules() {
let config = load("[profiles.default]\n");
assert!(
run(
&config,
explain_of(None, "10.0.0.5", &["a.example.com"], false)
)
.await
.is_ok()
);
}
#[tokio::test]
async fn explain_accepts_no_client_address_at_all() {
let config = load(ONE_PROFILE);
let command = FilterCommand::Explain {
profile: None,
client_ip: None,
identifiers: Vec::new(),
path: "/directory".to_string(),
account_id: "explain".to_string(),
json: false,
};
assert!(run(&config, command).await.is_ok());
}
}