use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(rename = "override")]
pub project_override: bool,
#[serde(rename = "whitelist")]
pub disabled_patterns: Vec<String>,
#[serde(rename = "allow")]
pub allowed_keys: Vec<String>,
pub ignore_internal_tools: bool,
pub version: u32,
pub learned: Vec<LearnedSecret>,
pub heuristic: HeuristicConfig,
pub pii: PiiConfig,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct LearnedSecret {
pub name: String,
pub sha256: String,
#[serde(rename = "len")]
pub byte_len: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub shape: Option<String>,
}
pub const DEFAULT_MIN_LENGTH: usize = 16;
pub const DEFAULT_MAX_LENGTH: usize = 128;
pub const DEFAULT_MIN_CHAR_CLASSES: usize = 3;
pub const DEFAULT_MIN_ENTROPY: f64 = 3.5;
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct HeuristicConfig {
pub enabled: bool,
#[serde(default = "default_min_length")]
pub min_length: usize,
#[serde(default = "default_max_length")]
pub max_length: usize,
#[serde(default = "default_min_char_classes")]
pub min_char_classes: usize,
#[serde(default = "default_min_entropy")]
pub min_entropy: f64,
}
impl Default for HeuristicConfig {
fn default() -> Self {
HeuristicConfig {
enabled: false,
min_length: DEFAULT_MIN_LENGTH,
max_length: DEFAULT_MAX_LENGTH,
min_char_classes: DEFAULT_MIN_CHAR_CLASSES,
min_entropy: DEFAULT_MIN_ENTROPY,
}
}
}
fn default_min_length() -> usize {
DEFAULT_MIN_LENGTH
}
fn default_max_length() -> usize {
DEFAULT_MAX_LENGTH
}
fn default_min_char_classes() -> usize {
DEFAULT_MIN_CHAR_CLASSES
}
fn default_min_entropy() -> f64 {
DEFAULT_MIN_ENTROPY
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct PiiConfig {
pub email: bool,
pub phone: bool,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(default)]
pub struct CustomPattern {
pub name: String,
pub regex: String,
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub struct EngineConfig {
#[serde(rename = "override")]
pub project_override: bool,
pub allow_values: Vec<String>,
pub patterns: Vec<CustomPattern>,
}
#[derive(Debug)]
pub struct ConfigError {
pub path: PathBuf,
pub message: String,
}
impl fmt::Display for ConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
write!(formatter, "{}: {}", self.path.display(), self.message)
}
}
impl From<ConfigError> for String {
fn from(error: ConfigError) -> String {
error.to_string()
}
}
fn read_yaml<T: serde::de::DeserializeOwned>(path: &Path) -> Result<Option<T>, ConfigError> {
let config_error = |message: String| ConfigError {
path: path.to_path_buf(),
message,
};
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(config_error(e.to_string())),
};
serde_yaml::from_str(&text)
.map(Some)
.map_err(|e| config_error(e.to_string()))
}
pub fn home_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.filter(|home| !home.is_empty())
.map(PathBuf::from)
}
pub fn global_config_path(home: &Path) -> PathBuf {
home.join(".config/redacted/config.yaml")
}
fn global_and_project_paths(
home: Option<&Path>,
cwd: Option<&Path>,
global_file: &str,
project_file: &str,
) -> (Option<PathBuf>, Option<PathBuf>) {
let global_path = home.map(|home| home.join(".config/redacted").join(global_file));
let project_path = cwd.map(|cwd| cwd.join(project_file));
(global_path, project_path)
}
pub fn load(home: Option<&Path>, cwd: Option<&Path>) -> Result<Config, ConfigError> {
let (global_path, project_path) =
global_and_project_paths(home, cwd, "config.yaml", ".redacted.yaml");
let global: Option<Config> = global_path.as_deref().map_or(Ok(None), read_yaml)?;
let project: Option<Config> = project_path.as_deref().map_or(Ok(None), read_yaml)?;
let (version, learned, heuristic, pii) = match &global {
Some(global) => (
global.version,
global.learned.clone(),
global.heuristic.clone(),
global.pii.clone(),
),
None => (
0,
Vec::new(),
HeuristicConfig::default(),
PiiConfig::default(),
),
};
let merged = match (global, project) {
(None, None) => Config::default(),
(Some(global), None) => global,
(None, Some(project)) => project,
(Some(_), Some(project)) if project.project_override => project,
(Some(global), Some(project)) => Config {
disabled_patterns: [global.disabled_patterns, project.disabled_patterns].concat(),
allowed_keys: [global.allowed_keys, project.allowed_keys].concat(),
ignore_internal_tools: global.ignore_internal_tools || project.ignore_internal_tools,
..Default::default()
},
};
Ok(Config {
version,
learned,
heuristic,
pii,
..merged
})
}
pub const VENDOR_ONLY_NOTICE: &str = "vendor signatures only, no learned secrets, heuristic off; run `redacted init --env PATH` to learn yours";
pub fn is_vendor_only(config: &Config) -> bool {
config.learned.is_empty() && !config.heuristic.enabled
}
pub fn project_learned_count(cwd: &Path) -> usize {
let project: Option<Config> = read_yaml(&cwd.join(".redacted.yaml")).ok().flatten();
project.map_or(0, |project| project.learned.len())
}
pub fn load_engine(home: Option<&Path>, cwd: Option<&Path>) -> Result<EngineConfig, ConfigError> {
let (global_path, project_path) =
global_and_project_paths(home, cwd, "engine.yml", ".redacted.engine.yml");
let global: Option<EngineConfig> = global_path.as_deref().map_or(Ok(None), read_yaml)?;
let project: Option<EngineConfig> = project_path.as_deref().map_or(Ok(None), read_yaml)?;
let merged = match (global, project) {
(None, None) => EngineConfig::default(),
(Some(global), None) => global,
(None, Some(project)) => project,
(Some(_), Some(project)) if project.project_override => project,
(Some(global), Some(project)) => EngineConfig {
allow_values: [global.allow_values, project.allow_values].concat(),
patterns: [global.patterns, project.patterns].concat(),
..global
},
};
Ok(merged)
}
#[cfg(test)]
mod tests;