use clap::{ArgGroup, Parser, Subcommand};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "doppel",
about = "Intercept secrets in payloads, replace with fakes, restore from responses",
long_about = "Intercept secrets in payloads, replace with fakes, restore from responses.\n\nWorkflow:\n 1. doppel init - create a patterns file (one-time setup)\n 2. doppel register/define - add secrets to detect\n 3. doppel swap - replace secrets with fakes; emit swapped payload + entries + key file\n 4. forward swapped payload to the external service\n 5. doppel restore - pipe the response through to recover originals\n\nThe patterns file is stable across requests. Entries and session key are per-request."
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Replace detected secrets with fakes; write swapped payload to stdout.
///
/// Reads the payload from stdin. Writes the swapped payload to stdout.
/// Also writes an entries file (not sensitive) and a session key file (sensitive,
/// mode 0600). Both are required by `doppel restore` to reverse the substitution.
Swap {
/// Patterns file (created by `init`).
#[arg(long)]
patterns: PathBuf,
/// Output: entries file (ciphertext; pass to `restore` via --entries).
///
/// Not sensitive; safe to store alongside the swapped payload.
/// Pass to `doppel restore --entries` to reverse the substitution.
#[arg(long)]
entries: PathBuf,
/// Output: session key file (sensitive, mode 0600; export as DOPPEL_KEY for restore).
///
/// Sensitive - created with mode 0600. Export its hex contents as DOPPEL_KEY
/// before running `doppel restore`.
#[arg(long = "key-out")]
key_out: PathBuf,
},
/// Restore fakes in a response stream back to their original secrets.
///
/// Reads from stdin; writes restored output to stdout. Streams output -
/// does not wait for stdin EOF before writing.
/// Session key: export DOPPEL_KEY with the hex string from swap's key file.
Restore {
/// Entries file produced by `doppel swap`.
#[arg(long)]
entries: PathBuf,
// NO --key flag - INV-20: key via DOPPEL_KEY env var only
},
/// Create a patterns file with all built-in structural pattern definitions.
///
/// The patterns file configures secret detection and fake generation for `swap`.
/// Built-in patterns cover common API key formats (Anthropic, OpenAI, AWS, GitHub, GCP).
/// Use `register` or `define` to add more.
Init {
/// Path to create the patterns file.
#[arg(long)]
patterns: PathBuf,
/// Overwrite if file exists.
///
/// WARNING: regenerates all salts - previously produced fakes become invalid.
#[arg(long)]
force: bool,
},
/// Register a secret (read from stdin) into an existing patterns file.
///
/// Matched by exact value (HMAC-verified). Use when the secret has no structural
/// pattern, or for guaranteed detection of a specific known value.
Register {
/// Path to the patterns file to update.
#[arg(long)]
patterns: PathBuf,
/// Unique label for this secret within the patterns file.
#[arg(long)]
label: String,
/// Bytes at start of secret to preserve verbatim in the fake.
#[arg(long, default_value_t = 0)]
preserve_prefix: usize,
/// Bytes at end of secret to preserve verbatim in the fake.
#[arg(long, default_value_t = 0)]
preserve_suffix: usize,
/// Generate fake bytes from the secret's own charset only.
#[arg(long)]
restrict_charset: bool,
/// Number of bytes at start of secret used as detection anchor. Default: 2.
#[arg(long, default_value_t = 2)]
start_fragment: usize,
/// Number of bytes at end of secret used as detection anchor. Default: 2.
#[arg(long, default_value_t = 2)]
end_fragment: usize,
},
/// Add a user-defined structural pattern to the patterns file.
#[command(
long_about = "Add a user-defined structural pattern to the patterns file.\n\nStructural patterns match secrets by format, not exact value. Supply --segment for each segment in order.\n\nliteral:<value> - exact fixed string\n\nvariable:<charset>:<min>:<max> - variable-length random segment\n\nCharsets: alphanumeric, uppercase_alphanumeric, digits, hex_lower, url_safe_base64\n\nExample: --segment literal:sk- --segment variable:alphanumeric:48:48"
)]
Define {
/// Path to the patterns file to update.
#[arg(long)]
patterns: PathBuf,
/// Unique identifier for this pattern (e.g. MY_API_KEY).
#[arg(long)]
identifier: String,
/// Segment: "literal:<value>" or "variable:<charset>:<min>:<max>"; repeat in order.
#[arg(
long,
required = true,
num_args = 1,
long_help = "Repeat for each segment in order.\n\nliteral:<value> - exact fixed string\n\nvariable:<charset>:<min>:<max> - variable-length random segment\n\nCharsets: alphanumeric, uppercase_alphanumeric, digits, hex_lower, url_safe_base64\n\nExample: --segment literal:sk- --segment variable:alphanumeric:48:48"
)]
segment: Vec<String>,
},
/// List all structural patterns and registered secrets in a patterns file.
List {
/// Path to the patterns file.
#[arg(long)]
patterns: PathBuf,
},
/// Show details for a structural pattern or registered secret.
#[command(group(ArgGroup::new("target").required(true).args(["identifier", "label"])))]
Inspect {
/// Path to the patterns file.
#[arg(long)]
patterns: PathBuf,
/// Identifier of the structural pattern to inspect.
#[arg(long, group = "target")]
identifier: Option<String>,
/// Label of the registered secret to inspect.
#[arg(long, group = "target")]
label: Option<String>,
},
/// Remove a structural pattern or registered secret from a patterns file.
#[command(group(ArgGroup::new("target").required(true).args(["identifier", "label"])))]
Remove {
/// Path to the patterns file.
#[arg(long)]
patterns: PathBuf,
/// Identifier of the structural pattern to remove.
#[arg(long, group = "target")]
identifier: Option<String>,
/// Label of the registered secret to remove.
#[arg(long, group = "target")]
label: Option<String>,
},
}
const INIT_COMMENT_BLOCK: &str = r#"# Registered secrets are registered via the CLI:
#
# echo -n 'my-secret-value' | doppel register \
# --patterns <this-file> \
# --label my-secret \
# --preserve-prefix 0 \
# --preserve-suffix 0 \
# --start-fragment 2 \
# --end-fragment 2
#
# Each [[registered]] entry contains:
# label - human-readable identifier (unique, required by CLI)
# start_fragment - first bytes of the secret (hex; detection anchor)
# end_fragment - last bytes of the secret (hex; detection anchor)
# exact_length - total byte length of the secret
# hmac_salt - unique salt for HMAC verification (hex)
# hmac_digest - HMAC digest for candidate confirmation (hex)
# preserve_prefix - bytes at start preserved verbatim in fake
# preserve_suffix - bytes at end preserved verbatim in fake
# charset - (optional) byte set for fake generation; omit for wide default
#
# User-defined Structural patterns can be added via:
#
# doppel define --patterns <this-file> \
# --identifier MY_PATTERN \
# --segment literal:prefix_ \
# --segment variable:alphanumeric:32:32
#
"#;
fn read_patterns_file(path: &Path) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
std::fs::read(path).map_err(|e| -> Box<dyn std::error::Error> {
if e.kind() == std::io::ErrorKind::NotFound {
format!(
"patterns file not found: {}\n tip: create it with: doppel init --patterns {}",
path.display(),
path.display()
)
.into()
} else {
format!("failed to read patterns file: {}", e).into()
}
})
}
fn run_swap(
patterns_path: &Path,
entries_path: &Path,
key_out_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
use doppel::{SecretsFile, swap, types::Entry};
use std::io::{self, Read, Write};
let file_data = read_patterns_file(patterns_path)?;
let pf = SecretsFile::deserialize(&file_data)
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
let patterns = pf.to_patterns().map_err(|e| {
format!(
"failed to load patterns from {}: {}",
patterns_path.display(),
e
)
})?;
let mut payload = Vec::new();
io::stdin().read_to_end(&mut payload)?;
let result = swap(&payload, &patterns)?;
// Serialize in memory first; fail before any output if key file already exists
let entries_json = Entry::serialize_entries(&result.entries)?;
write_key_file(key_out_path, result.session_key.as_bytes())?;
io::stdout().write_all(&result.payload)?;
std::fs::write(entries_path, &entries_json)?;
Ok(())
}
fn run_init(patterns_path: &Path, force: bool) -> Result<(), Box<dyn std::error::Error>> {
use doppel::SecretsFile;
let mut pf = SecretsFile::new();
pf.generate_missing_structural_salts_with_segments();
let serialized = pf.serialize()?;
let mut data = INIT_COMMENT_BLOCK.as_bytes().to_vec();
data.extend_from_slice(&serialized);
write_patterns_file(patterns_path, &data, !force).map_err(
|e| -> Box<dyn std::error::Error> {
if e.kind() == std::io::ErrorKind::AlreadyExists {
format!(
"patterns file already exists: {}\n Use --force to overwrite (WARNING: regenerates all salts; existing fakes become invalid)",
patterns_path.display()
)
.into()
} else {
e.into()
}
},
)?;
let count = pf.structural.len();
eprintln!(
"created patterns file: {} ({} Structural patterns, 0 Registered secrets)",
patterns_path.display(),
count
);
Ok(())
}
fn run_register(
patterns_path: &Path,
label: &str,
preserve_prefix: usize,
preserve_suffix: usize,
restrict_charset: bool,
start_fragment_len: usize,
end_fragment_len: usize,
) -> Result<(), Box<dyn std::error::Error>> {
use doppel::{SecretOptions, SecretsFile, register_with_options};
use std::io::Read;
let mut secret = zeroize::Zeroizing::new(Vec::new());
std::io::stdin().read_to_end(&mut secret)?;
if secret.is_empty() {
return Err("no secret provided on stdin".into());
}
let file_content = std::fs::read_to_string(patterns_path).map_err(|_| {
format!(
"patterns file not found: {}\n tip: create it with: doppel init --patterns {}",
patterns_path.display(),
patterns_path.display()
)
})?;
let mut doc: toml_edit::DocumentMut = file_content
.parse()
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
let mut pf = SecretsFile::deserialize(file_content.as_bytes())
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
let opts = SecretOptions {
preserve_prefix,
preserve_suffix,
restrict_charset,
start_fragment_len,
end_fragment_len,
};
let pattern = register_with_options(&secret, &opts)?;
pf.add_secret_pattern(&pattern, Some(label.to_string()))?;
let new_entry = pf.registered.last().expect("entry just added");
let item = toml_edit::ser::to_document(new_entry)
.map_err(|e| format!("failed to serialize entry: {}", e))?
.into_item();
let table = item
.into_table()
.map_err(|_| "serialized entry was not a TOML table")?;
if doc
.get("registered")
.map(|i| i.is_array_of_tables())
.unwrap_or(false)
{
doc["registered"]
.as_array_of_tables_mut()
.unwrap()
.push(table);
} else {
doc.remove("registered");
let mut aot = toml_edit::ArrayOfTables::new();
aot.push(table);
doc.insert("registered", toml_edit::Item::ArrayOfTables(aot));
}
let new_content = doc.to_string();
write_patterns_file(patterns_path, new_content.as_bytes(), false)?;
let variable_len = secret.len() - preserve_prefix - preserve_suffix;
eprintln!(
"registered secret: {} (variable portion: {} bytes)",
label, variable_len
);
Ok(())
}
fn run_define(
patterns_path: &Path,
identifier: &str,
segment_specs: &[String],
) -> Result<(), Box<dyn std::error::Error>> {
use doppel::SecretsFile;
use rand::RngCore;
let seg_defs: Vec<doppel::segment::SegmentDef> = segment_specs
.iter()
.enumerate()
.map(|(i, spec)| parse_segment_spec(spec, i))
.collect::<Result<_, _>>()?;
let file_content = std::fs::read_to_string(patterns_path).map_err(|_| {
format!(
"patterns file not found: {}\n tip: create it with: doppel init --patterns {}",
patterns_path.display(),
patterns_path.display()
)
})?;
let mut doc: toml_edit::DocumentMut = file_content
.parse()
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
let mut pf = SecretsFile::deserialize(file_content.as_bytes())
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
let mut salt = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut salt);
pf.add_structural_entry(identifier.to_string(), seg_defs, salt)?;
let new_entry = pf.structural.last().expect("entry just added");
let item = toml_edit::ser::to_document(new_entry)
.map_err(|e| format!("failed to serialize entry: {}", e))?
.into_item();
let table = item
.into_table()
.map_err(|_| "serialized entry was not a TOML table")?;
doc["structural"]
.as_array_of_tables_mut()
.ok_or("structural is not an array of tables")?
.push(table);
let new_content = doc.to_string();
write_patterns_file(patterns_path, new_content.as_bytes(), false)?;
let seg_count = segment_specs.len();
eprintln!(
"defined Structural pattern: {} ({} segments)",
identifier, seg_count
);
Ok(())
}
fn parse_segment_spec(spec: &str, index: usize) -> Result<doppel::segment::SegmentDef, String> {
use doppel::segment::SegmentDef;
let (seg_type, rest) = spec.split_once(':').ok_or_else(|| {
format!(
"invalid segment spec \"{}\"; expected \"literal:<value>\" or \"variable:<charset>:<min>:<max>\"",
spec
)
})?;
match seg_type {
"literal" => Ok(SegmentDef::Literal {
value: rest.to_string(),
}),
"variable" => {
let parts: Vec<&str> = rest.splitn(3, ':').collect();
if parts.len() != 3 {
return Err(format!(
"invalid segment spec \"{}\"; expected \"variable:<charset>:<min>:<max>\"",
spec
));
}
let charset = parts[0].to_string();
let min: usize = parts[1]
.parse()
.map_err(|_| format!("segment {}: min is not a valid number", index))?;
let max: usize = parts[2]
.parse()
.map_err(|_| format!("segment {}: max is not a valid number", index))?;
Ok(SegmentDef::Variable { charset, min, max })
}
_ => Err(format!(
"invalid segment spec \"{}\"; expected \"literal:<value>\" or \"variable:<charset>:<min>:<max>\"",
spec
)),
}
}
fn run_list(patterns_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
use doppel::SecretsFile;
let file_data = read_patterns_file(patterns_path)?;
let pf = SecretsFile::deserialize(&file_data)
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
println!("Structural patterns:");
if pf.structural.is_empty() {
println!(" (none)");
} else {
let max_id_len = pf
.structural
.iter()
.map(|e| e.identifier.len())
.max()
.unwrap_or(0);
let col_width = max_id_len.clamp(10, 40);
for entry in &pf.structural {
let desc = format_pattern_segments(entry);
println!(" {:width$} {}", entry.identifier, desc, width = col_width);
}
}
println!();
println!("Registered secrets:");
if pf.registered.is_empty() {
println!(" (none)");
} else {
for entry in &pf.registered {
let label_str = entry.label.as_deref().unwrap_or("(unlabeled)");
let charset_desc = format_charset_summary(&entry.charset);
println!(
" {} length={} charset={}",
label_str, entry.exact_length, charset_desc
);
}
}
Ok(())
}
fn format_pattern_segments(entry: &doppel::PatternEntry) -> String {
match &entry.segments {
Some(defs) => defs
.iter()
.map(|d| match d {
doppel::segment::SegmentDef::Literal { value } => {
format!("\"{}\"", value)
}
doppel::segment::SegmentDef::Variable { charset, min, max } => {
if min == max {
format!("<{} {}>", min, charset)
} else {
format!("<{}-{} {}>", min, max, charset)
}
}
})
.collect::<Vec<_>>()
.join(" "),
None => "(built-in, segments not in file)".to_string(),
}
}
fn format_charset_summary(charset: &Option<Vec<u8>>) -> String {
match charset {
None => "wide".to_string(),
Some(cs) => format!("custom ({} chars)", cs.len()),
}
}
fn run_inspect(
patterns_path: &Path,
identifier: Option<&str>,
label: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
use doppel::SecretsFile;
let file_data = read_patterns_file(patterns_path)?;
let pf = SecretsFile::deserialize(&file_data)
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
if let Some(id) = identifier {
let entry = pf
.structural
.iter()
.find(|e| e.identifier == id)
.ok_or_else(|| format!("no structural pattern with identifier \"{}\"", id))?;
let is_builtin = doppel::SecretsFile::is_builtin_identifier(id);
let type_str = if is_builtin {
"built-in"
} else {
"user-defined"
};
let salt_fingerprint = hex_encode(&entry.salt[..4]);
println!("Structural pattern: {}", id);
println!(" Type: {}", type_str);
println!(" Salt: {}...", &*salt_fingerprint);
println!(" Segments:");
match &entry.segments {
Some(defs) => {
for (i, d) in defs.iter().enumerate() {
match d {
doppel::segment::SegmentDef::Literal { value } => {
println!(" {}. literal \"{}\"", i + 1, value);
}
doppel::segment::SegmentDef::Variable { charset, min, max } => {
println!(
" {}. variable charset={} min={} max={}",
i + 1,
charset,
min,
max
);
}
}
}
}
None => {
println!(" (compiled-in; not stored in file)");
}
}
} else if let Some(lbl) = label {
let entry = pf
.registered
.iter()
.find(|e| e.label.as_deref() == Some(lbl))
.ok_or_else(|| format!("no registered secret with label \"{}\"", lbl))?;
let variable = entry.exact_length - entry.preserve_prefix - entry.preserve_suffix;
let charset_desc = format_charset_summary(&entry.charset);
let salt_fingerprint = hex_encode(&entry.hmac_salt[..4]);
println!("Registered secret: {}", lbl);
println!(" Length: {} bytes", entry.exact_length);
println!(" Preserve prefix: {} bytes", entry.preserve_prefix);
println!(" Preserve suffix: {} bytes", entry.preserve_suffix);
println!(" Variable portion: {} bytes", variable);
println!(" Charset: {}", charset_desc);
println!(" Salt: {}...", &*salt_fingerprint);
} else {
unreachable!("clap requires --identifier or --label")
}
Ok(())
}
fn run_remove(
patterns_path: &Path,
identifier: Option<&str>,
label: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
use doppel::SecretsFile;
let file_content = std::fs::read_to_string(patterns_path).map_err(|_| {
format!(
"patterns file not found: {}\n tip: create it with: doppel init --patterns {}",
patterns_path.display(),
patterns_path.display()
)
})?;
let mut doc: toml_edit::DocumentMut = file_content
.parse()
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
let pf = SecretsFile::deserialize(file_content.as_bytes())
.map_err(|e| format!("invalid patterns file: {}: {}", patterns_path.display(), e))?;
if let Some(id) = identifier {
pf.structural
.iter()
.position(|e| e.identifier == id)
.ok_or_else(|| {
format!(
"no structural pattern with identifier \"{}\" in {}",
id,
patterns_path.display()
)
})?;
if SecretsFile::is_builtin_identifier(id) {
eprintln!(
"warning: removing built-in pattern \"{}\"; swap will no longer detect this secret class",
id
);
}
if let Some(aot) = doc["structural"].as_array_of_tables_mut() {
let idx = aot
.iter()
.position(|t| t["identifier"].as_str() == Some(id));
if let Some(i) = idx {
aot.remove(i);
}
}
let new_content = doc.to_string();
write_patterns_file(patterns_path, new_content.as_bytes(), false)?;
eprintln!("removed Structural pattern: {}", id);
} else if let Some(lbl) = label {
pf.registered
.iter()
.position(|e| e.label.as_deref() == Some(lbl))
.ok_or_else(|| {
format!(
"no registered secret with label \"{}\" in {}",
lbl,
patterns_path.display()
)
})?;
if let Some(aot) = doc["registered"].as_array_of_tables_mut() {
let idx = aot.iter().position(|t| t["label"].as_str() == Some(lbl));
if let Some(i) = idx {
aot.remove(i);
}
}
let new_content = doc.to_string();
write_patterns_file(patterns_path, new_content.as_bytes(), false)?;
eprintln!("removed Registered secret: {}", lbl);
} else {
unreachable!("clap requires --identifier or --label")
}
Ok(())
}
fn write_patterns_file(path: &Path, data: &[u8], create_exclusive: bool) -> std::io::Result<()> {
use std::io::Write;
if create_exclusive {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
// INV-21 equivalent: O_CREAT|O_EXCL is atomic — fails if file exists
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(data)?;
}
#[cfg(not(unix))]
{
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)?;
file.write_all(data)?;
}
} else {
// Write to a .tmp sibling then rename (atomic on POSIX same-filesystem)
let tmp = path.with_extension("tmp");
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)?;
file.write_all(data)?;
// mode(0o600) only applies to new inodes; set explicitly for existing files
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
std::fs::write(&tmp, data)?;
}
std::fs::rename(&tmp, path)?;
}
Ok(())
}
fn write_key_file(path: &Path, key_bytes: &[u8; 32]) -> std::io::Result<()> {
use std::io::Write;
let hex_key = hex_encode(key_bytes);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true) // INV-21: O_EXCL ensures mode applies to a fresh inode
.mode(0o600)
.open(path)?;
file.write_all(hex_key.as_bytes())?;
file.write_all(b"\n")?;
}
#[cfg(not(unix))]
{
let mut file = std::fs::File::create(path)?;
file.write_all(hex_key.as_bytes())?;
file.write_all(b"\n")?;
}
Ok(())
}
fn hex_encode(bytes: &[u8]) -> zeroize::Zeroizing<String> {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
write!(s, "{:02x}", b).expect("writing to String is infallible");
}
zeroize::Zeroizing::new(s)
}
fn run_restore(entries_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
use doppel::{
restore,
types::{Entry, SessionKey},
};
use std::io;
// INV-20: session key ONLY via DOPPEL_KEY env var
let key_hex = zeroize::Zeroizing::new(
std::env::var("DOPPEL_KEY").map_err(|_| "DOPPEL_KEY environment variable not set")?,
);
let key_bytes: zeroize::Zeroizing<Vec<u8>> =
zeroize::Zeroizing::new(hex_decode(&key_hex).map_err(|_| "DOPPEL_KEY is not valid hex")?);
let key_array = zeroize::Zeroizing::new(
<[u8; 32]>::try_from(key_bytes.as_slice())
.map_err(|_| "DOPPEL_KEY must be 64 hex characters (32 bytes)")?,
);
let session_key = SessionKey::from_bytes(*key_array);
let entries_data = std::fs::read(entries_path)?;
let entries = Entry::deserialize_entries(&entries_data)?;
let stdin = io::stdin();
let stdout = io::stdout();
restore(
&mut stdin.lock(),
&mut stdout.lock(),
&entries,
&session_key,
)?;
Ok(())
}
fn hex_decode(s: &str) -> Result<Vec<u8>, ()> {
if s.len() % 2 != 0 {
return Err(());
}
s.as_bytes()
.chunks(2)
.map(|pair| {
let hi = hex_nibble(pair[0])?;
let lo = hex_nibble(pair[1])?;
Ok((hi << 4) | lo)
})
.collect()
}
fn hex_nibble(b: u8) -> Result<u8, ()> {
match b {
b'0'..=b'9' => Ok(b - b'0'),
b'a'..=b'f' => Ok(b - b'a' + 10),
b'A'..=b'F' => Ok(b - b'A' + 10),
_ => Err(()),
}
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Commands::Swap {
patterns,
entries,
key_out,
} => run_swap(&patterns, &entries, &key_out),
Commands::Restore { entries } => run_restore(&entries),
Commands::Init { patterns, force } => run_init(&patterns, force),
Commands::Register {
patterns,
label,
preserve_prefix,
preserve_suffix,
restrict_charset,
start_fragment,
end_fragment,
} => run_register(
&patterns,
&label,
preserve_prefix,
preserve_suffix,
restrict_charset,
start_fragment,
end_fragment,
),
Commands::Define {
patterns,
identifier,
segment,
} => run_define(&patterns, &identifier, &segment),
Commands::List { patterns } => run_list(&patterns),
Commands::Inspect {
patterns,
identifier,
label,
} => run_inspect(&patterns, identifier.as_deref(), label.as_deref()),
Commands::Remove {
patterns,
identifier,
label,
} => run_remove(&patterns, identifier.as_deref(), label.as_deref()),
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}