[keygen] refactor argument parsing logic into separate `app(...)` function and add tests (#31015)
* refactor argument parsing and processing for testing * add tests for command verify * add tests for command pubkey * add tests for command new * add tests for command grind * clippy * be explicit about types * use `try_get_matches` and tempfile * clippy * call `Error::exit` on error from `try_get_matches`
This commit is contained in:
parent
d67fa6c470
commit
0ff8a09041
|
@ -5876,6 +5876,7 @@ dependencies = [
|
||||||
"solana-remote-wallet",
|
"solana-remote-wallet",
|
||||||
"solana-sdk 1.16.0",
|
"solana-sdk 1.16.0",
|
||||||
"solana-version",
|
"solana-version",
|
||||||
|
"tempfile",
|
||||||
"tiny-bip39",
|
"tiny-bip39",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -21,6 +21,9 @@ solana-sdk = { workspace = true }
|
||||||
solana-version = { workspace = true }
|
solana-version = { workspace = true }
|
||||||
tiny-bip39 = { workspace = true }
|
tiny-bip39 = { workspace = true }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile = "3.4.0"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "solana-keygen"
|
name = "solana-keygen"
|
||||||
path = "src/keygen.rs"
|
path = "src/keygen.rs"
|
||||||
|
|
|
@ -27,7 +27,6 @@ use {
|
||||||
collections::HashSet,
|
collections::HashSet,
|
||||||
error,
|
error,
|
||||||
path::Path,
|
path::Path,
|
||||||
process::exit,
|
|
||||||
sync::{
|
sync::{
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, Ordering},
|
||||||
Arc,
|
Arc,
|
||||||
|
@ -124,12 +123,13 @@ impl KeyGenerationCommonArgs for Command<'_> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn check_for_overwrite(outfile: &str, matches: &ArgMatches) {
|
fn check_for_overwrite(outfile: &str, matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
let force = matches.is_present("force");
|
let force = matches.is_present("force");
|
||||||
if !force && Path::new(outfile).exists() {
|
if !force && Path::new(outfile).exists() {
|
||||||
eprintln!("Refusing to overwrite {outfile} without --force flag");
|
let err_msg = format!("Refusing to overwrite {outfile} without --force flag");
|
||||||
exit(1);
|
return Err(err_msg.into());
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_keypair_from_matches(
|
fn get_keypair_from_matches(
|
||||||
|
@ -357,11 +357,10 @@ fn acquire_derivation_path(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() -> Result<(), Box<dyn error::Error>> {
|
fn app<'a>(num_threads: &'a str, crate_version: &'a str) -> Command<'a> {
|
||||||
let default_num_threads = num_cpus::get().to_string();
|
Command::new(crate_name!())
|
||||||
let matches = Command::new(crate_name!())
|
|
||||||
.about(crate_description!())
|
.about(crate_description!())
|
||||||
.version(solana_version::version!())
|
.version(crate_version)
|
||||||
.subcommand_required(true)
|
.subcommand_required(true)
|
||||||
.arg_required_else_help(true)
|
.arg_required_else_help(true)
|
||||||
.arg({
|
.arg({
|
||||||
|
@ -477,7 +476,7 @@ fn main() -> Result<(), Box<dyn error::Error>> {
|
||||||
.value_name("NUMBER")
|
.value_name("NUMBER")
|
||||||
.takes_value(true)
|
.takes_value(true)
|
||||||
.validator(is_parsable::<usize>)
|
.validator(is_parsable::<usize>)
|
||||||
.default_value(&default_num_threads)
|
.default_value(num_threads)
|
||||||
.help("Specify the number of grind threads"),
|
.help("Specify the number of grind threads"),
|
||||||
)
|
)
|
||||||
.arg(
|
.arg(
|
||||||
|
@ -561,8 +560,13 @@ fn main() -> Result<(), Box<dyn error::Error>> {
|
||||||
),
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
.get_matches();
|
}
|
||||||
|
|
||||||
|
fn main() -> Result<(), Box<dyn error::Error>> {
|
||||||
|
let default_num_threads = num_cpus::get().to_string();
|
||||||
|
let matches = app(&default_num_threads, solana_version::version!())
|
||||||
|
.try_get_matches()
|
||||||
|
.unwrap_or_else(|e| e.exit());
|
||||||
do_main(&matches).map_err(|err| DisplayError::new_as_boxed(err).into())
|
do_main(&matches).map_err(|err| DisplayError::new_as_boxed(err).into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -584,7 +588,7 @@ fn do_main(matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
|
|
||||||
if matches.is_present("outfile") {
|
if matches.is_present("outfile") {
|
||||||
let outfile = matches.value_of("outfile").unwrap();
|
let outfile = matches.value_of("outfile").unwrap();
|
||||||
check_for_overwrite(outfile, matches);
|
check_for_overwrite(outfile, matches)?;
|
||||||
write_pubkey_file(outfile, pubkey)?;
|
write_pubkey_file(outfile, pubkey)?;
|
||||||
} else {
|
} else {
|
||||||
println!("{pubkey}");
|
println!("{pubkey}");
|
||||||
|
@ -603,7 +607,7 @@ fn do_main(matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
|
|
||||||
match outfile {
|
match outfile {
|
||||||
Some(STDOUT_OUTFILE_TOKEN) => (),
|
Some(STDOUT_OUTFILE_TOKEN) => (),
|
||||||
Some(outfile) => check_for_overwrite(outfile, matches),
|
Some(outfile) => check_for_overwrite(outfile, matches)?,
|
||||||
None => (),
|
None => (),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -651,7 +655,7 @@ fn do_main(matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
};
|
};
|
||||||
|
|
||||||
if outfile != STDOUT_OUTFILE_TOKEN {
|
if outfile != STDOUT_OUTFILE_TOKEN {
|
||||||
check_for_overwrite(outfile, matches);
|
check_for_overwrite(outfile, matches)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let keypair_name = "recover";
|
let keypair_name = "recover";
|
||||||
|
@ -698,10 +702,9 @@ fn do_main(matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
&& ends_with_args.is_empty()
|
&& ends_with_args.is_empty()
|
||||||
&& starts_and_ends_with_args.is_empty()
|
&& starts_and_ends_with_args.is_empty()
|
||||||
{
|
{
|
||||||
eprintln!(
|
return Err(
|
||||||
"Error: No keypair search criteria provided (--starts-with or --ends-with or --starts-and-ends-with)"
|
"Error: No keypair search criteria provided (--starts-with or --ends-with or --starts-and-ends-with)".into()
|
||||||
);
|
);
|
||||||
exit(1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let num_threads: usize = matches.value_of_t_or_exit("num_threads");
|
let num_threads: usize = matches.value_of_t_or_exit("num_threads");
|
||||||
|
@ -842,8 +845,8 @@ fn do_main(matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
if signature.verify(&pubkey, &simple_message) {
|
if signature.verify(&pubkey, &simple_message) {
|
||||||
println!("Verification for public key: {pubkey_bs58}: Success");
|
println!("Verification for public key: {pubkey_bs58}: Success");
|
||||||
} else {
|
} else {
|
||||||
println!("Verification for public key: {pubkey_bs58}: Failed");
|
let err_msg = format!("Verification for public key: {pubkey_bs58}: Failed");
|
||||||
exit(1);
|
return Err(err_msg.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
|
@ -851,3 +854,373 @@ fn do_main(matches: &ArgMatches) -> Result<(), Box<dyn error::Error>> {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use {
|
||||||
|
super::*,
|
||||||
|
tempfile::{tempdir, TempDir},
|
||||||
|
};
|
||||||
|
|
||||||
|
fn process_test_command(args: &[&str]) -> Result<(), Box<dyn error::Error>> {
|
||||||
|
let default_num_threads = num_cpus::get().to_string();
|
||||||
|
let solana_version = solana_version::version!();
|
||||||
|
let app_matches = app(&default_num_threads, solana_version).get_matches_from(args);
|
||||||
|
do_main(&app_matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tmp_keypair_and_config_file(
|
||||||
|
keypair_out_dir: &TempDir,
|
||||||
|
config_out_dir: &TempDir,
|
||||||
|
) -> (Pubkey, String, String) {
|
||||||
|
let keypair = Keypair::new();
|
||||||
|
let keypair_path = keypair_out_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("{}-keypair", keypair.pubkey()));
|
||||||
|
let keypair_outfile = keypair_path.into_os_string().into_string().unwrap();
|
||||||
|
write_keypair_file(&keypair, &keypair_outfile).unwrap();
|
||||||
|
|
||||||
|
let config = Config {
|
||||||
|
keypair_path: keypair_outfile.clone(),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let config_path = config_out_dir
|
||||||
|
.path()
|
||||||
|
.join(format!("{}-config", keypair.pubkey()));
|
||||||
|
let config_outfile = config_path.into_os_string().into_string().unwrap();
|
||||||
|
config.save(&config_outfile).unwrap();
|
||||||
|
|
||||||
|
(keypair.pubkey(), keypair_outfile, config_outfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmp_outfile_path(out_dir: &TempDir, name: &str) -> String {
|
||||||
|
let path = out_dir.path().join(name);
|
||||||
|
path.into_os_string().into_string().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_arguments() {
|
||||||
|
let default_num_threads = num_cpus::get().to_string();
|
||||||
|
let solana_version = solana_version::version!();
|
||||||
|
|
||||||
|
// run clap internal assert statements
|
||||||
|
app(&default_num_threads, solana_version).debug_assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verify() {
|
||||||
|
let keypair_out_dir = tempdir().unwrap();
|
||||||
|
let config_out_dir = tempdir().unwrap();
|
||||||
|
let (correct_pubkey, keypair_path, config_path) =
|
||||||
|
create_tmp_keypair_and_config_file(&keypair_out_dir, &config_out_dir);
|
||||||
|
|
||||||
|
// success case using a keypair file
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"verify",
|
||||||
|
&correct_pubkey.to_string(),
|
||||||
|
&keypair_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// success case using a config file
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"verify",
|
||||||
|
&correct_pubkey.to_string(),
|
||||||
|
"--config",
|
||||||
|
&config_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// fail case using a keypair file
|
||||||
|
let incorrect_pubkey = Pubkey::new_unique();
|
||||||
|
let result = process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"verify",
|
||||||
|
&incorrect_pubkey.to_string(),
|
||||||
|
&keypair_path,
|
||||||
|
])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expected = format!("Verification for public key: {incorrect_pubkey}: Failed");
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
|
||||||
|
// fail case using a config file
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"verify",
|
||||||
|
&incorrect_pubkey.to_string(),
|
||||||
|
"--config",
|
||||||
|
&config_path,
|
||||||
|
])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expected = format!("Verification for public key: {incorrect_pubkey}: Failed");
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
|
||||||
|
// keypair file takes precedence over config file
|
||||||
|
let alt_keypair_out_dir = tempdir().unwrap();
|
||||||
|
let alt_config_out_dir = tempdir().unwrap();
|
||||||
|
let (_, alt_keypair_path, alt_config_path) =
|
||||||
|
create_tmp_keypair_and_config_file(&alt_keypair_out_dir, &alt_config_out_dir);
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"verify",
|
||||||
|
&correct_pubkey.to_string(),
|
||||||
|
&keypair_path,
|
||||||
|
"--config",
|
||||||
|
&alt_config_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"verify",
|
||||||
|
&correct_pubkey.to_string(),
|
||||||
|
&alt_keypair_path,
|
||||||
|
"--config",
|
||||||
|
&config_path,
|
||||||
|
])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expected = format!("Verification for public key: {incorrect_pubkey}: Failed");
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pubkey() {
|
||||||
|
let keypair_out_dir = tempdir().unwrap();
|
||||||
|
let config_out_dir = tempdir().unwrap();
|
||||||
|
let (expected_pubkey, keypair_path, config_path) =
|
||||||
|
create_tmp_keypair_and_config_file(&keypair_out_dir, &config_out_dir);
|
||||||
|
|
||||||
|
// success case using a keypair file
|
||||||
|
{
|
||||||
|
let outfile_dir = tempdir().unwrap();
|
||||||
|
let outfile_path = tmp_outfile_path(&outfile_dir, &expected_pubkey.to_string());
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"pubkey",
|
||||||
|
&keypair_path,
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result_pubkey = solana_sdk::pubkey::read_pubkey_file(&outfile_path).unwrap();
|
||||||
|
assert_eq!(result_pubkey, expected_pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// success case using a config file
|
||||||
|
{
|
||||||
|
let outfile_dir = tempdir().unwrap();
|
||||||
|
let outfile_path = tmp_outfile_path(&outfile_dir, &expected_pubkey.to_string());
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"pubkey",
|
||||||
|
"--config",
|
||||||
|
&config_path,
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result_pubkey = solana_sdk::pubkey::read_pubkey_file(&outfile_path).unwrap();
|
||||||
|
assert_eq!(result_pubkey, expected_pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// keypair file takes precedence over config file
|
||||||
|
{
|
||||||
|
let alt_keypair_out_dir = tempdir().unwrap();
|
||||||
|
let alt_config_out_dir = tempdir().unwrap();
|
||||||
|
let (_, _, alt_config_path) =
|
||||||
|
create_tmp_keypair_and_config_file(&alt_keypair_out_dir, &alt_config_out_dir);
|
||||||
|
let outfile_dir = tempdir().unwrap();
|
||||||
|
let outfile_path = tmp_outfile_path(&outfile_dir, &expected_pubkey.to_string());
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"pubkey",
|
||||||
|
&keypair_path,
|
||||||
|
"--config",
|
||||||
|
&alt_config_path,
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result_pubkey = solana_sdk::pubkey::read_pubkey_file(&outfile_path).unwrap();
|
||||||
|
assert_eq!(result_pubkey, expected_pubkey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// refuse to overwrite file
|
||||||
|
{
|
||||||
|
let outfile_dir = tempdir().unwrap();
|
||||||
|
let outfile_path = tmp_outfile_path(&outfile_dir, &expected_pubkey.to_string());
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"pubkey",
|
||||||
|
&keypair_path,
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"pubkey",
|
||||||
|
"--config",
|
||||||
|
&config_path,
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expected = format!("Refusing to overwrite {outfile_path} without --force flag");
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_new() {
|
||||||
|
let keypair_out_dir = tempdir().unwrap();
|
||||||
|
let config_out_dir = tempdir().unwrap();
|
||||||
|
let (expected_pubkey, _, _) =
|
||||||
|
create_tmp_keypair_and_config_file(&keypair_out_dir, &config_out_dir);
|
||||||
|
|
||||||
|
let outfile_dir = tempdir().unwrap();
|
||||||
|
let outfile_path = tmp_outfile_path(&outfile_dir, &expected_pubkey.to_string());
|
||||||
|
|
||||||
|
// general success case
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// refuse to overwrite file
|
||||||
|
let result = process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--outfile",
|
||||||
|
&outfile_path,
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expected = format!("Refusing to overwrite {outfile_path} without --force flag");
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
|
||||||
|
// no outfile
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--no-outfile",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// sanity check on languages and word count combinations
|
||||||
|
let languages = [
|
||||||
|
"english",
|
||||||
|
"chinese-simplified",
|
||||||
|
"chinese-traditional",
|
||||||
|
"japanese",
|
||||||
|
"spanish",
|
||||||
|
"korean",
|
||||||
|
"french",
|
||||||
|
"italian",
|
||||||
|
];
|
||||||
|
let word_counts = ["12", "15", "18", "21", "24"];
|
||||||
|
|
||||||
|
for language in languages {
|
||||||
|
for word_count in word_counts {
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--no-outfile",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--language",
|
||||||
|
language,
|
||||||
|
"--word-count",
|
||||||
|
word_count,
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanity check derivation path
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--no-outfile",
|
||||||
|
"--derivation-path",
|
||||||
|
// empty derivation path
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--no-outfile",
|
||||||
|
"--derivation-path",
|
||||||
|
"m/44'/501'/0'/0'", // default derivation path
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let result = process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"new",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--no-outfile",
|
||||||
|
"--derivation-path",
|
||||||
|
"-", // invalid derivation path
|
||||||
|
])
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
let expected = "invalid derivation path: invalid prefix: -";
|
||||||
|
assert_eq!(result, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_grind() {
|
||||||
|
// simple sanity checks
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"grind",
|
||||||
|
"--no-outfile",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--use-mnemonic",
|
||||||
|
"--starts-with",
|
||||||
|
"a:1",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
process_test_command(&[
|
||||||
|
"solana-keygen",
|
||||||
|
"grind",
|
||||||
|
"--no-outfile",
|
||||||
|
"--no-bip39-passphrase",
|
||||||
|
"--use-mnemonic",
|
||||||
|
"--ends-with",
|
||||||
|
"b:1",
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue