appblocklist 1.0.0

Client for the AppBlockList App Lookup API: categorize iOS and Android apps by bundle ID, App Store ID or package name for MDM app blocklists.
Documentation
//! `abl`: a small command line tool on top of the appblocklist crate.
//!
//! ```text
//! abl lookup <id>...                       print one line per app
//! abl check [--block CATS] [--max-age R] [--json] <id>... | -
//!                                          exit 1 if any app breaks policy
//! abl mobileconfig [--block CATS] <id>... | -
//!                                          print a blockedAppBundleIDs array
//! ```
//!
//! IDs can be iOS bundle IDs, App Store IDs or Android package names. Use `-`
//! to read one ID per line from stdin. Exit codes: 0 ok, 1 policy violation,
//! 2 usage or configuration error, 3 quota reached.
//!
//!     cargo run --example abl -- check --block "Social Networking" --max-age 12+ - < apps.txt

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
}

/// "12+" -> 12
fn age(rating: &str) -> Option<u32> {
    rating.trim().trim_end_matches('+').parse().ok()
}

/// Why an app breaks policy, if it does.
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('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}