telnetz 0.4.0

telnet like commandline tool for connecting to TCP and TLS sockets
use std::error::Error;
use std::io::ErrorKind;
use std::time::{Duration, Instant};

use base64::{Engine as _, engine::general_purpose::STANDARD};
use serde_json::{Value, json};

pub struct Report {
    pub phase: &'static str,
    pub reason: &'static str,
    pub exit_code: u8,
    pub error: Option<String>,
    pub bytes_sent: u64,
    pub bytes_received: u64,
    pub matched: bool,
    pub peer_addr: Option<String>,
    pub server_name: Option<String>,
    pub tls: Option<Value>,
    pub output: Vec<u8>,
    started: Instant,
}

impl Report {
    pub fn new() -> Self {
        Self {
            phase: "usage",
            reason: "invalid_arguments",
            exit_code: 2,
            error: None,
            bytes_sent: 0,
            bytes_received: 0,
            matched: false,
            peer_addr: None,
            server_name: None,
            tls: None,
            output: Vec::new(),
            started: Instant::now(),
        }
    }

    pub fn finish(&mut self, exit_code: u8, reason: &'static str) -> u8 {
        self.exit_code = exit_code;
        self.reason = reason;
        exit_code
    }

    pub fn fail(&mut self, error: &(dyn Error + 'static)) {
        self.error = Some(error.to_string());
        let reason = self.classify(error);
        self.finish(1, reason);
    }

    pub fn output_budget(&self, limit: Duration) -> Duration {
        limit
            .saturating_sub(self.started.elapsed())
            .max(Duration::from_millis(100))
    }

    fn classify(&self, error: &(dyn Error + 'static)) -> &'static str {
        if let Some(rustls::Error::InvalidCertificate(cert)) = error.downcast_ref::<rustls::Error>()
        {
            use rustls::CertificateError::*;
            return match cert {
                UnknownIssuer => "unknown_issuer",
                Expired | ExpiredContext { .. } => "certificate_expired",
                NotValidYet | NotValidYetContext { .. } => "certificate_not_yet_valid",
                NotValidForName | NotValidForNameContext { .. } => "hostname_mismatch",
                _ => "invalid_certificate",
            };
        }
        if let Some(io) = error.downcast_ref::<std::io::Error>() {
            if let Some(inner) = io.get_ref()
                && inner.is::<rustls::Error>()
            {
                return self.classify(inner);
            }
            match io.kind() {
                ErrorKind::ConnectionRefused => return "connection_refused",
                ErrorKind::ConnectionReset => return "connection_reset",
                ErrorKind::BrokenPipe => return "broken_pipe",
                ErrorKind::TimedOut => return "connect_timeout",
                _ => {}
            }
        }
        match self.phase {
            "dns" => "dns_error",
            "connect" => "connect_error",
            "tls" => "tls_error",
            "certificate" => "invalid_certificate_file",
            "output" => "output_error",
            "write" => "write_error",
            _ => "read_error",
        }
    }

    pub fn json(&self) -> Value {
        json!({
            "schema_version": 1,
            "ok": self.exit_code == 0,
            "exit_code": self.exit_code,
            "phase": self.phase,
            "reason": self.reason,
            "error": self.error,
            "elapsed_ms": self.started.elapsed().as_millis(),
            "bytes_sent": self.bytes_sent,
            "bytes_received": self.bytes_received,
            "matched": self.matched,
            "peer_addr": self.peer_addr,
            "server_name": self.server_name,
            "tls": self.tls,
            "output_base64": STANDARD.encode(&self.output),
            "output_utf8": std::str::from_utf8(&self.output).ok(),
        })
    }
}