use crate::cli::{CommonArgs, EncryptOpts, OutputArgs};
use crate::crypto::CryptoPolicy;
use crate::error::{Error, Result};
#[derive(Debug, PartialEq, Eq)]
pub enum ConfigIssue {
FipsPolicyConflict { explicit: String },
SignerWithoutAnchor,
JobsZero,
JobsExceedsCpus { requested: usize, available: usize },
OutputDirWithStdin,
PbkdfMsecBelowFloor { requested: u32, floor: u32 },
RecoveryWithDet { alg: String },
}
impl ConfigIssue {
pub fn is_error(&self) -> bool {
matches!(
self,
ConfigIssue::FipsPolicyConflict { .. }
| ConfigIssue::JobsZero
| ConfigIssue::PbkdfMsecBelowFloor { .. }
| ConfigIssue::RecoveryWithDet { .. }
)
}
pub fn describe(&self) -> String {
match self {
ConfigIssue::FipsPolicyConflict { explicit } => {
format!("error: --fips forces --policy=nist but --policy={explicit} was set")
}
ConfigIssue::SignerWithoutAnchor => {
"warning: --signer is set but neither --anchor nor --audit-log is; the signer key will not be used"
.to_string()
}
ConfigIssue::JobsZero => "error: --jobs must be at least 1".to_string(),
ConfigIssue::JobsExceedsCpus {
requested,
available,
} => {
format!(
"warning: --jobs {requested} exceeds available CPUs ({available}); scheduling overhead may dominate"
)
}
ConfigIssue::OutputDirWithStdin => {
"warning: --output-dir has no effect when input is stdin ('-'); stdin always writes to stdout"
.to_string()
}
ConfigIssue::PbkdfMsecBelowFloor { requested, floor } => {
format!(
"error: --pbkdf-msec {requested} is below the policy minimum of {floor} ms"
)
}
ConfigIssue::RecoveryWithDet { alg } => {
format!(
"error: --recovery-key is incompatible with {alg}: escrow mode uses a fresh \
random key per encryption, so the deterministic (same-input → same-output) \
contract cannot hold; use a non-det cipher such as aes-256-siv"
)
}
}
}
fn arg(&self) -> &'static str {
match self {
ConfigIssue::FipsPolicyConflict { .. } => "--policy",
ConfigIssue::SignerWithoutAnchor => "--signer",
ConfigIssue::JobsZero | ConfigIssue::JobsExceedsCpus { .. } => "--jobs",
ConfigIssue::OutputDirWithStdin => "--output-dir",
ConfigIssue::PbkdfMsecBelowFloor { .. } => "--pbkdf-msec",
ConfigIssue::RecoveryWithDet { .. } => "--recovery-key",
}
}
}
pub fn collect(common: &CommonArgs) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
if common.fips
&& let Some(p) = common.policy.as_deref()
&& p != "nist"
{
issues.push(ConfigIssue::FipsPolicyConflict {
explicit: p.to_string(),
});
}
if common.signer.is_some() && !common.anchor && common.audit_log.is_none() {
issues.push(ConfigIssue::SignerWithoutAnchor);
}
if common.jobs == 0 {
issues.push(ConfigIssue::JobsZero);
} else if let Some(available) = available_cpus()
&& common.jobs > available
{
issues.push(ConfigIssue::JobsExceedsCpus {
requested: common.jobs,
available,
});
}
issues
}
pub fn collect_output(output: &OutputArgs) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
if output.output_dir.is_some() && output.files.iter().any(|f| f == "-") {
issues.push(ConfigIssue::OutputDirWithStdin);
}
issues
}
pub fn collect_encrypt(enc: &EncryptOpts, policy: &dyn CryptoPolicy) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
let floor = policy.min_pbkdf_millis();
if floor > 0
&& enc.pbkdf_params.is_none()
&& let Some(requested) = enc.pbkdf_msec
&& requested < floor
{
issues.push(ConfigIssue::PbkdfMsecBelowFloor { requested, floor });
}
if !enc.recovery_key.is_empty() {
let alg = enc
.cipher
.clone()
.unwrap_or_else(|| policy.default_cipher_alg());
if alg.ends_with("-det") {
issues.push(ConfigIssue::RecoveryWithDet { alg });
}
}
issues
}
pub fn report(issues: &[ConfigIssue]) -> Result<()> {
for issue in issues {
if !issue.is_error() {
eprintln!("{}", issue.describe());
}
}
let errors: Vec<&ConfigIssue> = issues.iter().filter(|i| i.is_error()).collect();
if errors.is_empty() {
return Ok(());
}
let arg = errors[0].arg();
let reason = errors
.iter()
.map(|e| e.describe())
.collect::<Vec<_>>()
.join("\n ");
Err(Error::InvalidArg { arg, reason })
}
fn available_cpus() -> Option<usize> {
std::thread::available_parallelism().ok().map(|n| n.get())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
fn base_args() -> CommonArgs {
CommonArgs::for_filter(None)
}
#[test]
fn clean_config_collects_no_issues() {
let c = base_args();
assert!(collect(&c).is_empty());
}
#[test]
fn fips_with_explicit_default_policy_errors() {
let mut c = base_args();
c.fips = true;
c.policy = Some("default".to_string());
let issues = collect(&c);
assert_eq!(
issues,
vec![ConfigIssue::FipsPolicyConflict {
explicit: "default".to_string()
}]
);
assert!(issues[0].is_error());
}
#[test]
fn fips_with_explicit_nist_policy_ok() {
let mut c = base_args();
c.fips = true;
c.policy = Some("nist".to_string());
assert!(collect(&c).is_empty());
}
#[test]
fn fips_with_no_explicit_policy_ok() {
let mut c = base_args();
c.fips = true;
assert!(
collect(&c).is_empty(),
"fips alone is fine; policy defaults to nist later"
);
}
#[test]
fn signer_without_anchor_warns() {
let mut c = base_args();
c.signer = Some(Path::new("k.pem").to_path_buf());
let issues = collect(&c);
assert_eq!(issues, vec![ConfigIssue::SignerWithoutAnchor]);
assert!(!issues[0].is_error());
assert!(report(&issues).is_ok());
}
#[test]
fn signer_with_anchor_ok() {
let mut c = base_args();
c.signer = Some(Path::new("k.pem").to_path_buf());
c.anchor = true;
assert!(collect(&c).is_empty());
}
#[test]
fn jobs_zero_errors() {
let mut c = base_args();
c.jobs = 0;
let issues = collect(&c);
assert_eq!(issues, vec![ConfigIssue::JobsZero]);
assert!(issues[0].is_error());
let err = report(&issues).unwrap_err();
match err {
Error::InvalidArg { arg, reason } => {
assert_eq!(arg, "--jobs");
assert!(reason.contains("at least 1"), "got: {reason}");
}
_ => panic!("expected InvalidArg, got {err:?}"),
}
}
#[test]
fn multiple_errors_listed_in_one_message() {
let mut c = base_args();
c.fips = true;
c.policy = Some("default".to_string());
c.jobs = 0;
let issues = collect(&c);
assert_eq!(issues.len(), 2, "both errors collected, not just the first");
let err = report(&issues).unwrap_err();
match err {
Error::InvalidArg { reason, .. } => {
assert!(reason.contains("--policy"), "missing policy in: {reason}");
assert!(reason.contains("--jobs"), "missing jobs in: {reason}");
}
_ => panic!("expected InvalidArg, got {err:?}"),
}
}
#[test]
fn jobs_above_available_warns() {
let mut c = base_args();
c.jobs = 65535;
let issues = collect(&c);
assert!(
issues
.iter()
.any(|i| matches!(i, ConfigIssue::JobsExceedsCpus { .. })),
"expected JobsExceedsCpus warning, got {issues:?}"
);
assert!(report(&issues).is_ok());
}
#[test]
fn describe_prefixes_match_severity() {
assert!(ConfigIssue::JobsZero.describe().starts_with("error:"));
assert!(
ConfigIssue::SignerWithoutAnchor
.describe()
.starts_with("warning:")
);
assert!(
ConfigIssue::JobsExceedsCpus {
requested: 99,
available: 1
}
.describe()
.starts_with("warning:")
);
assert!(
ConfigIssue::OutputDirWithStdin
.describe()
.starts_with("warning:")
);
assert!(
ConfigIssue::PbkdfMsecBelowFloor {
requested: 10,
floor: 100
}
.describe()
.starts_with("error:")
);
}
fn base_output(files: &[&str]) -> OutputArgs {
OutputArgs {
word: Vec::new(),
output: Vec::new(),
prefix: String::new(),
output_dir: None,
files: files.iter().map(|f| f.to_string()).collect(),
}
}
#[test]
fn output_dir_with_stdin_warns() {
let mut o = base_output(&["-"]);
o.output_dir = Some(Path::new("out").to_path_buf());
assert_eq!(collect_output(&o), vec![ConfigIssue::OutputDirWithStdin]);
}
#[test]
fn output_dir_with_mixed_stdin_warns() {
let mut o = base_output(&["a.ept", "-", "b.ept"]);
o.output_dir = Some(Path::new("out").to_path_buf());
assert_eq!(collect_output(&o), vec![ConfigIssue::OutputDirWithStdin]);
}
#[test]
fn output_dir_with_files_only_is_clean() {
let mut o = base_output(&["a.ept", "b.ept"]);
o.output_dir = Some(Path::new("out").to_path_buf());
assert!(collect_output(&o).is_empty());
}
#[test]
fn stdin_without_output_dir_is_clean() {
assert!(collect_output(&base_output(&["-"])).is_empty());
}
#[test]
fn nist_rejects_msec_below_floor() {
let enc = EncryptOpts {
pbkdf_msec: Some(10),
..EncryptOpts::default()
};
let nist = crate::policy::nist::CryptoPolicyNIST {};
let issues = collect_encrypt(&enc, &nist);
assert_eq!(
issues,
vec![ConfigIssue::PbkdfMsecBelowFloor {
requested: 10,
floor: nist.min_pbkdf_millis()
}]
);
assert!(issues[0].is_error());
}
#[test]
fn nist_accepts_msec_at_floor() {
let nist = crate::policy::nist::CryptoPolicyNIST {};
let enc = EncryptOpts {
pbkdf_msec: Some(nist.min_pbkdf_millis()),
..EncryptOpts::default()
};
assert!(collect_encrypt(&enc, &nist).is_empty());
}
#[test]
fn default_policy_has_no_floor() {
let default = crate::policy::default::CryptoPolicyDefault {};
assert_eq!(default.min_pbkdf_millis(), 0);
let enc = EncryptOpts {
pbkdf_msec: Some(1),
..EncryptOpts::default()
};
assert!(collect_encrypt(&enc, &default).is_empty());
}
#[test]
fn manual_params_exempt_from_msec_floor() {
let nist = crate::policy::nist::CryptoPolicyNIST {};
let enc = EncryptOpts {
pbkdf_msec: Some(1),
pbkdf_params: Some("i=1000,r=8,p=1".to_string()),
..EncryptOpts::default()
};
assert!(collect_encrypt(&enc, &nist).is_empty());
}
#[test]
fn recovery_key_with_det_cipher_errors() {
let enc = EncryptOpts {
cipher: Some("aes-256-gcm-det".to_string()),
recovery_key: vec![Path::new("r.pem").to_path_buf()],
..EncryptOpts::default()
};
let default = crate::policy::default::CryptoPolicyDefault {};
let issues = collect_encrypt(&enc, &default);
assert!(matches!(issues[0], ConfigIssue::RecoveryWithDet { .. }));
assert!(issues[0].is_error());
}
#[test]
fn recovery_key_with_explicit_non_det_cipher_ok() {
let enc = EncryptOpts {
cipher: Some("aes-256-siv".to_string()),
recovery_key: vec![Path::new("r.pem").to_path_buf()],
..EncryptOpts::default()
};
let default = crate::policy::default::CryptoPolicyDefault {};
assert!(collect_encrypt(&enc, &default).is_empty());
}
#[test]
fn recovery_key_with_default_policy_cipher_errors() {
let enc = EncryptOpts {
recovery_key: vec![Path::new("r.pem").to_path_buf()],
..EncryptOpts::default()
};
let default = crate::policy::default::CryptoPolicyDefault {};
let issues = collect_encrypt(&enc, &default);
assert!(matches!(issues[0], ConfigIssue::RecoveryWithDet { .. }));
}
#[test]
fn recovery_key_under_nist_default_cipher_ok() {
let enc = EncryptOpts {
recovery_key: vec![Path::new("r.pem").to_path_buf()],
..EncryptOpts::default()
};
let nist = crate::policy::nist::CryptoPolicyNIST {};
assert!(collect_encrypt(&enc, &nist).is_empty());
}
#[test]
fn unset_msec_never_flags() {
let nist = crate::policy::nist::CryptoPolicyNIST {};
assert!(
collect_encrypt(&EncryptOpts::default(), &nist).is_empty(),
"no explicit --pbkdf-msec means the policy default applies"
);
}
}