use appblocklist::{App, Client, Error};
use std::io::{self, BufRead};
use std::process::exit;
use std::time::Duration;
struct Opts {
block: Vec<String>,
max_age: Option<u32>,
json: bool,
ids: Vec<String>,
}
fn usage() -> ! {
eprintln!("usage: abl <lookup|check|mobileconfig> [--block \"A,B\"] [--max-age 12+] [--json] <id>... | -");
exit(2)
}
fn parse_opts(args: &[String]) -> Opts {
let mut o = Opts { block: vec!["Social Networking".into()], max_age: None, json: false, ids: vec![] };
let mut i = 0;
while i < args.len() {
match args[i].as_str() {
"--block" => {
i += 1;
let v = args.get(i).unwrap_or_else(|| usage());
o.block = v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
}
"--max-age" => {
i += 1;
o.max_age = Some(age(args.get(i).unwrap_or_else(|| usage())).unwrap_or_else(|| usage()));
}
"--json" => o.json = true,
"-" => o.ids.extend(io::stdin().lock().lines().filter_map(Result::ok)),
id => o.ids.push(id.to_string()),
}
i += 1;
}
o.ids.retain(|s| !s.trim().is_empty() && !s.trim_start().starts_with('#'));
if o.ids.is_empty() {
usage();
}
o
}
fn age(rating: &str) -> Option<u32> {
rating.trim().trim_end_matches('+').parse().ok()
}
fn violation(app: &App, o: &Opts) -> Option<String> {
if !app.found {
return None;
}
if app.in_category(o.block.as_slice()) {
return Some(format!("category {}", app.primary_category().unwrap_or("?")));
}
match (o.max_age, age(&app.age_rating)) {
(Some(max), Some(a)) if a > max => Some(format!("age rating {}", app.age_rating)),
_ => None,
}
}
fn run_all(client: &Client, ids: &[String]) -> Vec<(String, Result<App, Error>)> {
let (results, fatal) = client.lookup_many(ids, Duration::from_millis(50));
if let Some(e) = fatal {
eprintln!("abl: {}", e);
exit(if matches!(e, Error::QuotaExceeded { .. }) { 3 } else { 2 });
}
results
}
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let (cmd, rest) = match args.split_first() {
Some((c, r)) => (c.as_str(), r),
None => usage(),
};
if !matches!(cmd, "lookup" | "check" | "mobileconfig") {
usage();
}
let opts = parse_opts(rest);
let client = Client::from_env().unwrap_or_else(|e| {
eprintln!("abl: {}", e);
exit(2)
});
let results = run_all(&client, &opts.ids);
match cmd {
"lookup" => {
for (id, r) in &results {
match r {
Ok(a) if a.found => println!(
"{}\t{}\t{}\t{}",
id,
a.name,
a.age_rating,
a.filtering_categories.join("; ")
),
Ok(a) => println!("{}\tnot found\t{}", id, a.message.as_deref().unwrap_or("")),
Err(e) => println!("{}\terror\t{}", id, e),
}
}
}
"check" => {
let mut bad = 0;
let mut report = Vec::new();
for (id, r) in &results {
let (verdict, reason) = match r {
Ok(a) => match violation(a, &opts) {
Some(why) => {
bad += 1;
("block", why)
}
None if a.found => ("allow", String::new()),
None => ("unknown", a.message.clone().unwrap_or_default()),
},
Err(e) => ("error", e.to_string()),
};
if opts.json {
report.push(serde_json::json!({ "id": id, "verdict": verdict, "reason": reason, "app": r.as_ref().ok() }));
} else {
println!("{:<8}{} {}", verdict, id, reason);
}
}
if opts.json {
println!("{}", serde_json::to_string_pretty(&report).unwrap());
}
exit(if bad > 0 { 1 } else { 0 });
}
"mobileconfig" => {
println!("<key>blockedAppBundleIDs</key>");
println!("<array>");
for (_, r) in &results {
if let Ok(a) = r {
if a.found && !a.bundle_id.is_empty() && violation(a, &opts).is_some() {
println!(" <string>{}</string>", xml_escape(&a.bundle_id));
}
}
}
println!("</array>");
}
_ => usage(),
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&").replace('<', "<").replace('>', ">")
}