diff --git a/Cargo.lock b/Cargo.lock index 8708636..8e2487f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -405,6 +405,12 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "nix" version = "0.29.0" @@ -417,6 +423,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "ntapi" version = "0.4.1" @@ -482,6 +498,17 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "parse_datetime" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8720474e3dd4af20cea8716703498b9f3b690f318fa9d9d9e2e38eaf44b96d0" +dependencies = [ + "chrono", + "nom", + "regex", +] + [[package]] name = "phf" version = "0.11.2" @@ -1012,6 +1039,7 @@ version = "0.0.1" dependencies = [ "chrono", "clap", + "parse_datetime", "regex", "serde", "serde_json", diff --git a/src/uu/dmesg/Cargo.toml b/src/uu/dmesg/Cargo.toml index c6a8870..e154767 100644 --- a/src/uu/dmesg/Cargo.toml +++ b/src/uu/dmesg/Cargo.toml @@ -17,6 +17,7 @@ regex = { workspace = true } serde_json = { workspace = true } serde = { workspace = true } chrono = "0.4.38" +parse_datetime = "0.6.0" [features] fixed-boot-time = [] diff --git a/src/uu/dmesg/src/dmesg.rs b/src/uu/dmesg/src/dmesg.rs index 75bfe8f..96c995b 100644 --- a/src/uu/dmesg/src/dmesg.rs +++ b/src/uu/dmesg/src/dmesg.rs @@ -3,11 +3,18 @@ // For the full copyright and license information, please view the LICENSE // file that was distributed with this source code. +use chrono::{DateTime, FixedOffset}; use clap::{crate_version, Arg, ArgAction, Command}; use regex::Regex; -use std::fs; +use std::{ + collections::HashSet, + fs::File, + hash::Hash, + io::{BufRead, BufReader}, + sync::OnceLock, +}; use uucore::{ - error::{FromIo, UResult, USimpleError}, + error::{FromIo, UError, UResult, USimpleError}, format_usage, help_about, help_usage, }; @@ -43,7 +50,69 @@ pub fn uumain(args: impl uucore::Args) -> UResult<()> { } }; } - dmesg.parse()?.print(); + if let Some(list_args) = matches.get_many::<String>(options::FACILITY) { + let mut facility_filters = HashSet::new(); + for list in list_args { + for arg in list.split(',') { + let facility = match arg { + "kern" => Facility::Kern, + "user" => Facility::User, + "mail" => Facility::Mail, + "daemon" => Facility::Daemon, + "auth" => Facility::Auth, + "syslog" => Facility::Syslog, + "lpr" => Facility::Lpr, + "news" => Facility::News, + "uucp" => Facility::Uucp, + "cron" => Facility::Cron, + "authpriv" => Facility::Authpriv, + "ftp" => Facility::Ftp, + "res0" => Facility::Res0, + "res1" => Facility::Res1, + "res2" => Facility::Res2, + "res3" => Facility::Res3, + "local0" => Facility::Local0, + "local1" => Facility::Local1, + "local2" => Facility::Local2, + "local3" => Facility::Local3, + "local4" => Facility::Local4, + "local5" => Facility::Local5, + "local6" => Facility::Local6, + "local7" => Facility::Local7, + _ => return Err(USimpleError::new(1, format!("unknown facility '{arg}'"))), + }; + facility_filters.insert(facility); + } + } + dmesg.facility_filters = Some(facility_filters); + } + if let Some(list_args) = matches.get_many::<String>(options::LEVEL) { + let mut level_filters = HashSet::new(); + for list in list_args { + for arg in list.split(',') { + let level = match arg { + "emerg" => Level::Emerg, + "alert" => Level::Alert, + "crit" => Level::Crit, + "err" => Level::Err, + "warn" => Level::Warn, + "notice" => Level::Notice, + "info" => Level::Info, + "debug" => Level::Debug, + _ => return Err(USimpleError::new(1, format!("unknown level '{arg}'"))), + }; + level_filters.insert(level); + } + } + dmesg.level_filters = Some(level_filters); + } + if let Some(since) = matches.get_one::<String>(options::SINCE) { + dmesg.since_filter = Some(time_formatter::parse_datetime(since)?); + } + if let Some(until) = matches.get_one::<String>(options::UNTIL) { + dmesg.until_filter = Some(time_formatter::parse_datetime(until)?); + } + dmesg.print()?; Ok(()) } @@ -75,19 +144,52 @@ pub fn uu_app() -> Command { ) .action(ArgAction::Set), ) + .arg( + Arg::new(options::FACILITY) + .short('f') + .long("facility") + .help("restrict output to defined facilities") + .action(ArgAction::Append), + ) + .arg( + Arg::new(options::LEVEL) + .short('l') + .long("level") + .help("restrict output to defined levels") + .action(ArgAction::Append), + ) + .arg( + Arg::new(options::SINCE) + .long("since") + .help("display the lines since the specified time") + .action(ArgAction::Set), + ) + .arg( + Arg::new(options::UNTIL) + .long("until") + .help("display the lines until the specified time") + .action(ArgAction::Set), + ) } mod options { pub const KMSG_FILE: &str = "kmsg-file"; pub const JSON: &str = "json"; pub const TIME_FORMAT: &str = "time-format"; + pub const FACILITY: &str = "facility"; + pub const LEVEL: &str = "level"; + pub const SINCE: &str = "since"; + pub const UNTIL: &str = "until"; } struct Dmesg<'a> { kmsg_file: &'a str, output_format: OutputFormat, time_format: TimeFormat, - records: Option<Vec<Record>>, + facility_filters: Option<HashSet<Facility>>, + level_filters: Option<HashSet<Level>>, + since_filter: Option<chrono::DateTime<chrono::FixedOffset>>, + until_filter: Option<chrono::DateTime<chrono::FixedOffset>>, } impl Dmesg<'_> { @@ -96,86 +198,105 @@ impl Dmesg<'_> { kmsg_file: "/dev/kmsg", output_format: OutputFormat::Normal, time_format: TimeFormat::Raw, - records: None, + facility_filters: None, + level_filters: None, + since_filter: None, + until_filter: None, } } - fn parse(mut self) -> UResult<Self> { - let mut records = vec![]; - let re = Self::record_regex(); - let lines = self.read_lines_from_kmsg_file()?; - for line in lines { - for (_, [pri_fac, seq, time, msg]) in re.captures_iter(&line).map(|c| c.extract()) { - records.push(Record::from_str_fields( - pri_fac, - seq, - time, - msg.to_string(), - )?); - } - } - self.records = Some(records); - Ok(self) - } - - fn record_regex() -> Regex { - let valid_number_pattern = "0|[1-9][0-9]*"; - let additional_fields_pattern = ",^[,;]*"; - let record_pattern = format!( - "(?m)^({0}),({0}),({0}),.(?:{1})*;(.*)$", - valid_number_pattern, additional_fields_pattern - ); - Regex::new(&record_pattern).expect("invalid regex.") - } - - fn read_lines_from_kmsg_file(&self) -> UResult<Vec<String>> { - let kmsg_bytes = fs::read(self.kmsg_file) - .map_err_context(|| format!("cannot open {}", self.kmsg_file))?; - let lines = kmsg_bytes - .split(|&byte| byte == 0) - .map(|line| String::from_utf8_lossy(line).to_string()) - .collect(); - Ok(lines) - } - - fn print(&self) { + fn print(&self) -> UResult<()> { match self.output_format { OutputFormat::Json => self.print_json(), OutputFormat::Normal => self.print_normal(), } } - fn print_json(&self) { - if let Some(records) = &self.records { - println!("{}", json::serialize_records(records)); + fn print_json(&self) -> UResult<()> { + let records: UResult<Vec<Record>> = self.try_filtered_iter()?.collect(); + println!("{}", json::serialize_records(&records?)); + Ok(()) + } + + fn print_normal(&self) -> UResult<()> { + let mut reltime_formatter = time_formatter::ReltimeFormatter::new(); + let mut delta_formatter = time_formatter::DeltaFormatter::new(); + for record in self.try_filtered_iter()? { + let record = record?; + match self.time_format { + TimeFormat::Delta => { + print!("[{}] ", delta_formatter.format(record.timestamp_us)) + } + TimeFormat::Reltime => { + print!("[{}] ", reltime_formatter.format(record.timestamp_us)) + } + TimeFormat::Ctime => { + print!("[{}] ", time_formatter::ctime(record.timestamp_us)) + } + TimeFormat::Iso => { + print!("{} ", time_formatter::iso(record.timestamp_us)) + } + TimeFormat::Raw => { + print!("[{}] ", time_formatter::raw(record.timestamp_us)) + } + TimeFormat::Notime => (), + } + println!("{}", record.message); + } + Ok(()) + } + + fn try_filtered_iter(&self) -> UResult<impl Iterator<Item = UResult<Record>> + '_> { + Ok(self + .try_iter()? + .filter(Self::is_record_in_set(&self.facility_filters)) + .filter(Self::is_record_in_set(&self.level_filters)) + .filter(Self::is_record_since(&self.since_filter)) + .filter(Self::is_record_until(&self.until_filter))) + } + + fn try_iter(&self) -> UResult<RecordIterator> { + let file = File::open(self.kmsg_file) + .map_err_context(|| format!("cannot open {}", self.kmsg_file))?; + let file_reader = BufReader::new(file); + Ok(RecordIterator { file_reader }) + } + + fn is_record_in_set<T>( + set: &Option<HashSet<T>>, + ) -> impl Fn(&Result<Record, Box<dyn UError>>) -> bool + '_ + where + T: From<u32> + Eq + Hash, + { + move |record: &UResult<Record>| match (record, set) { + (Ok(record), Some(set)) => set.contains(&T::from(record.priority_facility)), + _ => true, } } - fn print_normal(&self) { - if let Some(records) = &self.records { - let mut reltime_formatter = time_formatter::ReltimeFormatter::new(); - let mut delta_formatter = time_formatter::DeltaFormatter::new(); - for record in records { - match self.time_format { - TimeFormat::Delta => { - print!("[{}] ", delta_formatter.format(record.timestamp_us)) - } - TimeFormat::Reltime => { - print!("[{}] ", reltime_formatter.format(record.timestamp_us)) - } - TimeFormat::Ctime => { - print!("[{}] ", time_formatter::ctime(record.timestamp_us)) - } - TimeFormat::Iso => { - print!("{} ", time_formatter::iso(record.timestamp_us)) - } - TimeFormat::Raw => { - print!("[{}] ", time_formatter::raw(record.timestamp_us)) - } - TimeFormat::Notime => (), - } - println!("{}", record.message); + fn is_record_since( + since: &Option<DateTime<FixedOffset>>, + ) -> impl Fn(&UResult<Record>) -> bool + '_ { + move |record: &UResult<Record>| match (record, since) { + (Ok(record), Some(since)) => { + let time = + time_formatter::datetime_from_microseconds_since_boot(record.timestamp_us); + time >= *since } + _ => true, + } + } + + fn is_record_until( + until: &Option<DateTime<FixedOffset>>, + ) -> impl Fn(&UResult<Record>) -> bool + '_ { + move |record: &UResult<Record>| match (record, until) { + (Ok(record), Some(until)) => { + let time = + time_formatter::datetime_from_microseconds_since_boot(record.timestamp_us); + time <= *until + } + _ => true, } } } @@ -194,6 +315,102 @@ enum TimeFormat { Raw, } +#[derive(Eq, Hash, PartialEq)] +enum Facility { + Kern, + User, + Mail, + Daemon, + Auth, + Syslog, + Lpr, + News, + Uucp, + Cron, + Authpriv, + Ftp, + Res0, + Res1, + Res2, + Res3, + Local0, + Local1, + Local2, + Local3, + Local4, + Local5, + Local6, + Local7, + Unknown, +} + +#[derive(Eq, Hash, PartialEq)] +enum Level { + Emerg, + Alert, + Crit, + Err, + Warn, + Notice, + Info, + Debug, + Unknown, +} + +struct RecordIterator { + file_reader: BufReader<File>, +} + +impl Iterator for RecordIterator { + type Item = UResult<Record>; + + fn next(&mut self) -> Option<Self::Item> { + match self.read_record_line() { + Err(e) => Some(Err(e)), + Ok(None) => None, + Ok(Some(line)) => match self.parse_record(&line) { + None => self.next(), + Some(record) => Some(Ok(record)), + }, + } + } +} + +impl RecordIterator { + fn read_record_line(&mut self) -> UResult<Option<String>> { + let mut buf = vec![]; + let num_bytes = self.file_reader.read_until(0, &mut buf)?; + match num_bytes { + 0 => Ok(None), + _ => Ok(Some(String::from_utf8_lossy(&buf).to_string())), + } + } + + fn parse_record(&self, record_line: &str) -> Option<Record> { + record_regex() + .captures_iter(record_line) + .map(|c| c.extract()) + .filter_map(|(_, [pri_fac, seq, time, msg])| { + Record::from_str_fields(pri_fac, seq, time, msg.to_string()).ok() + }) + .next() + } +} + +fn record_regex() -> &'static Regex { + RECORD_REGEX.get_or_init(|| { + let valid_number_pattern = "0|[1-9][0-9]*"; + let additional_fields_pattern = ",^[,;]*"; + let record_pattern = format!( + "(?m)^({0}),({0}),({0}),.(?:{1})*;(.*)$", + valid_number_pattern, additional_fields_pattern + ); + Regex::new(&record_pattern).expect("invalid regex.") + }) +} + +static RECORD_REGEX: OnceLock<Regex> = OnceLock::new(); + struct Record { priority_facility: u32, _sequence: u64, @@ -217,3 +434,53 @@ impl Record { } } } + +impl From<u32> for Level { + fn from(value: u32) -> Self { + let priority = value & 0b111; + match priority { + 0 => Level::Emerg, + 1 => Level::Alert, + 2 => Level::Crit, + 3 => Level::Err, + 4 => Level::Warn, + 5 => Level::Notice, + 6 => Level::Info, + 7 => Level::Debug, + _ => Level::Unknown, + } + } +} + +impl From<u32> for Facility { + fn from(value: u32) -> Self { + let facility = (value >> 3) as u8; + match facility { + 0 => Facility::Kern, + 1 => Facility::User, + 2 => Facility::Mail, + 3 => Facility::Daemon, + 4 => Facility::Auth, + 5 => Facility::Syslog, + 6 => Facility::Lpr, + 7 => Facility::News, + 8 => Facility::Uucp, + 9 => Facility::Cron, + 10 => Facility::Authpriv, + 11 => Facility::Ftp, + 12 => Facility::Res0, + 13 => Facility::Res1, + 14 => Facility::Res2, + 15 => Facility::Res3, + 16 => Facility::Local0, + 17 => Facility::Local1, + 18 => Facility::Local2, + 19 => Facility::Local3, + 20 => Facility::Local4, + 21 => Facility::Local5, + 22 => Facility::Local6, + 23 => Facility::Local7, + _ => Facility::Unknown, + } + } +} diff --git a/src/uu/dmesg/src/time_formatter.rs b/src/uu/dmesg/src/time_formatter.rs index 198431f..f26bb4c 100644 --- a/src/uu/dmesg/src/time_formatter.rs +++ b/src/uu/dmesg/src/time_formatter.rs @@ -7,6 +7,7 @@ use chrono::{DateTime, FixedOffset, TimeDelta}; #[cfg(feature = "fixed-boot-time")] use chrono::{NaiveDate, NaiveTime}; use std::sync::OnceLock; +use uucore::error::{UResult, USimpleError}; pub fn raw(timestamp_us: i64) -> String { let seconds = timestamp_us / 1000000; @@ -116,6 +117,17 @@ impl DeltaFormatter { } } +pub fn parse_datetime(s: &str) -> UResult<DateTime<FixedOffset>> { + parse_datetime::parse_datetime(s) + .map_err(|_| USimpleError::new(1, format!("invalid time value \"{s}\""))) +} + +pub fn datetime_from_microseconds_since_boot(microseconds: i64) -> DateTime<FixedOffset> { + boot_time() + .checked_add_signed(TimeDelta::microseconds(microseconds)) + .unwrap() +} + static BOOT_TIME: OnceLock<DateTime<FixedOffset>> = OnceLock::new(); #[cfg(feature = "fixed-boot-time")] diff --git a/tests/by-util/test_dmesg.rs b/tests/by-util/test_dmesg.rs index b811584..2debb8a 100644 --- a/tests/by-util/test_dmesg.rs +++ b/tests/by-util/test_dmesg.rs @@ -81,3 +81,111 @@ fn test_invalid_time_format() { .code_is(1) .stderr_only("dmesg: unknown time format: definitely-invalid\n"); } + +#[test] +fn test_filter_facility() { + let facilities = [ + "kern", "user", "mail", "daemon", "auth", "syslog", "lpr", "news", "uucp", "cron", + "authpriv", "ftp", "local0", "local1", "local2", "local3", "local4", "local5", "local6", + "local7", + ]; + for facility in facilities { + let facility_filter_arg = format!("--facility={facility}"); + let mut cmd = new_ucmd!(); + let result = cmd + .arg("--kmsg-file") + .arg("kmsg.input") + .arg(facility_filter_arg) + .succeeds(); + let stdout = result.no_stderr().stdout_str(); + assert_eq!(stdout.lines().count(), 8); + let expected = format!("LOG_{}", facility.to_uppercase()); + stdout + .lines() + .for_each(|line| assert!(line.contains(&expected))); + } +} + +#[test] +fn test_filter_levels() { + let levels = [ + "emerg", "alert", "crit", "err", "warn", "notice", "info", "debug", + ]; + for level in levels { + let level_filter_arg = format!("--level={level}"); + let mut cmd = new_ucmd!(); + let result = cmd + .arg("--kmsg-file") + .arg("kmsg.input") + .arg(level_filter_arg) + .succeeds(); + let stdout = result.no_stderr().stdout_str(); + assert_eq!(stdout.lines().count(), 20); + let expected = format!("LOG_{}", level.to_uppercase()); + stdout + .lines() + .for_each(|line| assert!(line.contains(&expected))); + } +} + +#[test] +fn test_invalid_facility_argument() { + new_ucmd!() + .arg("--facility=definitely-invalid") + .fails() + .code_is(1) + .stderr_only("dmesg: unknown facility 'definitely-invalid'\n"); +} + +#[test] +fn test_invalid_level_argument() { + new_ucmd!() + .arg("--level=definitely-invalid") + .fails() + .code_is(1) + .stderr_only("dmesg: unknown level 'definitely-invalid'\n"); +} + +#[test] +fn test_filter_multiple() { + let mut cmd = new_ucmd!(); + let result = cmd + .arg("--kmsg-file") + .arg("kmsg.input") + .arg("--facility=kern,user") + .arg("--level=emerg,alert") + .succeeds(); + let stdout = result.no_stderr().stdout_str(); + assert_eq!(stdout.lines().count(), 4); + stdout.lines().for_each(|line| { + assert!( + (line.contains("LOG_KERN") || line.contains("LOG_USER")) + && (line.contains("LOG_EMERG") || line.contains("LOG_ALERT")) + ) + }); +} + +#[test] +fn test_since_until() { + new_ucmd!() + .arg("--kmsg-file") + .arg("kmsg.input") + .arg("--since=2024-11-19 17:47:32 +0700") + .arg("--until=2024-11-19 18:55:52 +0700") + .succeeds() + .no_stderr() + .stdout_is_templated_fixture("test_since_until.expected", &[("\r\n", "\n")]); +} + +#[test] +fn test_since_until_invalid_time() { + let options = ["--since", "--until"]; + for option in options { + new_ucmd!() + .arg(format!("{option}=definitely-invalid")) + .fails() + .stderr_only(format!( + "dmesg: invalid time value \"definitely-invalid\"\n" + )); + } +} diff --git a/tests/fixtures/dmesg/test_since_until.expected b/tests/fixtures/dmesg/test_since_until.expected new file mode 100644 index 0000000..2b76c31 --- /dev/null +++ b/tests/fixtures/dmesg/test_since_until.expected @@ -0,0 +1,5 @@ +[80000.000000] LOG_WARNING LOG_AUTH +[81000.000000] LOG_WARNING LOG_AUTHPRIV +[82000.000000] LOG_WARNING LOG_CRON +[83000.000000] LOG_WARNING LOG_DAEMON +[84000.000000] LOG_WARNING LOG_FTP