Merge pull request from fuad1502/dmesg-filter

Support `dmesg` filtering options (`--facility`, `--level`, `--since`, and `--until` options).
This commit is contained in:
Daniel Hofstetter 2024-12-15 14:43:43 +01:00 committed by GitHub
commit 7c7809ec99
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 492 additions and 71 deletions

28
Cargo.lock generated

@ -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",

@ -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 = []

@ -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,
}
}
}

@ -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")]

@ -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"
));
}
}

@ -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