Skip to main content

mnemo/
backup.rs

1//! Sauvegarde et restauration locales (`mnemo backup` / `mnemo restore`).
2//!
3//! Une sauvegarde est une archive `.tar.gz` autonome contenant :
4//! - `history.db` : la base SQLite complète ;
5//! - `config.toml` : la configuration (si présente) ;
6//! - `metadata.json` : des métadonnées de traçabilité (version, date, tailles…).
7//!
8//! Sécurité : la restauration valide systématiquement l'archive (DB ouvrable,
9//! table `commands`, version de schéma) et crée automatiquement une sauvegarde
10//! de l'état courant avant tout remplacement. `--dry-run` ne modifie rien et
11//! sans `--yes` une confirmation interactive est demandée.
12
13use anyhow::{bail, Context, Result};
14use flate2::read::GzDecoder;
15use flate2::write::GzEncoder;
16use flate2::Compression;
17use serde::{Deserialize, Serialize};
18use std::fs::{self, File};
19use std::path::{Path, PathBuf};
20use std::time::{SystemTime, UNIX_EPOCH};
21
22use crate::{config, confirm, db, migrations};
23/// Métadonnées embarquées dans `metadata.json`.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct BackupMetadata {
26    pub mnemo_version: String,
27    pub created_at: String,
28    pub db_path: String,
29    pub config_path: String,
30    pub db_size_bytes: u64,
31    pub command_count: i64,
32    pub schema_version: i64,
33}
34
35/// Résultat d'une création de sauvegarde.
36#[derive(Debug, Clone)]
37pub struct BackupInfo {
38    pub path: PathBuf,
39    pub metadata: BackupMetadata,
40}
41
42/// Secondes Unix + horodatage formaté `YYYY-MM-DD HH:MM:SS`.
43fn timestamp_parts() -> (u64, String) {
44    let secs = SystemTime::now()
45        .duration_since(UNIX_EPOCH)
46        .map(|d| d.as_secs())
47        .unwrap_or(0);
48    (secs, db::format_timestamp(secs))
49}
50
51/// `"2026-06-14 12:34:56"` -> `"2026-06-14T12:34:56Z"` (ISO 8601 UTC).
52fn iso_from(formatted: &str) -> String {
53    format!("{}Z", formatted.replacen(' ', "T", 1))
54}
55
56/// `"2026-06-14 12:34:56"` -> `"mnemo-backup-20260614-123456.tar.gz"`.
57fn archive_filename(formatted: &str) -> String {
58    let digits: String = formatted.chars().filter(|c| c.is_ascii_digit()).collect();
59    let (date, time) = digits.split_at(8.min(digits.len()));
60    format!("mnemo-backup-{date}-{time}.tar.gz")
61}
62
63/// Garantit un chemin d'archive non existant : si une sauvegarde a déjà été
64/// créée dans la même seconde, on suffixe `-1`, `-2`… pour ne jamais écraser.
65fn unique_path(path: PathBuf) -> PathBuf {
66    if !path.exists() {
67        return path;
68    }
69    let parent = path.parent().map(Path::to_path_buf).unwrap_or_default();
70    let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
71    let stem = name.strip_suffix(".tar.gz").unwrap_or(name);
72    for i in 1.. {
73        let candidate = parent.join(format!("{stem}-{i}.tar.gz"));
74        if !candidate.exists() {
75            return candidate;
76        }
77    }
78    path
79}
80
81/// Crée une sauvegarde complète dans `dest_dir` (ou le dossier par défaut
82/// `~/.local/share/mnemo/backups/`). Réutilisée comme sauvegarde automatique
83/// avant les opérations destructives.
84pub fn create_backup(dest_dir: Option<&Path>) -> Result<BackupInfo> {
85    let db_path = config::db_path()?;
86    let config_path = config::config_path()?;
87
88    let dest = match dest_dir {
89        Some(d) => d.to_path_buf(),
90        None => config::data_dir()?.join("backups"),
91    };
92    fs::create_dir_all(&dest)
93        .with_context(|| format!("création du dossier de sauvegarde {}", dest.display()))?;
94    config::harden_dir(&dest);
95
96    let (secs, formatted) = timestamp_parts();
97
98    // Métadonnées : on s'assure que la base existe et est migrée, puis on lit
99    // ses caractéristiques sans la modifier davantage.
100    let (command_count, schema_version) = {
101        let conn = db::open(&db_path)?;
102        let count = db::count(&conn)?;
103        let ver = migrations::schema_version(&conn)?;
104        (count, ver)
105    };
106    let db_size_bytes = fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
107
108    let metadata = BackupMetadata {
109        mnemo_version: env!("CARGO_PKG_VERSION").to_string(),
110        created_at: iso_from(&formatted),
111        db_path: db_path.display().to_string(),
112        config_path: config_path.display().to_string(),
113        db_size_bytes,
114        command_count,
115        schema_version,
116    };
117
118    let archive_path = dest.join(archive_filename(&formatted));
119    let archive_path = unique_path(archive_path);
120    write_archive(&archive_path, &db_path, &config_path, &metadata, secs)?;
121    // L'archive contient l'historique et la configuration : permissions privées.
122    config::harden_file(&archive_path);
123    Ok(BackupInfo {
124        path: archive_path,
125        metadata,
126    })
127}
128
129/// Dossier standard des sauvegardes (`~/.local/share/mnemo/backups`).
130pub fn backups_dir() -> Result<PathBuf> {
131    Ok(config::data_dir()?.join("backups"))
132}
133
134/// Liste les archives de sauvegarde (`*.tar.gz`) présentes dans `dir`.
135///
136/// Renvoie un vecteur vide si le dossier est absent ou illisible (jamais
137/// d'erreur : utilisé par des contrôles best-effort).
138pub fn list_archives(dir: &Path) -> Vec<PathBuf> {
139    let mut archives = Vec::new();
140    if let Ok(entries) = fs::read_dir(dir) {
141        for entry in entries.flatten() {
142            let path = entry.path();
143            let is_archive = path.is_file()
144                && path
145                    .file_name()
146                    .and_then(|n| n.to_str())
147                    .map(|n| n.ends_with(".tar.gz"))
148                    .unwrap_or(false);
149            if is_archive {
150                archives.push(path);
151            }
152        }
153    }
154    archives
155}
156
157/// Écrit l'archive `.tar.gz` sur disque.
158fn write_archive(
159    archive_path: &Path,
160    db_path: &Path,
161    config_path: &Path,
162    metadata: &BackupMetadata,
163    mtime: u64,
164) -> Result<()> {
165    let file = File::create(archive_path)
166        .with_context(|| format!("création de l'archive {}", archive_path.display()))?;
167    let enc = GzEncoder::new(file, Compression::default());
168    let mut builder = tar::Builder::new(enc);
169
170    builder
171        .append_path_with_name(db_path, "history.db")
172        .context("ajout de history.db à l'archive")?;
173
174    if config_path.exists() {
175        builder
176            .append_path_with_name(config_path, "config.toml")
177            .context("ajout de config.toml à l'archive")?;
178    }
179
180    let meta_json = serde_json::to_vec_pretty(metadata)?;
181    let mut header = tar::Header::new_gnu();
182    header.set_size(meta_json.len() as u64);
183    header.set_mode(0o600);
184    header.set_mtime(mtime);
185    header.set_cksum();
186    builder
187        .append_data(&mut header, "metadata.json", meta_json.as_slice())
188        .context("ajout de metadata.json à l'archive")?;
189
190    let enc = builder.into_inner().context("finalisation du tar")?;
191    enc.finish()
192        .context("finalisation de la compression gzip")?;
193    Ok(())
194}
195
196/// Point d'entrée de `mnemo backup`.
197pub fn run(output: Option<PathBuf>, json: bool) -> Result<()> {
198    let info = create_backup(output.as_deref())?;
199
200    if json {
201        let value = serde_json::json!({
202            "backup_path": info.path.display().to_string(),
203            "metadata": info.metadata,
204        });
205        println!("{}", serde_json::to_string_pretty(&value)?);
206    } else {
207        println!("Sauvegarde créée : {}", info.path.display());
208        println!("  Commandes        : {}", info.metadata.command_count);
209        println!(
210            "  Taille DB        : {} octets",
211            info.metadata.db_size_bytes
212        );
213        println!("  Version schéma   : {}", info.metadata.schema_version);
214    }
215    Ok(())
216}
217
218/// Fichiers attendus extraits d'une archive de restauration.
219struct ExtractedBackup {
220    dir: PathBuf,
221    db: PathBuf,
222    config: Option<PathBuf>,
223    metadata: Option<BackupMetadata>,
224}
225
226impl Drop for ExtractedBackup {
227    fn drop(&mut self) {
228        // Nettoyage best-effort du dossier temporaire d'extraction.
229        let _ = fs::remove_dir_all(&self.dir);
230    }
231}
232
233/// Extrait l'archive dans un dossier temporaire et localise les fichiers.
234fn extract_archive(archive: &Path) -> Result<ExtractedBackup> {
235    if !archive.exists() {
236        bail!("archive introuvable : {}", archive.display());
237    }
238
239    let (secs, _) = timestamp_parts();
240    let pid = std::process::id();
241    let dir = std::env::temp_dir().join(format!("mnemo-restore-{pid}-{secs}"));
242    fs::create_dir_all(&dir)
243        .with_context(|| format!("création du dossier temporaire {}", dir.display()))?;
244
245    let file = File::open(archive)
246        .with_context(|| format!("ouverture de l'archive {}", archive.display()))?;
247    let dec = GzDecoder::new(file);
248    let ar = tar::Archive::new(dec);
249    crate::archive::safe_unpack(ar, &dir)
250        .with_context(|| format!("extraction de l'archive {}", archive.display()))?;
251
252    let db = dir.join("history.db");
253    if !db.exists() {
254        bail!("archive invalide : history.db manquant");
255    }
256    let config = {
257        let c = dir.join("config.toml");
258        c.exists().then_some(c)
259    };
260    let metadata = {
261        let m = dir.join("metadata.json");
262        if m.exists() {
263            let raw = fs::read_to_string(&m)?;
264            serde_json::from_str::<BackupMetadata>(&raw).ok()
265        } else {
266            None
267        }
268    };
269
270    Ok(ExtractedBackup {
271        dir,
272        db,
273        config,
274        metadata,
275    })
276}
277
278/// Valide que la base extraite est saine et compatible.
279fn validate_db(db_path: &Path) -> Result<i64> {
280    let conn = db::open_readonly(db_path)
281        .with_context(|| "la base restaurée n'est pas ouvrable (archive invalide)")?;
282    if !db::table_exists(&conn, "commands")? {
283        bail!("archive invalide : table `commands` absente");
284    }
285    let schema = migrations::schema_version(&conn)?;
286    if schema > migrations::SCHEMA_VERSION {
287        bail!(
288            "archive incompatible : schéma v{} > schéma supporté v{} (mnemo trop ancien)",
289            schema,
290            migrations::SCHEMA_VERSION
291        );
292    }
293    Ok(schema)
294}
295
296/// Remplace un fichier de façon atomique (copie vers un `.tmp` puis renommage).
297fn replace_file(src: &Path, dst: &Path) -> Result<()> {
298    if let Some(parent) = dst.parent() {
299        fs::create_dir_all(parent)?;
300    }
301    let tmp = dst.with_extension("mnemo-tmp");
302    fs::copy(src, &tmp).with_context(|| format!("copie vers {}", tmp.display()))?;
303    fs::rename(&tmp, dst).with_context(|| format!("remplacement de {}", dst.display()))?;
304    Ok(())
305}
306
307/// Point d'entrée de `mnemo restore`.
308pub fn restore_run(archive: &Path, dry_run: bool, assume_yes: bool) -> Result<()> {
309    let extracted = extract_archive(archive)?;
310    let schema = validate_db(&extracted.db)?;
311
312    // Aperçu de ce qui sera restauré.
313    println!("Archive : {}", archive.display());
314    if let Some(meta) = &extracted.metadata {
315        println!("  Version mnemo    : {}", meta.mnemo_version);
316        println!("  Date sauvegarde  : {}", meta.created_at);
317        println!("  Commandes        : {}", meta.command_count);
318    }
319    println!("  Version schéma   : {schema}");
320    println!(
321        "  Configuration    : {}",
322        if extracted.config.is_some() {
323            "incluse"
324        } else {
325            "absente"
326        }
327    );
328    println!("Cibles :");
329    println!("  DB     -> {}", config::db_path()?.display());
330    println!("  Config -> {}", config::config_path()?.display());
331
332    if dry_run {
333        println!("\n[dry-run] Aucune modification effectuée.");
334        return Ok(());
335    }
336
337    if !confirm::confirm(
338        "Restaurer cette sauvegarde remplacera la base et la config actuelles. Continuer ?",
339        assume_yes,
340    )? {
341        println!("Restauration annulée.");
342        return Ok(());
343    }
344
345    // Sauvegarde automatique de l'état courant avant remplacement.
346    let safety = create_backup(None)?;
347    println!("Sauvegarde de sécurité créée : {}", safety.path.display());
348
349    replace_file(&extracted.db, &config::db_path()?)?;
350    if let Some(cfg) = &extracted.config {
351        replace_file(cfg, &config::config_path()?)?;
352    }
353
354    println!("Restauration terminée.");
355    Ok(())
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    #[test]
363    fn nom_archive_bien_forme() {
364        assert_eq!(
365            archive_filename("2026-06-14 12:34:56"),
366            "mnemo-backup-20260614-123456.tar.gz"
367        );
368    }
369
370    #[test]
371    fn iso_bien_forme() {
372        assert_eq!(iso_from("2026-06-14 12:34:56"), "2026-06-14T12:34:56Z");
373    }
374}