use std::fs;
use std::io::{BufRead, BufReader, BufWriter, Write};
use std::path::Path;
use crate::error::{Error, Result};
use crate::etree::{self, ParseOps};
use crate::{capability, cappolicy, consts};
use super::{
CommonArgs, EncryptOpts, Operation, OutputArgs, build_anchor_config, make_policy,
resolve_separators, walk_for_chains,
};
#[derive(Clone, Debug)]
pub struct RunConfig {
pub common: CommonArgs,
pub output: OutputArgs,
pub op: Option<(EncryptOpts, Operation)>,
pub recipient_pubs: Vec<String>,
pub recipient_privs: Vec<String>,
}
impl RunConfig {
pub(super) fn build_paops(&self, policy_name: &str) -> Result<ParseOps> {
let policy = make_policy(policy_name);
let mut paops = if let Some(defaults) = self.common.defaults.as_deref() {
let mut p = ParseOps::new(make_policy(defaults))?;
p.crypto.policy = policy;
p
} else {
ParseOps::new(policy)?
};
if let Some(dir) = self.common.casdir.clone() {
paops.io.set_local_casdir(dir);
} else if Path::new("cas").is_dir() {
paops.io.set_local_casdir(Path::new("cas").to_path_buf());
} else {
paops.io.set_local_casdir(Path::new(".").to_path_buf());
}
paops.io.verbose = self.common.verbose && !self.common.quiet;
paops.io.inline_data = self.common.inline || self.common.casdir.is_none();
paops.max_depth = self.common.max_depth;
let (left, right) = resolve_separators(&self.common);
paops.separators.left = left;
paops.separators.right = right;
paops.passwords.extend(self.common.password.clone());
paops.crypto.recipient_pubs = self.recipient_pubs.clone();
for (i, w) in self.output.word.iter().enumerate() {
if let Some(priv_pem) = self
.recipient_privs
.get(i)
.or_else(|| self.recipient_privs.first())
{
paops
.crypto
.recipient_privkeys
.insert(w.clone(), priv_pem.clone());
}
}
if self.common.pbkdf_disable_cache {
paops.crypto.pbkdf_cache = None;
}
if let Some((enc_opts, op_kind)) = self.op.as_ref() {
for w in &self.output.word {
match op_kind {
Operation::Encrypt => {
paops.transforms.encrypt.insert(w.clone());
}
Operation::Decrypt => {
paops.transforms.decrypt.insert(w.clone());
}
Operation::Store => {
paops.transforms.store.insert(w.clone());
}
Operation::Fetch => {
paops.transforms.fetch.insert(w.clone());
}
Operation::EncryptStore => {
paops.transforms.encrypt.insert(w.clone());
paops.transforms.store.insert(w.clone());
}
}
}
if matches!(op_kind, Operation::Encrypt | Operation::EncryptStore) {
if let Some(alg) = enc_opts.pbkdf.as_deref() {
paops.crypto.pbkdfopts.alg = alg.to_string();
}
if let Some(saltlen) = enc_opts.pbkdf_salt_len {
paops.crypto.pbkdfopts.saltlen = saltlen;
}
if let Some(msec) = enc_opts.pbkdf_msec {
paops.crypto.pbkdfopts.msec = Some(msec);
}
if let Some(raw) = enc_opts.pbkdf_params.as_deref() {
paops.crypto.pbkdfopts.msec = None;
let params: std::collections::BTreeMap<String, usize> = raw
.split(',')
.map(|kv| {
let (k, v) = kv.split_once('=').unwrap_or(("", "0"));
(k.to_string(), v.parse().unwrap_or(0))
})
.collect();
paops.crypto.pbkdfopts.params = Some(params);
}
if let Some(salt_hex) = enc_opts.pbkdf_salt.as_deref() {
paops.crypto.pbkdfopts.salt = Some(hex::decode(salt_hex).map_err(Error::from)?);
}
if let Some(c) = enc_opts.cipher.as_deref() {
paops.crypto.cipheropts.alg = c.to_string();
}
if let Some(iv_hex) = enc_opts.cipher_iv.as_deref() {
paops.crypto.cipheropts.iv = Some(hex::decode(iv_hex).map_err(Error::from)?);
}
}
}
paops.anchor = build_anchor_config(
self.common.anchor,
self.common.signer.as_deref(),
self.op.as_ref().map(|(_, k)| *k),
&self.output.word,
)?;
Ok(paops)
}
}
pub fn run(cfg: RunConfig) -> Result<()> {
let cfg_parallel = cfg.clone();
let RunConfig {
common,
output,
op,
recipient_pubs,
recipient_privs,
} = cfg;
let explicit_policy = common.policy.clone();
let mut policy_name = explicit_policy
.clone()
.unwrap_or_else(|| consts::DEFAULT_POLICY.to_string());
let fips = common.fips
|| (cfg!(unix)
&& match fs::read_to_string("/proc/sys/crypto/fips_enabled") {
Ok(s) => s.starts_with('1'),
Err(_) => false,
});
if fips {
if let Some(p) = explicit_policy.as_deref()
&& p != "nist"
{
return Err(Error::InvalidArg {
arg: "--policy",
reason: format!("Policy setting of '{p}' conflicts with --fips"),
});
}
policy_name = "nist".to_string();
}
let policy = make_policy(&policy_name);
let mut paops = if let Some(defaults) = common.defaults.as_deref() {
let mut p = ParseOps::new(make_policy(defaults))?;
p.crypto.policy = policy;
p
} else {
ParseOps::new(policy)?
};
if let Some(dir) = common.casdir.clone() {
paops.io.set_local_casdir(dir);
} else if Path::new("cas").is_dir() {
paops.io.set_local_casdir(Path::new("cas").to_path_buf());
} else {
paops.io.set_local_casdir(Path::new(".").to_path_buf());
}
paops.io.verbose = common.verbose && !common.quiet;
paops.io.inline_data = common.inline || common.casdir.is_none();
paops.max_depth = common.max_depth;
let (left, right) = resolve_separators(&common);
paops.separators.left = left;
paops.separators.right = right;
paops.passwords.extend(common.password);
paops.crypto.recipient_pubs = recipient_pubs;
for (i, w) in output.word.iter().enumerate() {
if let Some(priv_pem) = recipient_privs.get(i).or_else(|| recipient_privs.first()) {
paops
.crypto
.recipient_privkeys
.insert(w.clone(), priv_pem.clone());
}
}
if common.pbkdf_disable_cache {
paops.crypto.pbkdf_cache = None;
}
if let Some((enc_opts, op_kind)) = op.as_ref() {
if matches!(op_kind, Operation::Encrypt | Operation::EncryptStore)
&& let Some(p) = common
.policy_file
.as_ref()
.map(|p| cappolicy::CapPolicy::load_file(p))
.transpose()?
{
let held = capability::CapabilitySet::from_paops(&paops);
for w in &output.word {
p.check_word_capability(w, &held)?;
}
}
for w in &output.word {
match op_kind {
Operation::Encrypt => {
paops.transforms.encrypt.insert(w.clone());
}
Operation::Decrypt => {
paops.transforms.decrypt.insert(w.clone());
}
Operation::Store => {
paops.transforms.store.insert(w.clone());
}
Operation::Fetch => {
paops.transforms.fetch.insert(w.clone());
}
Operation::EncryptStore => {
paops.transforms.encrypt.insert(w.clone());
paops.transforms.store.insert(w.clone());
}
}
}
if matches!(op_kind, Operation::Encrypt | Operation::EncryptStore) {
if let Some(alg) = enc_opts.pbkdf.as_deref() {
paops.crypto.pbkdfopts.alg = alg.to_string();
}
if let Some(saltlen) = enc_opts.pbkdf_salt_len {
paops.crypto.pbkdfopts.saltlen = saltlen;
}
if let Some(msec) = enc_opts.pbkdf_msec {
paops.crypto.pbkdfopts.msec = Some(msec);
}
if let Some(raw) = enc_opts.pbkdf_params.as_deref() {
paops.crypto.pbkdfopts.msec = None;
let params: std::collections::BTreeMap<String, usize> = raw
.split(',')
.map(|kv| {
let (k, v) = kv.split_once('=').unwrap_or(("", "0"));
(k.to_string(), v.parse().unwrap_or(0))
})
.collect();
paops.crypto.pbkdfopts.params = Some(params);
}
if let Some(salt_hex) = enc_opts.pbkdf_salt.as_deref() {
paops.crypto.pbkdfopts.salt = Some(hex::decode(salt_hex).map_err(Error::from)?);
}
if let Some(c) = enc_opts.cipher.as_deref() {
paops.crypto.cipheropts.alg = c.to_string();
}
if let Some(iv_hex) = enc_opts.cipher_iv.as_deref() {
paops.crypto.cipheropts.iv = Some(hex::decode(iv_hex).map_err(Error::from)?);
}
}
}
if paops.io.verbose {
eprintln!(
"LEFT_SEP='{}' RIGHT_SEP='{}' casdir = '{}'",
paops.separators.left,
paops.separators.right,
paops.io.casdir.display(),
);
}
let files = pair_inputs_to_outputs(
&output.files,
&output.output,
&output.prefix,
output.output_dir.as_deref(),
);
paops.anchor = build_anchor_config(
common.anchor,
common.signer.as_deref(),
op.as_ref().map(|(_, k)| *k),
&output.word,
)?;
if common.jobs > 1 && files.len() > 1 {
tracing::info!(
files = files.len(),
jobs = common.jobs,
"processing files in parallel"
);
let jobs = common.jobs.min(files.len());
let chunk_size = files.len().div_ceil(jobs);
let policy = &policy_name;
let cfg_ref = &cfg_parallel;
let chunks: Vec<&[(String, String)]> = files.chunks(chunk_size).collect();
std::thread::scope(|s| {
let handles: Vec<_> = chunks
.iter()
.map(|chunk| {
s.spawn(move || -> Result<()> {
let mut local_paops = cfg_ref.build_paops(policy)?;
for (path_in, path_out) in chunk.iter() {
process_one_file(path_in, path_out, &mut local_paops)?;
}
Ok(())
})
})
.collect();
for h in handles {
h.join()
.map_err(|_| Error::Io(std::io::Error::other("worker thread panicked")))??;
}
Ok(())
})
} else {
for (path_in, path_out) in &files {
process_one_file(path_in, path_out, &mut paops)?;
}
Ok(())
}
}
pub(super) fn pair_inputs_to_outputs(
inputs: &[String],
outputs: &[String],
prefix: &str,
output_dir: Option<&Path>,
) -> Vec<(String, String)> {
let mut result = Vec::with_capacity(inputs.len());
let mut out_iter = outputs.iter();
let prefix_is_dir = !prefix.is_empty() && (prefix.ends_with('/') || Path::new(prefix).is_dir());
for input in inputs {
if let Some(output) = out_iter.next() {
result.push((input.clone(), output.clone()));
continue;
}
let output = if input == "-" {
"-".to_string()
} else if let Some(dir) = output_dir {
join_with_basename(dir, input)
} else if prefix_is_dir {
let dir_str = prefix.trim_end_matches('/');
join_with_basename(Path::new(dir_str), input)
} else if prefix.is_empty() {
input.clone()
} else {
format!("{}{}", prefix, input)
};
result.push((input.clone(), output));
}
result
}
fn join_with_basename(dir: &Path, input: &str) -> String {
let base = Path::new(input)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| input.to_string());
dir.join(base).to_string_lossy().into_owned()
}
#[tracing::instrument(skip(paops), fields(path = %path_in))]
fn process_one_file(path_in: &str, path_out: &str, paops: &mut ParseOps) -> Result<()> {
tracing::debug!(path_in, path_out, "processing file");
let reader_in: Box<dyn BufRead> = if path_in == "-" {
Box::new(BufReader::new(std::io::stdin()))
} else {
match std::fs::File::open(path_in) {
Ok(f) => Box::new(BufReader::new(f)),
Err(e) => {
return Err(Error::Io(std::io::Error::other(format!(
"Failed to open {path_in} for reading: {e}"
))));
}
}
};
paops.runtime.fname = if path_in == "-" {
"<stdin>".to_string()
} else {
path_in.to_string()
};
let tree_in = etree::parse(reader_in, paops)
.map_err(|e| Error::Io(std::io::Error::other(format!("{e} in {path_in}, aborting"))))?;
if paops.io.verbose {
eprintln!("Transforming {}", path_in);
}
let mut tree_out = etree::transform(&tree_in, paops)
.map_err(|e| Error::Io(std::io::Error::other(format!("{e} in {path_in}, aborting"))))?;
if paops.anchor.enabled {
let chain_node = build_chain_anchor_node(&tree_out, paops)?;
tree_out.push(chain_node);
}
if paops.io.verbose {
eprintln!("Writing {}", path_out);
}
if paops.io.dry_run {
eprintln!(
"dry-run: {} → {} ({} nodes in, {} nodes out, would write)",
path_in,
path_out,
tree_in.len(),
tree_out.len()
);
return Ok(());
}
let mut writer_out: Box<dyn Write> = if path_out == "-" {
Box::new(BufWriter::new(std::io::stdout()))
} else {
match std::fs::File::create(path_out) {
Ok(f) => Box::new(BufWriter::new(f)),
Err(e) => {
return Err(Error::Io(std::io::Error::other(format!(
"Failed to open {path_out} for writing: {e}"
))));
}
}
};
etree::tree_write(&mut writer_out, &tree_out, paops).map_err(|e| {
Error::Io(std::io::Error::other(format!(
"Write to {path_out} failed: {e}"
)))
})?;
Ok(())
}
fn build_chain_anchor_node(
tree_out: &etree::TextTree,
paops: &mut ParseOps,
) -> Result<etree::TextNode> {
use crate::ledger::{Anchor, PayloadHash, SignerId};
use crate::pki::SigAlgKind;
use std::collections::BTreeMap;
let priv_pem = paops
.anchor
.signer_priv_pem
.clone()
.ok_or_else(|| Error::InvalidArg {
arg: "anchor",
reason: "anchor config missing signer_priv_pem".to_string(),
})?;
let botan_priv = botan::Privkey::load_pem(&priv_pem).map_err(Error::botan)?;
let botan_pub = botan_priv.pubkey().map_err(Error::botan)?;
let pub_pem = botan_pub.pem_encode().map_err(Error::botan)?;
let fp = capability::KeyFp::from_pem(&pub_pem)?;
let blob = etree::tree_to_blob(tree_out, paops)?;
let policy = crate::crypto::CryptoPolicyDefault {};
let payload_hex = crate::crypto::hexdigest("sha3-256", &blob, &policy)?;
let mut payload_arr = [0u8; 32];
payload_arr.copy_from_slice(&hex::decode(payload_hex)?);
let payload_hash = PayloadHash(payload_arr);
let parents = latest_existing_anchors(tree_out, paops)?;
let words_joined = paops.anchor.words.join(",");
let mutations = if words_joined.is_empty() {
paops.anchor.operation.clone()
} else {
format!("{}+{}", paops.anchor.operation, words_joined)
};
let signer = SignerId::new(SigAlgKind::Ed25519, fp);
let anchor = Anchor::builder(signer, payload_hash)
.with_parents(parents)
.with_mutations(mutations)
.build();
let signed = anchor.sign(&priv_pem, &pub_pem, SigAlgKind::Ed25519)?;
let extfields: BTreeMap<String, String> = signed.to_extfields();
Ok(etree::TextNode::Chain { extfields })
}
fn latest_existing_anchors(
tree: &etree::TextTree,
_paops: &ParseOps,
) -> Result<Vec<crate::ledger::AnchorHash>> {
let mut all = Vec::new();
walk_for_chains(tree, &mut all)?;
Ok(all.pop().into_iter().collect())
}