1use std::net::IpAddr;
9
10use clap::Subcommand;
11
12use super::style::Palette;
13use super::{CliError, resolve_profile};
14use crate::config::Config;
15use crate::filter::explain::{
16 Subject, explain, explanation_json, policy_json, render_explanation, render_policy,
17};
18use crate::sqlite::order::Identifier;
19
20#[derive(Subcommand)]
21pub enum FilterCommand {
22 Show {
24 #[arg(long)]
26 profile: Option<String>,
27 #[arg(long)]
28 json: bool,
29 },
30 Explain {
36 #[arg(long)]
37 profile: Option<String>,
38 #[arg(long)]
40 client_ip: Option<IpAddr>,
41 #[arg(long = "identifier")]
43 identifiers: Vec<String>,
44 #[arg(long, default_value = "/newOrder")]
46 path: String,
47 #[arg(long, default_value = "explain")]
49 account_id: String,
50 #[arg(long)]
51 json: bool,
52 },
53}
54
55pub async fn run_filter_command(
56 command: FilterCommand,
57 palette: Palette,
58 config: &Config,
59) -> Result<(), CliError> {
60 match command {
61 FilterCommand::Show { profile, json } => {
62 let (name, policy) = build(config, profile.as_deref())?;
63 if json {
64 print_json(&policy_json(&name, &policy))?;
65 } else {
66 print!("{}", render_policy(&name, &policy, palette));
67 }
68 Ok(())
69 }
70 FilterCommand::Explain {
71 profile,
72 client_ip,
73 identifiers,
74 path,
75 account_id,
76 json,
77 } => {
78 let (name, policy) = build(config, profile.as_deref())?;
79 let subject = Subject {
80 client_ip,
81 account_id,
82 identifiers: identifiers.iter().map(Identifier::dns).collect(),
83 path,
84 eab: None,
85 };
86
87 let explanation = explain(&policy, &subject).await;
88 if json {
89 print_json(&explanation_json(&name, &subject, &explanation))?;
90 } else {
91 print!(
92 "{}",
93 render_explanation(&name, &subject, &explanation, palette)
94 );
95 }
96 Ok(())
97 }
98 }
99}
100
101fn print_json(value: &serde_json::Value) -> Result<(), CliError> {
110 println!(
111 "{}",
112 serde_json::to_string_pretty(value)
113 .map_err(|error| CliError(format!("cannot render JSON: {error}")))?
114 );
115 Ok(())
116}
117
118fn build(
130 config: &Config,
131 wanted: Option<&str>,
132) -> Result<(String, crate::filter::FilterPolicy), CliError> {
133 let profile = resolve_profile(config, wanted)?;
134 let sections = &profile.sections;
135
136 let resolver = crate::dns::HickoryResolver::from_system_uncached()
137 .map_err(|error| CliError(format!("cannot build a resolver: {error}")))?;
138 let proxies = crate::proxy::OutboundProxies::from_config(&config.proxy)
139 .map_err(|error| CliError(format!("configuration error: {error}")))?;
140
141 let inventory = crate::ipam::from_config(
142 §ions.ipam,
143 crate::http_client::Outbound::new(
144 std::sync::Arc::new(resolver),
145 std::sync::Arc::new(proxies),
146 ),
147 )
148 .map_err(|error| CliError(format!("profile `{}`: {error}", profile.name)))?;
149
150 let policy = crate::filter::build::build(
151 §ions.filter,
152 &config.dns,
153 inventory,
154 sections.eab.enabled,
155 )
156 .map_err(|error| CliError(format!("profile `{}`: {error}", profile.name)))?;
157
158 Ok((profile.name, policy))
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::config::ENV_LOCK;
165
166 fn load(body: &str) -> Config {
172 let _lock = ENV_LOCK
173 .lock()
174 .unwrap_or_else(std::sync::PoisonError::into_inner);
175 let dir = crate::testutil::TempDir::new("cli-filter");
176 std::fs::write(dir.join("config.toml"), body).unwrap();
177 unsafe {
179 std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
180 }
181 let config = Config::load().expect("the configuration must load");
182 unsafe {
183 std::env::remove_var("ACME_PROXY_CONFIG");
184 }
185 config
186 }
187
188 const ONE_PROFILE: &str = r#"
189 [profiles.default]
190 [profiles.default.filter]
191 rules = ["mgmt"]
192 rule.mgmt.when = "net"
193 rule.mgmt.then = "allow"
194 check.net.type = "allowed_ip"
195 check.net.allow = ["10.0.0.0/8"]
196 "#;
197
198 async fn run(config: &Config, command: FilterCommand) -> Result<(), CliError> {
199 run_filter_command(command, Palette::plain(), config).await
200 }
201
202 #[tokio::test]
203 async fn show_prints_the_policy_of_the_only_profile() {
204 let config = load(ONE_PROFILE);
205 assert!(
206 run(
207 &config,
208 FilterCommand::Show {
209 profile: None,
210 json: false
211 }
212 )
213 .await
214 .is_ok()
215 );
216 }
217
218 #[tokio::test]
219 async fn show_accepts_the_profile_by_name() {
220 let config = load(ONE_PROFILE);
221 assert!(
222 run(
223 &config,
224 FilterCommand::Show {
225 profile: Some("default".to_string()),
226 json: false
227 }
228 )
229 .await
230 .is_ok()
231 );
232 }
233
234 #[tokio::test]
235 async fn an_unknown_profile_is_refused_by_name() {
236 let config = load(ONE_PROFILE);
237 let error = run(
238 &config,
239 FilterCommand::Show {
240 profile: Some("nope".to_string()),
241 json: false,
242 },
243 )
244 .await
245 .unwrap_err();
246 assert!(error.0.contains("no profile named `nope`"), "{}", error.0);
247 }
248
249 #[tokio::test]
252 async fn several_profiles_require_naming_one() {
253 let config = load(
254 r#"
255 [profiles.a]
256 [profiles.b]
257 "#,
258 );
259 let error = run(
260 &config,
261 FilterCommand::Show {
262 profile: None,
263 json: false,
264 },
265 )
266 .await
267 .unwrap_err();
268 assert!(error.0.contains("--profile"), "{}", error.0);
269 }
270
271 #[tokio::test]
275 async fn a_broken_policy_is_reported_rather_than_printed() {
276 let config = load(
277 r#"
278 [profiles.default]
279 [profiles.default.filter]
280 rules = ["broken"]
281 rule.broken.when = "net and )"
282 rule.broken.then = "allow"
283 check.net.type = "allowed_ip"
284 check.net.allow = ["10.0.0.0/8"]
285 "#,
286 );
287 for json in [false, true] {
291 let error = run(
292 &config,
293 FilterCommand::Show {
294 profile: None,
295 json,
296 },
297 )
298 .await
299 .unwrap_err();
300 assert!(error.0.contains("at column"), "{}", error.0);
301 }
302 }
303
304 #[tokio::test]
306 async fn show_runs_in_both_output_shapes() {
307 let config = load(ONE_PROFILE);
308 for json in [false, true] {
309 assert!(
310 run(
311 &config,
312 FilterCommand::Show {
313 profile: None,
314 json
315 }
316 )
317 .await
318 .is_ok(),
319 "show --json={json} must succeed"
320 );
321 }
322 }
323
324 fn explain_of(profile: Option<String>, ip: &str, names: &[&str], json: bool) -> FilterCommand {
325 FilterCommand::Explain {
326 profile,
327 client_ip: Some(ip.parse().unwrap()),
328 identifiers: names.iter().map(std::string::ToString::to_string).collect(),
329 path: "/newOrder".to_string(),
330 account_id: "explain".to_string(),
331 json,
332 }
333 }
334
335 #[tokio::test]
336 async fn explain_runs_in_both_output_shapes() {
337 let config = load(ONE_PROFILE);
338 for json in [false, true] {
339 assert!(
340 run(
341 &config,
342 explain_of(None, "10.0.0.5", &["a.example.com"], json)
343 )
344 .await
345 .is_ok(),
346 "json = {json}"
347 );
348 }
349 }
350
351 #[tokio::test]
352 async fn explain_works_on_a_refused_address_too() {
353 let config = load(ONE_PROFILE);
354 assert!(
355 run(&config, explain_of(None, "203.0.113.9", &[], false))
356 .await
357 .is_ok()
358 );
359 }
360
361 #[tokio::test]
364 async fn explain_handles_a_policy_with_no_rules() {
365 let config = load("[profiles.default]\n");
366 assert!(
367 run(
368 &config,
369 explain_of(None, "10.0.0.5", &["a.example.com"], false)
370 )
371 .await
372 .is_ok()
373 );
374 }
375
376 #[tokio::test]
377 async fn explain_accepts_no_client_address_at_all() {
378 let config = load(ONE_PROFILE);
379 let command = FilterCommand::Explain {
380 profile: None,
381 client_ip: None,
382 identifiers: Vec::new(),
383 path: "/directory".to_string(),
384 account_id: "explain".to_string(),
385 json: false,
386 };
387 assert!(run(&config, command).await.is_ok());
388 }
389}