diff --git a/src/output/ansi.rs b/src/output/ansi.rs new file mode 100644 index 0000000..23f6025 --- /dev/null +++ b/src/output/ansi.rs @@ -0,0 +1,6 @@ +//! Common ANSI codes + +pub static BOLD: &str = "\x1B[1m"; +pub static UNDERLINE: &str = "\x1B[4m"; +pub static ITALIC: &str = "\x1B[3m"; +pub static RESET: &str = "\x1B[m"; diff --git a/src/output/dig.rs b/src/output/dig.rs index f87d5f8..ddd3c0e 100644 --- a/src/output/dig.rs +++ b/src/output/dig.rs @@ -9,7 +9,8 @@ use crate::client::Answer; //------------ write --------------------------------------------------------- pub fn write( - answer: &Answer, target: &mut impl io::Write + answer: &Answer, + target: &mut impl io::Write, ) -> Result<(), io::Error> { let msg = answer.msg_slice(); diff --git a/src/output/human.rs b/src/output/human.rs new file mode 100644 index 0000000..48d0cf1 --- /dev/null +++ b/src/output/human.rs @@ -0,0 +1,283 @@ +//! An output format designed to be read by humans. + +use domain::base::iana::Rtype; +use domain::base::opt::{AllOptData, OptRecord}; +use domain::base::wire::ParseError; +use domain::base::{Header, HeaderCounts, Message, ParsedName, QuestionSection, Record}; +use domain::rdata::AllRecordData; +use std::io; + +use super::ansi::{BOLD, RESET}; +use super::ttl; +use crate::client::Answer; + +use super::table_writer::TableWriter; + +type Rec<'a> = Record, AllRecordData<&'a [u8], ParsedName<&'a [u8]>>>; + +enum FormatError { + Io(io::Error), + BadRecord(ParseError), +} + +impl From for FormatError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for FormatError { + fn from(value: ParseError) -> Self { + Self::BadRecord(value) + } +} + +pub fn write(answer: &Answer, target: &mut impl io::Write) -> io::Result<()> { + match write_internal(answer, target) { + Ok(()) => Ok(()), + Err(FormatError::Io(e)) => Err(e), + Err(FormatError::BadRecord(e)) => { + writeln!(target, "ERROR: bad record: {e}")?; + Ok(()) + } + } +} + +fn write_internal(answer: &Answer, target: &mut impl io::Write) -> Result<(), FormatError> { + let msg = answer.msg_slice(); + + let header = msg.header(); + let counts = msg.header_counts(); + + write_header(target, header, counts)?; + + // We need opt further down + let opt = msg.opt(); + + if let Some(opt) = &opt { + write_opt(target, opt)?; + } + + let questions = msg.question(); + if counts.qdcount() > 0 { + write_question(target, &questions)?; + } + + let mut section = questions.answer()?.limit_to::>(); + if counts.ancount() > 0 { + write_answers(target, &mut section)?; + } + + // Authority + let mut section = section + .next_section()? + .unwrap() + .limit_to::>(); + if counts.nscount() > 0 { + writeln!(target, "\n{BOLD}AUTHORITY SECTION{RESET}")?; + write_answer_table(target, &mut section)?; + } + + // Additional + let section = section + .next_section()? + .unwrap() + .limit_to::>(); + if counts.arcount() > 1 || (opt.is_none() && counts.arcount() > 0) { + writeln!(target, "\n{BOLD}ADDITIONAL SECTION{RESET}")?; + write_answer_table( + target, + section.filter(|item| item.as_ref().map_or(true, |i| i.rtype() != Rtype::OPT)), + )?; + } + + write_stats(target, msg, answer)?; + + Ok(()) +} + +fn write_header( + target: &mut impl io::Write, + header: Header, + counts: HeaderCounts, +) -> Result<(), FormatError> { + writeln!(target, "{BOLD}HEADER{RESET}")?; + let header_rows = [ + ["opcode:".into(), header.opcode().to_string()], + ["rcode:".into(), header.rcode().to_string()], + ["id:".into(), header.id().to_string()], + ["flags:".into(), header.flags().to_string()], + [ + "records:".into(), + format!( + "QUESTION: {}, ANSWER: {}, AUTHORITY: {}, ADDITIONAL: {}", + counts.qdcount(), + counts.ancount(), + counts.nscount(), + counts.arcount() + ), + ], + ]; + + TableWriter { + indent: " ", + rows: &header_rows, + ..Default::default() + } + .write(target)?; + + Ok(()) +} + +fn write_opt(target: &mut impl io::Write, opt: &OptRecord<&[u8]>) -> Result<(), FormatError> { + writeln!(target, "\n{BOLD}OPT PSEUDOSECTION{RESET}")?; + + let mut rows = Vec::new(); + + rows.push([ + "EDNS".to_string(), + format!( + "version: {}; flags: {}; udp: {}", + opt.version(), + opt.dnssec_ok(), + opt.udp_payload_size() + ), + ]); + + for option in opt.opt().iter::>() { + use AllOptData::*; + + let (name, value) = match option { + Ok(opt) => match opt { + Nsid(nsid) => ("NSID", nsid.to_string()), + Dau(dau) => ("DAU", dau.to_string()), + Dhu(dhu) => ("DHU", dhu.to_string()), + N3u(n3u) => ("N3U", n3u.to_string()), + Expire(expire) => ("EXPIRE", expire.to_string()), + TcpKeepalive(opt) => ("TCPKEEPALIVE", opt.to_string()), + Padding(padding) => ("PADDING", padding.to_string()), + ClientSubnet(opt) => ("CLIENTSUBNET", opt.to_string()), + Cookie(cookie) => ("COOKIE: {}", cookie.to_string()), + Chain(chain) => ("CHAIN", chain.to_string()), + KeyTag(keytag) => ("KEYTAG", keytag.to_string()), + ExtendedError(extendederror) => ("EDE", extendederror.to_string()), + Other(other) => ("OTHER", other.code().to_string()), + _ => ("ERROR", "Unknown OPT".to_string()), + }, + Err(err) => ("ERROR", format!("bad option: {}.", err)), + }; + + rows.push([name.to_string(), value]); + } + + TableWriter { + indent: " ", + rows: &rows, + ..Default::default() + } + .write(target)?; + + Ok(()) +} + +fn write_question( + target: &mut impl io::Write, + questions: &QuestionSection<&[u8]>, +) -> Result<(), FormatError> { + writeln!(target, "\n{BOLD}QUESTION SECTION{RESET}")?; + + let questions = questions + .map(|q| { + let q = q?; + Ok([ + q.qname().to_string(), + q.qtype().to_string(), + q.qclass().to_string(), + ]) + }) + .collect::, FormatError>>()?; + + TableWriter { + indent: " ", + spacing: " ", + header: Some(["Name", "Type", "Class"]), + rows: &questions, + enabled_columns: [true, true, false], + ..Default::default() + } + .write(target)?; + Ok(()) +} + +fn write_answers<'a>( + target: &mut impl io::Write, + answers: impl Iterator, ParseError>>, +) -> Result<(), FormatError> { + writeln!(target, "\n{BOLD}ANSWER SECTION{RESET}")?; + write_answer_table(target, answers) +} + +fn write_answer_table<'a>( + target: &mut impl io::Write, + answers: impl Iterator, ParseError>>, +) -> Result<(), FormatError> { + let answers = answers + .map(|a| { + let a = a?; + Ok([ + a.owner().to_string(), + ttl::format(a.ttl()), + a.class().to_string(), + a.rtype().to_string(), + a.data().to_string(), + ]) + }) + .collect::, FormatError>>()?; + + TableWriter { + indent: " ", + spacing: " ", + header: Some(["Owner", "TTL", "Class", "Type", "Data"]), + rows: &answers, + enabled_columns: [true, true, false, true, true], + right_aligned: [false, true, false, false, false], + } + .write(target)?; + Ok(()) +} + +fn write_stats( + target: &mut impl io::Write, + msg: Message<&[u8]>, + answer: &Answer, +) -> Result<(), FormatError> { + writeln!(target, "\n{BOLD}EXTRA INFO{RESET}")?; + let stats = answer.stats(); + let stats = [ + [ + "When:".into(), + stats.start.format("%a %b %d %H:%M:%S %Z %Y").to_string(), + ], + [ + "Query time:".into(), + format!("{} msec", stats.duration.num_milliseconds()), + ], + [ + "Server:".into(), + format!("{}#{}", stats.server_addr.ip(), stats.server_addr.port()), + ], + ["Protocol:".into(), stats.server_proto.to_string()], + [ + "Response size:".into(), + format!("{} bytes", msg.as_slice().len()), + ], + ]; + + TableWriter { + indent: " ", + rows: &stats, + ..Default::default() + } + .write(target)?; + Ok(()) +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 0008da9..64de99e 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -1,33 +1,44 @@ //! Message output formats. +mod ansi; mod dig; +mod human; +mod table; +mod table_writer; +mod ttl; - -use std::io; -use clap::ValueEnum; use super::client::Answer; +use clap::{Parser, ValueEnum}; +use std::io; //------------ OutputFormat -------------------------------------------------- #[derive(Clone, Copy, Debug, ValueEnum)] pub enum OutputFormat { /// Similar to dig. - Dig + Dig, + /// Easily readable, formatted with ANSI codes and whitespace + Human, + /// Short readable format + Table, +} + +#[derive(Clone, Debug, Parser)] +pub struct OutputOptions { + #[arg(long = "format", default_value = "dig")] + pub format: OutputFormat, } impl OutputFormat { - pub fn write( - self, msg: &Answer, target: &mut impl io::Write - ) -> Result<(), io::Error> { + pub fn write(self, msg: &Answer, target: &mut impl io::Write) -> Result<(), io::Error> { match self { - Self::Dig => self::dig::write(msg, target) + Self::Dig => self::dig::write(msg, target), + Self::Human => self::human::write(msg, target), + Self::Table => self::table::write(msg, target), } } - pub fn print( - self, msg: &Answer, - ) -> Result<(), io::Error> { + pub fn print(self, msg: &Answer) -> Result<(), io::Error> { self.write(msg, &mut io::stdout().lock()) } } - diff --git a/src/output/table.rs b/src/output/table.rs new file mode 100644 index 0000000..aaf77a7 --- /dev/null +++ b/src/output/table.rs @@ -0,0 +1,93 @@ +use std::io; + +use domain::{ + base::{wire::ParseError, Rtype}, + rdata::AllRecordData, +}; + +use super::ttl; +use crate::{client::Answer, output::table_writer::TableWriter}; + +enum FormatError { + Io(io::Error), + BadRecord(ParseError), +} + +impl From for FormatError { + fn from(value: io::Error) -> Self { + Self::Io(value) + } +} + +impl From for FormatError { + fn from(value: ParseError) -> Self { + Self::BadRecord(value) + } +} + +pub fn write(answer: &Answer, target: &mut impl io::Write) -> io::Result<()> { + match write_internal(answer, target) { + Ok(()) => Ok(()), + Err(FormatError::Io(e)) => Err(e), + Err(FormatError::BadRecord(e)) => { + writeln!(target, "ERROR: bad record: {e}")?; + Ok(()) + } + } +} + +fn write_internal(answer: &Answer, target: &mut impl io::Write) -> Result<(), FormatError> { + let msg = answer.msg_slice(); + + let mut table_rows = Vec::new(); + + const SECTION_NAMES: [&str; 3] = ["ANSWER", "AUTHORITY", "ADDITIONAL"]; + let mut section = msg.question().answer()?; + + for name in SECTION_NAMES { + let mut iter = section + .limit_to::>() + .filter(|i| i.as_ref().map_or(true, |i| i.rtype() != Rtype::OPT)); + + if let Some(row) = iter.next() { + let row = row?; + table_rows.push([ + name.into(), + row.owner().to_string(), + ttl::format(row.ttl()), + row.class().to_string(), + row.rtype().to_string(), + row.data().to_string(), + ]); + } + + for row in &mut iter { + let row = row?; + table_rows.push([ + String::new(), + row.owner().to_string(), + ttl::format(row.ttl()), + row.class().to_string(), + row.rtype().to_string(), + row.data().to_string(), + ]); + } + + let Some(section2) = section.next_section()? else { + break; + }; + section = section2; + } + + TableWriter { + spacing: " ", + header: Some(["Section", "Owner", "TTL", "Class", "Type", "Data"]), + rows: &table_rows, + enabled_columns: [true, true, true, false, true, true], + right_aligned: [false, false, true, false, false, false], + ..Default::default() + } + .write(target)?; + + Ok(()) +} diff --git a/src/output/table_writer.rs b/src/output/table_writer.rs new file mode 100644 index 0000000..4a195ab --- /dev/null +++ b/src/output/table_writer.rs @@ -0,0 +1,89 @@ +use std::io; + +use super::ansi::{ITALIC, RESET, UNDERLINE}; + +pub struct TableWriter<'a, const N: usize> { + pub indent: &'a str, + pub spacing: &'a str, + pub header: Option<[&'a str; N]>, + pub rows: &'a [[String; N]], + pub enabled_columns: [bool; N], + pub right_aligned: [bool; N], +} + +impl Default for TableWriter<'_, N> { + fn default() -> Self { + Self { + indent: "", + spacing: " ", + header: None, + rows: &[], + enabled_columns: [true; N], + right_aligned: [false; N], + } + } +} + +impl TableWriter<'_, N> { + pub fn write(&self, mut target: impl io::Write) -> io::Result<()> { + let Self { + indent, + spacing, + header, + rows, + enabled_columns, + right_aligned, + } = self; + + let mut widths = [0; N]; + + if let Some(header) = header { + for i in 0..N { + widths[i] = header[i].len(); + } + } + + for row in *rows { + for i in 0..N { + widths[i] = widths[i].max(row[i].len()); + } + } + + let columns: Vec<_> = (0..N).filter(|i| enabled_columns[*i]).collect(); + + if columns.is_empty() { + return Ok(()); + } + + if let Some(header) = self.header { + write!(target, "{indent}{UNDERLINE}{ITALIC}")?; + for &i in &columns[..columns.len() - 1] { + write!(target, "{:width$}", row[i], width = widths[i])?; + } else { + write!(target, "{:width$}", row[last], width = widths[last])?; + } else { + write!(target, "{: (u32, u32, u32, u32) { + const DAY: u32 = Ttl::DAY.as_secs(); + const HOUR: u32 = Ttl::HOUR.as_secs(); + const MINUTE: u32 = Ttl::MINUTE.as_secs(); + + let ttl = ttl.as_secs(); + let (days, ttl) = (ttl / DAY, ttl % DAY); + let (hours, ttl) = (ttl / HOUR, ttl % HOUR); + let (minutes, seconds) = (ttl / MINUTE, ttl % MINUTE); + (days, hours, minutes, seconds) +} + +pub fn format(ttl: Ttl) -> String { + let (days, hours, minutes, seconds) = chunk(ttl); + + let mut s = String::new(); + + for (n, unit) in [(days, "d"), (hours, "h"), (minutes, "m"), (seconds, "s")] { + if !s.is_empty() { + write!(s, " {n:>2}{unit}").unwrap(); + } else if n > 0 { + write!(s, "{n}{unit}").unwrap(); + } + } + + s +}