use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const DEFAULT_SENSITIVE: &[&str] = &[
"password",
"passwd",
"token",
"secret",
"api_key",
"bearer",
"private_key",
"sshpass",
];
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub sensitive_keywords: Vec<String>,
pub ignore_prefixes: Vec<String>,
pub search_limit: usize,
pub stats: StatsConfig,
pub maintenance: MaintenanceConfig,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct StatsConfig {
pub ignored_commands: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MaintenanceConfig {
pub auto_prune_enabled: bool,
pub auto_prune_after: String,
pub auto_backup_before_prune: bool,
}
impl Default for MaintenanceConfig {
fn default() -> Self {
Self {
auto_prune_enabled: false,
auto_prune_after: "180d".to_string(),
auto_backup_before_prune: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueLevel {
Error,
Warning,
}
#[derive(Debug, Clone)]
pub struct ConfigIssue {
pub level: IssueLevel,
pub message: String,
}
impl Default for Config {
fn default() -> Self {
Self {
sensitive_keywords: DEFAULT_SENSITIVE.iter().map(|s| s.to_string()).collect(),
ignore_prefixes: vec!["mnemo".to_string()],
search_limit: 5000,
stats: StatsConfig::default(),
maintenance: MaintenanceConfig::default(),
}
}
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path()?;
if path.exists() {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("lecture de la config {}", path.display()))?;
let cfg: Config = toml::from_str(&raw)
.with_context(|| format!("parsing TOML de {}", path.display()))?;
Ok(cfg)
} else {
Ok(Config::default())
}
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("création du dossier {}", parent.display()))?;
harden_dir(parent);
}
let raw = toml::to_string_pretty(self)?;
std::fs::write(path, raw)
.with_context(|| format!("écriture de la config {}", path.display()))?;
harden_file(path);
Ok(())
}
pub fn normalize_ignored(name: &str) -> String {
name.trim().to_lowercase()
}
pub fn add_ignored_command(&mut self, name: &str) -> bool {
let normalized = Self::normalize_ignored(name);
if self.stats.ignored_commands.contains(&normalized) {
return false;
}
self.stats.ignored_commands.push(normalized);
self.stats.ignored_commands.sort();
true
}
pub fn remove_ignored_command(&mut self, name: &str) -> bool {
let normalized = Self::normalize_ignored(name);
let before = self.stats.ignored_commands.len();
self.stats.ignored_commands.retain(|c| c != &normalized);
self.stats.ignored_commands.len() != before
}
pub fn validate(&self) -> Vec<ConfigIssue> {
let mut issues = Vec::new();
if self.search_limit == 0 {
issues.push(ConfigIssue {
level: IssueLevel::Error,
message: "search_limit doit être strictement positif".to_string(),
});
}
if crate::prune::parse_duration(&self.maintenance.auto_prune_after).is_err() {
issues.push(ConfigIssue {
level: IssueLevel::Error,
message: format!(
"maintenance.auto_prune_after invalide : {:?} (ex : 180d, 6m, 1y)",
self.maintenance.auto_prune_after
),
});
}
if self.sensitive_keywords.is_empty() {
issues.push(ConfigIssue {
level: IssueLevel::Warning,
message: "sensitive_keywords est vide : aucune commande ne sera filtrée"
.to_string(),
});
}
issues
}
}
const KNOWN_TOP_KEYS: &[&str] = &[
"sensitive_keywords",
"ignore_prefixes",
"search_limit",
"stats",
"maintenance",
];
pub fn load_and_validate(path: &Path) -> Result<(Config, Vec<ConfigIssue>)> {
let raw = std::fs::read_to_string(path)
.with_context(|| format!("lecture de la config {}", path.display()))?;
let value: toml::Value = toml::from_str(&raw)
.with_context(|| format!("syntaxe TOML invalide dans {}", path.display()))?;
let cfg: Config = value
.clone()
.try_into()
.with_context(|| format!("structure invalide dans {}", path.display()))?;
let mut issues = cfg.validate();
if let Some(table) = value.as_table() {
for key in table.keys() {
if !KNOWN_TOP_KEYS.contains(&key.as_str()) {
issues.push(ConfigIssue {
level: IssueLevel::Warning,
message: format!("clé inconnue ignorée : {key:?}"),
});
}
}
}
Ok((cfg, issues))
}
pub fn backup_existing(path: &Path) -> Result<Option<PathBuf>> {
if !path.exists() {
return Ok(None);
}
let stamp = crate::db::now_timestamp()
.chars()
.filter(|c| c.is_ascii_digit())
.collect::<String>();
let stamp = format!(
"{}-{}",
&stamp[..8.min(stamp.len())],
&stamp[8.min(stamp.len())..]
);
let backup = path.with_file_name(format!(
"{}.bak.{stamp}",
path.file_name()
.and_then(|n| n.to_str())
.unwrap_or("config.toml")
));
std::fs::copy(path, &backup)
.with_context(|| format!("sauvegarde de {} vers {}", path.display(), backup.display()))?;
harden_file(&backup);
Ok(Some(backup))
}
pub fn config_dir() -> Result<PathBuf> {
let base = dirs::config_dir().context("dossier de configuration introuvable")?;
Ok(base.join("mnemo"))
}
pub fn config_path() -> Result<PathBuf> {
Ok(config_dir()?.join("config.toml"))
}
pub fn data_dir() -> Result<PathBuf> {
let base = dirs::data_dir().context("dossier de données introuvable")?;
Ok(base.join("mnemo"))
}
pub fn db_path() -> Result<PathBuf> {
Ok(data_dir()?.join("history.db"))
}
pub const SECRET_FILE_MODE: u32 = 0o600;
pub const SECRET_DIR_MODE: u32 = 0o700;
pub fn harden_file(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
let mut perms = meta.permissions();
if perms.mode() & 0o777 != SECRET_FILE_MODE {
perms.set_mode(SECRET_FILE_MODE);
let _ = std::fs::set_permissions(path, perms);
}
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}
pub fn harden_dir(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = std::fs::metadata(path) {
if meta.is_dir() {
let mut perms = meta.permissions();
if perms.mode() & 0o777 != SECRET_DIR_MODE {
perms.set_mode(SECRET_DIR_MODE);
let _ = std::fs::set_permissions(path, perms);
}
}
}
}
#[cfg(not(unix))]
{
let _ = path;
}
}