Skip to main content

pq_map/
cli.rs

1use crate::collect::{self, Options};
2use crate::config::Config;
3use crate::render;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6enum Mode {
7    Combined,
8    Summary,
9    Hosts,
10    Certs,
11    Graph,
12    Json,
13}
14
15const USAGE: &str = "\
16Usage:
17  pq-map DOMAIN [--refresh] [--no-probe] [--summary|--hosts|--certs|--graph|--json]
18
19Modes:
20  (default)   combined text view: names, hosts, services, DNS records
21  --summary   certificate estate summary
22  --hosts     concrete hostnames of currently valid certificates
23  --certs     CT certificate records table
24  --graph     host/crypto edges as JSON for 3d-force-graph
25  --json      full state as JSON
26
27Options:
28  --refresh   force a fresh CT snapshot (bypasses the 30-day cache)
29  --no-probe  skip live TLS probing
30  --ports     discover services on alternative ports for hosts whose 443 is
31              closed: family-driven port list (mx -> 465/587/25, mail -> 993/995/
32              465/587, ...) or explicit comma LIST (--ports=80,8443)
33  -h, --help  this help
34";
35
36pub fn normalize_domain(raw: &str) -> Option<String> {
37    let raw = raw.trim();
38    let raw = raw.strip_prefix("*.").unwrap_or(raw);
39    let candidate = if raw.contains("://") {
40        raw.to_string()
41    } else {
42        format!("https://{raw}")
43    };
44    let url = url::Url::parse(&candidate).ok()?;
45    let host = url.host_str()?.trim_end_matches('.').to_ascii_lowercase();
46    let host = match host.strip_prefix("www.") {
47        Some(rest) if rest.contains('.') => rest.to_string(),
48        _ => host,
49    };
50    (!host.is_empty()).then_some(host)
51}
52
53pub fn run() -> i32 {
54    let mut domain: Option<String> = None;
55    let mut mode = Mode::Combined;
56    let mut refresh = false;
57    let mut probe = true;
58    let mut ports: Option<Vec<u16>> = None;
59
60    for arg in std::env::args().skip(1) {
61        match arg.as_str() {
62            "--refresh" => refresh = true,
63            "--no-probe" => probe = false,
64            "--ports" => ports = Some(Vec::new()),
65            other if other.starts_with("--ports=") => {
66                let list = other
67                    .trim_start_matches("--ports=")
68                    .split(',')
69                    .filter_map(|p| p.trim().parse().ok())
70                    .collect::<Vec<u16>>();
71                if list.is_empty() {
72                    eprintln!("ERROR: --ports=LIST requires a comma-separated port list");
73                    return 2;
74                }
75                ports = Some(list);
76            }
77            "--summary" => mode = Mode::Summary,
78            "--hosts" => mode = Mode::Hosts,
79            "--certs" => mode = Mode::Certs,
80            "--graph" => mode = Mode::Graph,
81            "--json" => mode = Mode::Json,
82            "-h" | "--help" => {
83                print!("{USAGE}");
84                return 0;
85            }
86            other if other.starts_with("--") => {
87                eprintln!("ERROR: unknown option: {other}");
88                eprint!("{USAGE}");
89                return 2;
90            }
91            other => {
92                if domain.is_none() {
93                    domain = Some(other.to_string());
94                } else {
95                    eprintln!("ERROR: unexpected argument: {other}");
96                    eprint!("{USAGE}");
97                    return 2;
98                }
99            }
100        }
101    }
102
103    let Some(raw) = domain else {
104        eprint!("{USAGE}");
105        return 2;
106    };
107    let Some(domain) = normalize_domain(&raw) else {
108        eprintln!("ERROR: cannot derive a domain from: {raw}");
109        return 2;
110    };
111
112    let cfg = Config::from_env();
113    let runtime = match tokio::runtime::Builder::new_multi_thread()
114        .enable_all()
115        .build()
116    {
117        Ok(rt) => rt,
118        Err(e) => {
119            eprintln!("ERROR: cannot start async runtime: {e}");
120            return 1;
121        }
122    };
123
124    match runtime.block_on(collect::collect(
125        &domain,
126        &cfg,
127        &Options {
128            refresh,
129            probe,
130            ports,
131        },
132    )) {
133        Ok(state) => {
134            match mode {
135                Mode::Combined => print!("{}", render::text::combined(&state)),
136                Mode::Summary => print!("{}", render::text::summary(&state)),
137                Mode::Hosts => print!("{}", render::text::hosts(&state)),
138                Mode::Certs => print!("{}", render::text::certs(&state)),
139                Mode::Graph => println!("{}", render::graph::graph(&state)),
140                Mode::Json => match serde_json::to_string_pretty(&state) {
141                    Ok(json) => println!("{json}"),
142                    Err(e) => {
143                        eprintln!("ERROR: JSON serialization failed: {e}");
144                        return 1;
145                    }
146                },
147            }
148            0
149        }
150        Err(e) => {
151            eprintln!("ERROR: {e}");
152            1
153        }
154    }
155}
156
157#[cfg(test)]
158mod tests {
159    use super::normalize_domain;
160
161    #[test]
162    fn strips_scheme_path_and_www() {
163        assert_eq!(
164            normalize_domain("https://www.example.com/").as_deref(),
165            Some("example.com")
166        );
167    }
168
169    #[test]
170    fn accepts_bare_hosts_and_ports() {
171        assert_eq!(
172            normalize_domain("example.com").as_deref(),
173            Some("example.com")
174        );
175        assert_eq!(
176            normalize_domain("WWW.Example.COM.:8443").as_deref(),
177            Some("example.com")
178        );
179    }
180
181    #[test]
182    fn strips_userinfo_query_and_wildcard() {
183        assert_eq!(
184            normalize_domain("https://user:pw@example.com:8443/a?b=1#c").as_deref(),
185            Some("example.com")
186        );
187        assert_eq!(
188            normalize_domain("*.sub.example.com").as_deref(),
189            Some("sub.example.com")
190        );
191    }
192
193    #[test]
194    fn keeps_single_label_www_hosts() {
195        assert_eq!(normalize_domain("www.com").as_deref(), Some("www.com"));
196    }
197
198    #[test]
199    fn rejects_empty_input() {
200        assert_eq!(normalize_domain("   "), None);
201    }
202}