diff --git a/Cargo.toml b/Cargo.toml index 933b039..dc5eac2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -21,3 +21,4 @@ clap = { version = "4.0", features = ["cargo", "derive"] } serde = { version = "1.0" } serde_json = { version = "1.0" } tabled = { version = "0.20" } +stdlib = { path = "./stdlib" } diff --git a/README.md b/README.md index 63feb6d..8094451 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This began as, and continues to be, a learning exercise to better understand the | util | status | | ---- | ------ | | arch | :white_check_mark: | -| b2sum | :white_large_square: | +| b2sum | :white_check_mark: | | base32 | :white_large_square: | | base64 | :white_large_square: | | basename | :white_large_square: | diff --git a/arch/Cargo.toml b/arch/Cargo.toml index a25432e..15fa592 100644 --- a/arch/Cargo.toml +++ b/arch/Cargo.toml @@ -10,5 +10,5 @@ platform-info = "1.0.1" clap = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -stdlib = { path = "../stdlib" } +stdlib = { workspace = true } tabled = { workspace = true } diff --git a/arch/src/main.rs b/arch/src/main.rs index fb84627..30fc7a4 100644 --- a/arch/src/main.rs +++ b/arch/src/main.rs @@ -2,14 +2,17 @@ use platform_info::*; use serde_json::json; use tabled::{builder::Builder, settings::Style}; -use stdlib::clap_base_command; +use stdlib::{clap_args, clap_base_command}; + +clap_args!(Args {}); fn main() { let matches = clap_base_command().get_matches(); + let args = Args::from_matches(&matches); let arch = run(); - if let Some(output) = matches.get_one::("output") { + if let Some(output) = args.output { match output.as_str() { "table" => { let mut builder = Builder::new(); diff --git a/b2sum/Cargo.toml b/b2sum/Cargo.toml index 31172d3..18dc4f3 100644 --- a/b2sum/Cargo.toml +++ b/b2sum/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "b2sum" version = "0.1.0" -edition = "2021" +edition = "2024" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -9,3 +9,7 @@ edition = "2021" blake2 = "0.10.4" clap = { workspace = true } shellexpand = "2.1.2" +stdlib = { workspace = true } +tabled = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/b2sum/src/main.rs b/b2sum/src/main.rs index 684216c..fbe29ae 100644 --- a/b2sum/src/main.rs +++ b/b2sum/src/main.rs @@ -1,64 +1,30 @@ +use std::collections::HashMap; use std::fs::File; use std::io; -use std::io::prelude::*; use std::io::ErrorKind; use std::io::Write; +use std::io::prelude::*; use std::process; use blake2::{Blake2b512, Digest}; -use clap::Parser; -// use exitcode; - -/// Print or check BLAKE2 (512-bit) checksums. -#[derive(Parser, Debug)] -#[command(author, version, about, long_about = None)] -struct Args { - // // There's no difference between --binary and --text on GNU systems, so I'm not - // // sure how to implement and test this. - // /// read in binary mode - // #[arg(short, long)] - // binary: bool, - /// read BLAKE2 sums from the FILEs and check them - #[arg(short, long)] - check: bool, - - files: Vec, - - /// don't fail or report status for missing files - #[arg(long)] - ignore_missing: bool, - - /// digest length in bits; must not exceed the maximum for the blake2 algorithm and must be a multiple of 8 - #[arg(short, long, default_value_t = 128)] - length: i32, - - /// don't print OK for each successfully verified file - #[arg(long)] - quiet: bool, - - /// don't output anything, status code shows success - #[arg(long)] - status: bool, - - /// exit non-zero for improperly formatted checksum lines - #[arg(long)] - strict: bool, - - /// create a BSD-style checksum - #[arg(long)] - tag: bool, - - // /// read in text mode (default) - // #[arg(short, long, default_value_t = true)] - // text: bool, - /// warn about improperly formatted files - #[arg(short, long)] - warn: bool, - - /// end each output line with NUL, not newline, and disable file name escaping - #[arg(short, long)] - zero: bool, -} +use clap::{Arg, ArgAction, arg}; +// use serde_json::{Map, Value, json}; +use tabled::{builder::Builder, settings::Style}; + +use stdlib::{clap_args, clap_base_command}; + +clap_args!(Args { + flag check: bool, + flag ignore_missing: bool, + value(128) length: i32, + flag quiet: bool, + flag status: bool, + flag strict: bool, + flag tag: bool, + flag warn: bool, + flag zero: bool, + multi files: Vec, +}); struct B2Hash { filename: String, @@ -66,53 +32,86 @@ struct B2Hash { } fn main() { + let matches = clap_base_command() + .arg(arg!(-c --check "read BLAKE2 sums from the FILEs and check them")) + .arg(Arg::new("files").action(ArgAction::Append)) + .arg(Arg::new("ignore_missing").long("ignore-missing").action(ArgAction::SetTrue).help("ignore missing files")) + .arg(arg!(-l --length "output LENGTH characters of each checksum")) + .arg(arg!(--quiet "quiet mode, don't print OK for each successfully verified file")) + .arg(arg!(--status "output status only, don't print OK for each successfully verified file")) + .arg(arg!(--strict "strict mode, fail if any file fails to verify")) + .arg(arg!(--tag "output a BSD-style checksum")) + .arg(arg!(-w --warn "warn about improperly formatted files")) + .arg(arg!(-z --zero "end each output line with NUL, not newline, and disable file name escaping")) + .get_matches(); + let mut retcode = 0; - let args = Args::parse(); + let args = Args::from_matches(&matches); if args.check { retcode = check(&args); } else { let checksums = run(&args); + // Collapse the checksums down into a single HashMap + let mut map = HashMap::new(); + for checksum in checksums { if args.length == 0 { - output_hash(&args, checksum.hash, checksum.filename); + map.insert(checksum.filename, checksum.hash); } else if args.length % 8 == 0 { // length must be a multiple of 8 + if checksum.hash.is_empty() { + continue; + } let slice = &checksum.hash[..args.length as usize]; - output_hash(&args, slice.to_string(), checksum.filename); + map.insert(checksum.filename, slice.to_string()); } else { - output( - &args, + map.insert( + checksum.filename, format!("length ({}) is not a multiple of 8", args.length), ); } } - } - process::exit(retcode); -} -/// Print the output of a successful hash -fn output_hash(args: &Args, hash: String, filename: String) { - if args.tag { - output( - args, - format!("BLAKE2b-{} ({}) = {}", args.length, filename, hash), - ); - } else { - output(args, format!("{} {}", hash, filename)); - } -} + // Output the hashes + if let Some(output) = &args.output { + match output.as_str() { + "table" => { + let mut builder = Builder::new(); + builder.push_column(["Hash"]); + builder.push_column(["File"]); -/// Output the line with either a newline or NUL -fn output(args: &Args, line: String) { - if args.zero { - print!("{}\0", line); - io::stdout().flush().unwrap(); - } else { - println!("{}", line); + for file in map { + builder.push_record([file.1, file.0]); + } + let mut table = builder.build(); + println!("{}", table.with(Style::rounded())); + } + "json" => { + println!("{}", serde_json::to_string(&map).unwrap()); + } + "yaml" => { + println!("files:"); + for file in map { + println!(" - file: \"{}\"\n hash: \"{}\"", file.0, file.1); + } + } + _ => { + for file in map { + if args.zero { + print!("{} {}\0", file.1, file.0); + io::stdout().flush().unwrap(); + } else { + println!("{} {}", file.1, file.0); + } + } + } + } + } } + process::exit(retcode); } /// Perform the checksum validation @@ -129,6 +128,8 @@ fn check(args: &Args) -> i32 { let mut retval = 0; let mut failed = 0; + let mut map = HashMap::new(); + for filename in &args.files { let file = match File::open(filename) { Err(why) => panic!("couldn't open: {}", why), @@ -166,7 +167,8 @@ fn check(args: &Args) -> i32 { buf.clear(); continue; } - panic!("Invalid file format."); + println!("Invalid file format."); + return retval; } let hash2 = match b2sum_file(fname.to_string()) { @@ -176,24 +178,17 @@ fn check(args: &Args) -> i32 { buf.clear(); continue; } else { - output(args, format!("b2sum: {}: {}", fname, why)); + map.insert(fname.to_string(), why.to_string()); } "".to_string() } Ok(hash) => hash, }; - // TODO: return this information to the caller, so main() can - // process it and handle returning the right error code. if hash == hash2 { - if !args.quiet && !args.status { - output(args, format!("{}: OK", fname)); - } + map.insert(fname.to_string(), "OK".to_string()); } else { - if !args.quiet && !args.status { - output(args, format!("{}: FAILED", fname)); - } - + map.insert(fname.to_string(), "FAILED".to_string()); failed += 1; } @@ -201,14 +196,47 @@ fn check(args: &Args) -> i32 { buf.clear(); } } + + if !args.quiet + && !args.status + && let Some(output) = &args.output + { + match output.as_str() { + "table" => { + let mut builder = Builder::new(); + builder.push_column(["Status"]); + builder.push_column(["File"]); + + for file in map { + builder.push_record([file.1, file.0]); + } + let mut table = builder.build(); + println!("{}", table.with(Style::rounded())); + } + "json" => { + println!("{}", serde_json::to_string(&map).unwrap()); + } + "yaml" => { + println!("files:"); + for file in map { + println!(" - file: \"{}\"\n status: \"{}\"", file.0, file.1); + } + } + _ => { + for file in map { + if args.zero { + print!("{} {}\0", file.1, file.0); + io::stdout().flush().unwrap(); + } else { + println!("{} {}", file.1, file.0); + } + } + } + } + } + if failed > 0 { retval = 1; - if !args.status { - output( - args, - format!("b2sum: WARNING: {} computed checksum did NOT match", failed), - ); - } } retval } @@ -225,7 +253,8 @@ fn run(args: &Args) -> Vec { // skip this file continue; } else { - output(args, format!("b2sum: {}: {}", filename, why)); + // TODO: figure out a better way to surface this via the output format? + println!("b2sum: {}: {}", filename, why); } "".to_string() } diff --git a/data/test.check b/data/test.check new file mode 100644 index 0000000..53a5282 --- /dev/null +++ b/data/test.check @@ -0,0 +1,3 @@ +91beee108359196458cf821584c1259100e6b01e0c5b1099db8a733ff01c238cbe2d038d3a088058123bc012fbce9feb395241ddab5b907b192e005d2048f2ac data/test.data +9c4fb6a525a103b341d73e707090e6bedcbc611b064b7327941b84ea80ab3ab80fde7a55b4d0255e615756326695d3020460d78518f4c2f4192739a464bf5336 data/test1.data +2475acb0eb9b5c8fc6d51f4134aafea2c20f46abcd970e222a5952c7061bd6bee0b74d9e10fde8dc87cd6839bea49569ece307d786c7d16c5cc91ddec632a313 data/test2.data diff --git a/data/test.check-bad b/data/test.check-bad new file mode 100644 index 0000000..1aedb6c --- /dev/null +++ b/data/test.check-bad @@ -0,0 +1,3 @@ +a1beee108359196458cf821584c1259100e6b01e0c5b1099db8a733ff01c238cbe2d038d3a088058123bc012fbce9feb395241ddab5b907b192e005d2048f2ac data/test.data +9c4fb6a525a103b341d73e707090e6bedcbc611b064b7327941b84ea80ab3ab80fde7a55b4d0255e615756326695d3020460d78518f4c2f4192739a464bf5335 data/test1.data +2475acb0eb9b5c8fc6d51f4134aafea2c20f46abcd970e222a5952c7061bd6bee0b74d9e10fde8dc87cd6839bea49569ece307d786c7d16c5cc91ddec632a313 data/test2.data diff --git a/data/test.check-invalid b/data/test.check-invalid new file mode 100644 index 0000000..0dc02a5 --- /dev/null +++ b/data/test.check-invalid @@ -0,0 +1,3 @@ +91beee108359196458cf821584c1259100e6b01e0c5b1099db8a733ff01c238cbe2d038d3a088058123bc012fbce9feb395241ddab5b907b192e005d2048f2ac data/test.data +9c4fb6a525a103b341d73e707090e6bedcbc611b064b7327941b84ea80ab3ab80fde7a55b4d0255e615756326695d3020460d78518f4c2f4192739a464bf5336 bad data/test1.data +2475acb0eb9b5c8fc6d51f4134aafea2c20f46abcd970e222a5952c7061bd6bee0b74d9e10fde8dc87cd6839bea49569ece307d786c7d16c5cc91ddec632a313 data/test2.data diff --git a/data/test.data b/data/test.data new file mode 100644 index 0000000..4b5fa63 --- /dev/null +++ b/data/test.data @@ -0,0 +1 @@ +hello, world diff --git a/data/test1.data b/data/test1.data new file mode 100644 index 0000000..7e92132 --- /dev/null +++ b/data/test1.data @@ -0,0 +1 @@ +Another random string diff --git a/data/test2.data b/data/test2.data new file mode 100644 index 0000000..0b623c3 --- /dev/null +++ b/data/test2.data @@ -0,0 +1 @@ +Blah blah blah diff --git a/stdlib/src/lib.rs b/stdlib/src/lib.rs index 7065564..e738480 100644 --- a/stdlib/src/lib.rs +++ b/stdlib/src/lib.rs @@ -9,3 +9,80 @@ pub fn clap_base_command() -> Command { ) .version(crate_version!()) } + +#[macro_export] +/// Macro for parsing clap arguments into a struct. +macro_rules! clap_args { + // Entry point + ($name:ident { $($body:tt)* }) => { + $crate::clap_args!(@parse $name { fields[] rest[$($body)*] }); + }; + + // Terminal rule: all fields consumed — automatically includes base command fields (output) + (@parse $name:ident { fields[ $({ $kind:ident $field:ident : $ty:ty [$($default:tt)*] })* ] rest[] }) => { + struct $name { + output: Option, + $( $field: $ty ),* + } + + impl $name { + fn from_matches(matches: &::clap::ArgMatches) -> Self { + Self { + output: matches.get_one::("output").cloned(), + $( + $field: $crate::clap_args!(@extract $kind matches $field [$($default)*]), + )* + } + } + } + }; + + // Parsing rules: consume one field at a time into accumulator + + (@parse $name:ident { fields[ $($acc:tt)* ] rest[ flag $field:ident : $ty:ty, $($rest:tt)* ] }) => { + $crate::clap_args!(@parse $name { fields[ $($acc)* { flag $field : $ty [] } ] rest[ $($rest)* ] }); + }; + + (@parse $name:ident { fields[ $($acc:tt)* ] rest[ opt $field:ident : $ty:ty, $($rest:tt)* ] }) => { + $crate::clap_args!(@parse $name { fields[ $($acc)* { opt $field : $ty [] } ] rest[ $($rest)* ] }); + }; + + (@parse $name:ident { fields[ $($acc:tt)* ] rest[ maybe $field:ident : $ty:ty, $($rest:tt)* ] }) => { + $crate::clap_args!(@parse $name { fields[ $($acc)* { maybe $field : $ty [] } ] rest[ $($rest)* ] }); + }; + + (@parse $name:ident { fields[ $($acc:tt)* ] rest[ multi $field:ident : $ty:ty, $($rest:tt)* ] }) => { + $crate::clap_args!(@parse $name { fields[ $($acc)* { multi $field : $ty [] } ] rest[ $($rest)* ] }); + }; + + (@parse $name:ident { fields[ $($acc:tt)* ] rest[ value($default:expr) $field:ident : $ty:ty, $($rest:tt)* ] }) => { + $crate::clap_args!(@parse $name { fields[ $($acc)* { value $field : $ty [$default] } ] rest[ $($rest)* ] }); + }; + + // Extraction rules + + (@extract flag $matches:ident $field:ident []) => {{ + $matches.get_flag(stringify!($field)) + }}; + + (@extract opt $matches:ident $field:ident []) => {{ + $matches.get_one::(stringify!($field)).cloned().unwrap_or_default() + }}; + + (@extract maybe $matches:ident $field:ident []) => {{ + $matches.get_one::(stringify!($field)).cloned() + }}; + + (@extract multi $matches:ident $field:ident []) => {{ + $matches.get_many::(stringify!($field)) + .unwrap_or_default() + .cloned() + .collect() + }}; + + (@extract value $matches:ident $field:ident [$default:expr]) => {{ + $matches.get_one::(stringify!($field)) + .and_then(|v| v.parse().ok()) + .unwrap_or($default) + }}; +}