1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::path::{Path, PathBuf};
4
5const 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 pub sensitive_keywords: Vec<String>,
22 pub ignore_prefixes: Vec<String>,
24 pub search_limit: usize,
26 pub stats: StatsConfig,
28 pub maintenance: MaintenanceConfig,
30}
31
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
34#[serde(default)]
35pub struct StatsConfig {
36 pub ignored_commands: Vec<String>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct MaintenanceConfig {
49 pub auto_prune_enabled: bool,
51 pub auto_prune_after: String,
54 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
70pub enum IssueLevel {
71 Error,
73 Warning,
75}
76
77#[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 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 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 harden_file(path);
124 Ok(())
125 }
126
127 pub fn normalize_ignored(name: &str) -> String {
131 name.trim().to_lowercase()
132 }
133
134 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 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 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
185const KNOWN_TOP_KEYS: &[&str] = &[
188 "sensitive_keywords",
189 "ignore_prefixes",
190 "search_limit",
191 "stats",
192 "maintenance",
193];
194
195pub 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
224pub 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
272pub const SECRET_FILE_MODE: u32 = 0o600;
274pub const SECRET_DIR_MODE: u32 = 0o700;
276
277pub 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
301pub 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}