spftrace 0.3.0

Utility for tracing SPF queries
// spftrace – utility for tracing SPF queries
// Copyright © 2022–2023 David Bürgin <dbuergin@gluet.ch>
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU General Public License as published by the Free Software
// Foundation, either version 3 of the License, or (at your option) any later
// version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
// FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
// details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/>.

use crate::{config::Config, PROGRAM_NAME};
use std::{
    env,
    error::Error,
    io::{stdout, Write},
    net::{IpAddr, Ipv4Addr},
    process,
    time::Duration,
};
use viaspf::Sender;

const USAGE_TEXT: &str = "\
[options...] <sender> [<ip>]

Options:
  -H, --helo-domain <name>          The client's HELO domain name
  -h, --help                        Print usage information
  -R, --hostname <name>             The receiving host's domain name
  -w, --line-width <number>         Maximum width of wrapped lines
  -n, --max-lookups <number>        Maximum number of DNS lookups
  -o, --max-void-lookups <number>   Maximum number of void lookups
  -s, --system-resolver             Use the resolver configured on the system
  -t, --time                        Display query and lookup times
  -T, --timeout-secs <number>       Seconds until a query times out
  -V, --version                     Print version information
";

pub struct Args {
    pub config: Config,
    pub sender: Sender,
    pub ip: IpAddr,
}

impl Args {
    pub fn parse() -> Result<Self, Box<dyn Error>> {
        let mut args = env::args_os()
            .skip(1)
            .map(|s| s.into_string().map_err(|_| "invalid UTF-8 bytes in argument"));

        let mut config = Config::default();

        let sender = loop {
            let arg = args.next().ok_or("required argument <sender> missing")??;

            let missing_value = || format!("missing value for option {arg}");
            let invalid_value = |s: &str| format!("invalid value for option {arg}: \"{s}\"");

            match arg.as_str() {
                "-h" | "--help" => {
                    write!(stdout(), "Usage: {PROGRAM_NAME} {USAGE_TEXT}")?;
                    process::exit(0);
                }
                "-V" | "--version" => {
                    writeln!(stdout(), "{PROGRAM_NAME} {}", env!("CARGO_PKG_VERSION"))?;
                    process::exit(0);
                }
                "--debug" => {
                    // This option is not documented for now.
                    config.debug = true;
                }
                "-s" | "--system-resolver" => {
                    config.system_resolver = true;
                }
                "-t" | "--time" => {
                    config.time = true;
                }
                "-H" | "--helo-domain" => {
                    let arg = args.next().ok_or_else(missing_value)??;
                    let helo_domain = arg.parse().map_err(|_| invalid_value(&arg))?;

                    config.helo_domain = Some(helo_domain);
                }
                "-R" | "--hostname" => {
                    let hostname = args.next().ok_or_else(missing_value)??;

                    config.hostname = Some(hostname);
                }
                "-w" | "--line-width" => {
                    let arg = args.next().ok_or_else(missing_value)??;
                    let line_width = arg.parse().map_err(|_| invalid_value(&arg))?;

                    config.line_width = to_usize(line_width)?;
                }
                "-n" | "--max-lookups" => {
                    let arg = args.next().ok_or_else(missing_value)??;
                    let max_lookups = arg.parse().map_err(|_| invalid_value(&arg))?;

                    config.max_lookups = Some(to_usize(max_lookups)?);
                }
                "-o" | "--max-void-lookups" => {
                    let arg = args.next().ok_or_else(missing_value)??;
                    let max_void_lookups = arg.parse().map_err(|_| invalid_value(&arg))?;

                    config.max_void_lookups = Some(to_usize(max_void_lookups)?);
                }
                "-T" | "--timeout-secs" => {
                    let arg = args.next().ok_or_else(missing_value)??;
                    let timeout_secs = arg.parse::<u32>().map_err(|_| invalid_value(&arg))?;

                    config.timeout = Duration::from_secs(timeout_secs.into());
                }
                arg => {
                    // Show a more helpful error message when the argument looks
                    // like an option. (An option name can never be successfully
                    // parsed as a sender.)
                    if arg.starts_with('-')
                        && arg.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
                    {
                        return Err(format!("unrecognized option: \"{arg}\"").into());
                    }

                    break Sender::new(arg)
                        .map_err(|_| format!("invalid sender identity: \"{arg}\""))?;
                }
            }
        };

        let ip = match args.next() {
            Some(arg) => {
                let arg = arg?;
                arg.parse()
                    .map_err(|_| format!("invalid IP address: \"{arg}\""))?
            }
            None => IpAddr::V4(Ipv4Addr::UNSPECIFIED),
        };

        if args.next().is_some() {
            return Err("too many arguments".into());
        }

        Ok(Self { config, sender, ip })
    }
}

// Note: Above, user inputs are parsed as fixed-size integers (`u32`) instead of
// platform-dependent variable-size integers (eg `usize`).
fn to_usize(n: u32) -> Result<usize, &'static str> {
    n.try_into().map_err(|_| "unsupported pointer size")
}