Skip to main content

mnemo/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5/// Mots-clés sensibles ignorés par défaut lors de l'import / de l'ajout.
6const DEFAULT_SENSITIVE: &[&str] = &[
7    "password",
8    "passwd",
9    "token",
10    "secret",
11    "api_key",
12    "bearer",
13    "private_key",
14    "sshpass",
15];
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(default)]
19pub struct Config {
20    /// Commandes contenant un de ces mots-clés sont ignorées.
21    pub sensitive_keywords: Vec<String>,
22    /// Préfixes de commandes à ne jamais enregistrer (ex: "mnemo").
23    pub ignore_prefixes: Vec<String>,
24    /// Nombre maximal de commandes chargées dans la TUI.
25    pub search_limit: usize,
26    /// Options propres à `mnemo stats`.
27    pub stats: StatsConfig,
28    /// Options de maintenance / nettoyage automatique.
29    pub maintenance: MaintenanceConfig,
30}
31
32/// Configuration de la commande `mnemo stats`.
33#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34#[serde(default)]
35pub struct StatsConfig {
36    /// Noms de commandes (normalisés) à exclure du « Top commandes ». Les
37    /// commandes concernées restent en base ; elles sont seulement comptées
38    /// dans « Entrées ignorées ».
39    pub ignored_commands: Vec<String>,
40}
41
42/// Configuration du nettoyage automatique (`mnemo maintenance`).
43///
44/// Désactivé par défaut : mnemo ne supprime jamais de données sans action
45/// explicite de l'utilisateur (`--yes`).
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct MaintenanceConfig {
49    /// Active la possibilité de nettoyage automatique par ancienneté.
50    pub auto_prune_enabled: bool,
51    /// Ancienneté au-delà de laquelle les commandes sont éligibles au nettoyage
52    /// (durée lisible : `180d`, `26w`, `6m`, `1y`).
53    pub auto_prune_after: String,
54    /// Crée systématiquement une sauvegarde avant tout nettoyage automatique.
55    pub auto_backup_before_prune: bool,
56}
57
58impl Default for MaintenanceConfig {
59    fn default() -> Self {
60        Self {
61            auto_prune_enabled: false,
62            auto_prune_after: "180d".to_string(),
63            auto_backup_before_prune: true,
64        }
65    }
66}
67
68/// Gravité d'un problème de configuration relevé par [`Config::validate`].
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum IssueLevel {
71    /// Bloquant : la valeur est inutilisable en l'état.
72    Error,
73    /// Non bloquant : la valeur est inhabituelle mais tolérée.
74    Warning,
75}
76
77/// Problème relevé lors de la validation de la configuration.
78#[derive(Debug, Clone)]
79pub struct ConfigIssue {
80    pub level: IssueLevel,
81    pub message: String,
82}
83
84impl Default for Config {
85    fn default() -> Self {
86        Self {
87            sensitive_keywords: DEFAULT_SENSITIVE.iter().map(|s| s.to_string()).collect(),
88            ignore_prefixes: vec!["mnemo".to_string()],
89            search_limit: 5000,
90            stats: StatsConfig::default(),
91            maintenance: MaintenanceConfig::default(),
92        }
93    }
94}
95
96impl Config {
97    /// Charge la config depuis `~/.config/mnemo/config.toml`, ou les valeurs
98    /// par défaut si le fichier n'existe pas encore.
99    pub fn load() -> Result<Self> {
100        let path = config_path()?;
101        if path.exists() {
102            let raw = std::fs::read_to_string(&path)
103                .with_context(|| format!("lecture de la config {}", path.display()))?;
104            let cfg: Config = toml::from_str(&raw)
105                .with_context(|| format!("parsing TOML de {}", path.display()))?;
106            Ok(cfg)
107        } else {
108            Ok(Config::default())
109        }
110    }
111
112    /// Écrit la config au format TOML, en créant le dossier parent si besoin.
113    pub fn save(&self, path: &Path) -> Result<()> {
114        if let Some(parent) = path.parent() {
115            std::fs::create_dir_all(parent)
116                .with_context(|| format!("création du dossier {}", parent.display()))?;
117            harden_dir(parent);
118        }
119        let raw = toml::to_string_pretty(self)?;
120        std::fs::write(path, raw)
121            .with_context(|| format!("écriture de la config {}", path.display()))?;
122        // La configuration contient des réglages locaux : permissions privées.
123        harden_file(path);
124        Ok(())
125    }
126
127    /// Normalise un nom de commande pour la liste d'exclusion des stats :
128    /// trim + minuscules. Stratégie simple et documentée (comparaison exacte,
129    /// insensible à la casse).
130    pub fn normalize_ignored(name: &str) -> String {
131        name.trim().to_lowercase()
132    }
133
134    /// Ajoute une commande à `stats.ignored_commands` si absente.
135    /// Retourne `true` si la liste a changé, `false` si déjà présente.
136    pub fn add_ignored_command(&mut self, name: &str) -> bool {
137        let normalized = Self::normalize_ignored(name);
138        if self.stats.ignored_commands.contains(&normalized) {
139            return false;
140        }
141        self.stats.ignored_commands.push(normalized);
142        self.stats.ignored_commands.sort();
143        true
144    }
145
146    /// Retire une commande de `stats.ignored_commands` si présente.
147    /// Retourne `true` si la liste a changé, `false` si absente.
148    pub fn remove_ignored_command(&mut self, name: &str) -> bool {
149        let normalized = Self::normalize_ignored(name);
150        let before = self.stats.ignored_commands.len();
151        self.stats.ignored_commands.retain(|c| c != &normalized);
152        self.stats.ignored_commands.len() != before
153    }
154
155    /// Valide les valeurs connues de la configuration. Ne touche pas au disque ;
156    /// renvoie la liste des problèmes détectés (vide = configuration saine).
157    pub fn validate(&self) -> Vec<ConfigIssue> {
158        let mut issues = Vec::new();
159        if self.search_limit == 0 {
160            issues.push(ConfigIssue {
161                level: IssueLevel::Error,
162                message: "search_limit doit être strictement positif".to_string(),
163            });
164        }
165        if crate::prune::parse_duration(&self.maintenance.auto_prune_after).is_err() {
166            issues.push(ConfigIssue {
167                level: IssueLevel::Error,
168                message: format!(
169                    "maintenance.auto_prune_after invalide : {:?} (ex : 180d, 6m, 1y)",
170                    self.maintenance.auto_prune_after
171                ),
172            });
173        }
174        if self.sensitive_keywords.is_empty() {
175            issues.push(ConfigIssue {
176                level: IssueLevel::Warning,
177                message: "sensitive_keywords est vide : aucune commande ne sera filtrée"
178                    .to_string(),
179            });
180        }
181        issues
182    }
183}
184
185/// Clés de premier niveau reconnues dans le fichier TOML (pour signaler les
186/// éventuelles coquilles lors de `mnemo config validate`).
187const KNOWN_TOP_KEYS: &[&str] = &[
188    "sensitive_keywords",
189    "ignore_prefixes",
190    "search_limit",
191    "stats",
192    "maintenance",
193];
194
195/// Charge et valide un fichier de configuration depuis le disque.
196///
197/// Renvoie la config désérialisée et la liste des problèmes (syntaxe TOML mise
198/// à part, qui remonte en `Err`). Détecte aussi les clés de premier niveau
199/// inconnues (probables coquilles) en avertissement.
200pub fn load_and_validate(path: &Path) -> Result<(Config, Vec<ConfigIssue>)> {
201    let raw = std::fs::read_to_string(path)
202        .with_context(|| format!("lecture de la config {}", path.display()))?;
203    let value: toml::Value = toml::from_str(&raw)
204        .with_context(|| format!("syntaxe TOML invalide dans {}", path.display()))?;
205    let cfg: Config = value
206        .clone()
207        .try_into()
208        .with_context(|| format!("structure invalide dans {}", path.display()))?;
209
210    let mut issues = cfg.validate();
211    if let Some(table) = value.as_table() {
212        for key in table.keys() {
213            if !KNOWN_TOP_KEYS.contains(&key.as_str()) {
214                issues.push(ConfigIssue {
215                    level: IssueLevel::Warning,
216                    message: format!("clé inconnue ignorée : {key:?}"),
217                });
218            }
219        }
220    }
221    Ok((cfg, issues))
222}
223
224/// Sauvegarde le fichier de config existant avant écrasement.
225///
226/// Copie `config.toml` vers `config.toml.bak.AAAAMMJJ-HHMMSS`. N'a aucun effet
227/// si le fichier n'existe pas encore. Garantit qu'on n'écrase jamais une
228/// configuration sans en conserver une copie.
229pub fn backup_existing(path: &Path) -> Result<Option<PathBuf>> {
230    if !path.exists() {
231        return Ok(None);
232    }
233    let stamp = crate::db::now_timestamp()
234        .chars()
235        .filter(|c| c.is_ascii_digit())
236        .collect::<String>();
237    let stamp = format!(
238        "{}-{}",
239        &stamp[..8.min(stamp.len())],
240        &stamp[8.min(stamp.len())..]
241    );
242    let backup = path.with_file_name(format!(
243        "{}.bak.{stamp}",
244        path.file_name()
245            .and_then(|n| n.to_str())
246            .unwrap_or("config.toml")
247    ));
248    std::fs::copy(path, &backup)
249        .with_context(|| format!("sauvegarde de {} vers {}", path.display(), backup.display()))?;
250    harden_file(&backup);
251    Ok(Some(backup))
252}
253
254pub fn config_dir() -> Result<PathBuf> {
255    let base = dirs::config_dir().context("dossier de configuration introuvable")?;
256    Ok(base.join("mnemo"))
257}
258
259pub fn config_path() -> Result<PathBuf> {
260    Ok(config_dir()?.join("config.toml"))
261}
262
263pub fn data_dir() -> Result<PathBuf> {
264    let base = dirs::data_dir().context("dossier de données introuvable")?;
265    Ok(base.join("mnemo"))
266}
267
268pub fn db_path() -> Result<PathBuf> {
269    Ok(data_dir()?.join("history.db"))
270}
271
272/// Mode Unix privé attendu pour les fichiers sensibles (config, base, archives).
273pub const SECRET_FILE_MODE: u32 = 0o600;
274/// Mode Unix privé attendu pour les dossiers gérés par mnemo.
275pub const SECRET_DIR_MODE: u32 = 0o700;
276
277/// Resserre les permissions d'un fichier sensible à `600` (lecture/écriture
278/// propriétaire uniquement).
279///
280/// N'altère jamais le contenu du fichier. Sur les plateformes non-Unix, ne fait
281/// rien et ne renvoie pas d'erreur. L'opération est idempotente : la permission
282/// n'est réécrite que si elle diffère déjà de `600`.
283pub fn harden_file(path: &Path) {
284    #[cfg(unix)]
285    {
286        use std::os::unix::fs::PermissionsExt;
287        if let Ok(meta) = std::fs::metadata(path) {
288            let mut perms = meta.permissions();
289            if perms.mode() & 0o777 != SECRET_FILE_MODE {
290                perms.set_mode(SECRET_FILE_MODE);
291                let _ = std::fs::set_permissions(path, perms);
292            }
293        }
294    }
295    #[cfg(not(unix))]
296    {
297        let _ = path;
298    }
299}
300
301/// Resserre les permissions d'un dossier géré par mnemo à `700`.
302///
303/// Comme [`harden_file`], l'opération est best-effort, idempotente et sans effet
304/// sur les plateformes non-Unix.
305pub fn harden_dir(path: &Path) {
306    #[cfg(unix)]
307    {
308        use std::os::unix::fs::PermissionsExt;
309        if let Ok(meta) = std::fs::metadata(path) {
310            if meta.is_dir() {
311                let mut perms = meta.permissions();
312                if perms.mode() & 0o777 != SECRET_DIR_MODE {
313                    perms.set_mode(SECRET_DIR_MODE);
314                    let _ = std::fs::set_permissions(path, perms);
315                }
316            }
317        }
318    }
319    #[cfg(not(unix))]
320    {
321        let _ = path;
322    }
323}