Skip to main content

mnemo/lifecycle/
upgrade.rs

1//! `mnemo upgrade` : télécharge la dernière release (ou une version donnée),
2//! vérifie son intégrité SHA-256, sauvegarde les données puis remplace le
3//! binaire de façon atomique.
4//!
5//! Garanties :
6//! - HTTPS par défaut, vérification SHA-256 **avant** extraction ;
7//! - aucune exécution de script distant (on ne récupère que des assets) ;
8//! - les données (`history.db`, `config.toml`, sauvegardes) ne sont **jamais**
9//!   touchées ;
10//! - en cas d'échec, le binaire en place reste intact.
11
12use std::os::unix::fs::PermissionsExt;
13use std::path::{Path, PathBuf};
14
15use anyhow::{Context, Result};
16
17use crate::{backup, config, confirm};
18
19use super::github::{asset_url, fetch_latest_release, http_get_bytes, http_get_string};
20use super::uninstall::bin_path;
21use super::{
22    asset_names_for_version, current_version, normalize_tag, parse_sha256_file, signature,
23    target_triple, update_available, verify_sha256,
24};
25
26/// Point d'entrée de la commande.
27///
28/// `require_signature` rend la vérification Sigstore **obligatoire** : `cosign`
29/// doit être disponible, le bundle de signature téléchargeable et la
30/// vérification valide, sinon l'upgrade est refusé. Sans ce drapeau, la
31/// vérification reste best-effort (avertissement si `cosign` est absent, le
32/// SHA-256 obligatoire demeurant le contrôle bloquant).
33pub fn run(
34    dry_run: bool,
35    assume_yes: bool,
36    version: Option<String>,
37    target: Option<String>,
38    require_signature: bool,
39) -> Result<()> {
40    let current = current_version();
41    let target_triple = target.unwrap_or_else(|| target_triple().to_string());
42
43    // 1. Résolution de la version cible.
44    let (tag, explicit) = match version {
45        Some(v) => (normalize_tag(&v), true),
46        None => (normalize_tag(&fetch_latest_release()?.tag_name), false),
47    };
48
49    println!("Version installée : {current}");
50    println!("Version cible     : {tag} ({target_triple})");
51
52    // 2. Déjà à jour ? (sauf si une version explicite est demandée.)
53    if !explicit && !update_available(&current, &tag) {
54        println!("mnemo est déjà à jour ✓ - rien à faire.");
55        return Ok(());
56    }
57
58    let (archive_name, sha_name) = asset_names_for_version(&tag, &target_triple);
59    let archive_url = asset_url(&tag, &archive_name);
60    let sha_url = asset_url(&tag, &sha_name);
61    let bin = bin_path();
62
63    println!("Archive           : {archive_url}");
64
65    if dry_run {
66        println!("\nSimulation : aucun téléchargement ni remplacement effectué.");
67        println!("Le binaire {} serait remplacé par {tag}.", bin.display());
68        return Ok(());
69    }
70
71    // 3. Confirmation avant remplacement.
72    let ok = confirm::confirm(
73        &format!("Installer {tag} en remplacement de {current} ?"),
74        assume_yes,
75    )?;
76    if !ok {
77        println!("Mise à niveau annulée.");
78        return Ok(());
79    }
80
81    // 4. Téléchargement de l'archive et de sa somme de contrôle.
82    println!("Téléchargement de l'archive…");
83    let archive_bytes = http_get_bytes(&archive_url)
84        .with_context(|| format!("téléchargement de {archive_url} échoué"))?;
85    let sha_text =
86        http_get_string(&sha_url).with_context(|| format!("téléchargement de {sha_url} échoué"))?;
87    let expected = parse_sha256_file(&sha_text)
88        .context("fichier .sha256 illisible (somme attendue introuvable)")?;
89
90    // 5. Vérification d'intégrité AVANT toute extraction.
91    if !verify_sha256(&archive_bytes, &expected) {
92        anyhow::bail!(
93            "vérification SHA-256 échouée - installation refusée (archive corrompue ou altérée)"
94        );
95    }
96    println!("Intégrité SHA-256 vérifiée ✓");
97
98    // 5 bis. Vérification Sigstore (défense en profondeur). Le SHA-256 ci-dessus
99    // reste l'unique contrôle obligatoire ; cette étape ne s'exécute qu'après
100    // lui. En mode strict (`require_signature`), toute impossibilité de vérifier
101    // refuse l'installation ; sinon, elle est best-effort.
102    let tmp = tempdir()?;
103    enforce_signature(
104        &tag,
105        &archive_name,
106        &archive_bytes,
107        require_signature,
108        tmp.path(),
109    )?;
110
111    // 6. Extraction dans le dossier temporaire.
112    extract_targz(&archive_bytes, tmp.path()).context("extraction de l'archive échouée")?;
113    let extracted =
114        find_binary(tmp.path(), "mnemo").context("binaire `mnemo` introuvable dans l'archive")?;
115
116    // 7. Le nouveau binaire répond-il ?
117    set_executable(&extracted)?;
118    verify_binary_runs(&extracted)
119        .context("le binaire téléchargé ne s'exécute pas correctement")?;
120
121    // 8. Sauvegarde des données AVANT remplacement (si une base existe).
122    if config::db_path().map(|p| p.exists()).unwrap_or(false) {
123        match backup::create_backup(None) {
124            Ok(info) => println!("Sauvegarde des données : {}", info.path.display()),
125            Err(e) => eprintln!("Avertissement : sauvegarde impossible ({e})"),
126        }
127    }
128
129    // 9. Remplacement atomique du binaire.
130    replace_binary(&extracted, &bin)
131        .with_context(|| format!("remplacement de {} échoué", bin.display()))?;
132
133    println!("\nmnemo mis à niveau : {current} → {tag} ✓");
134    println!("Binaire : {}", bin.display());
135    Ok(())
136}
137
138/// Applique la politique de signature Sigstore après la vérification SHA-256.
139///
140/// SHA-256 reste l'unique contrôle **obligatoire** ; cette étape est une
141/// défense en profondeur :
142/// - `cosign` absent + mode best-effort → avertissement, on continue ;
143/// - `cosign` absent + mode strict → refus ;
144/// - bundle indisponible + best-effort → avertissement, on continue ;
145/// - bundle indisponible + strict → refus ;
146/// - signature invalide → refus **dans tous les cas** ;
147/// - signature valide → on continue.
148fn enforce_signature(
149    tag: &str,
150    archive_name: &str,
151    archive_bytes: &[u8],
152    require_signature: bool,
153    workdir: &Path,
154) -> Result<()> {
155    if !signature::cosign_available() {
156        if require_signature {
157            anyhow::bail!("Signature Sigstore obligatoire mais cosign est introuvable.");
158        }
159        println!(
160            "Signature Sigstore non vérifiée : cosign absent (continuité autorisée car SHA-256 \
161             vérifié). Utilisez --require-signature pour rendre ce contrôle obligatoire."
162        );
163        return Ok(());
164    }
165
166    // cosign disponible : récupération du bundle de signature de l'archive.
167    let bundle_name = signature::signature_asset_name(archive_name);
168    let bundle_url = asset_url(tag, &bundle_name);
169    let bundle_bytes = match http_get_bytes(&bundle_url) {
170        Ok(b) => b,
171        Err(e) => {
172            if require_signature {
173                anyhow::bail!(
174                    "Signature Sigstore obligatoire mais le bundle {bundle_name} est \
175                     indisponible : {e}"
176                );
177            }
178            println!(
179                "Signature Sigstore non vérifiée : bundle indisponible ({e}) (continuité \
180                 autorisée car SHA-256 vérifié)."
181            );
182            return Ok(());
183        }
184    };
185
186    // Écriture de l'asset et du bundle sur disque pour `cosign verify-blob`.
187    let asset_path = workdir.join(archive_name);
188    std::fs::write(&asset_path, archive_bytes)
189        .context("écriture de l'archive temporaire échouée")?;
190    let bundle_path = workdir.join(&bundle_name);
191    std::fs::write(&bundle_path, &bundle_bytes)
192        .context("écriture du bundle de signature échouée")?;
193
194    match signature::verify_sigstore_bundle(&asset_path, &bundle_path) {
195        Ok(()) => {
196            println!("Signature Sigstore vérifiée ✓");
197            Ok(())
198        }
199        Err(e) => {
200            anyhow::bail!("Signature Sigstore invalide - installation refusée : {e}");
201        }
202    }
203}
204
205/// Crée un dossier temporaire dédié.
206fn tempdir() -> Result<TempDir> {
207    TempDir::new().context("création d'un dossier temporaire échouée")
208}
209
210/// Décompresse une archive `.tar.gz` (en mémoire) vers `dest`.
211fn extract_targz(bytes: &[u8], dest: &Path) -> Result<()> {
212    use flate2::read::GzDecoder;
213    use tar::Archive;
214    let decoder = GzDecoder::new(bytes);
215    let archive = Archive::new(decoder);
216    crate::archive::safe_unpack(archive, dest)?;
217    Ok(())
218}
219
220/// Recherche récursive d'un fichier de nom `name` sous `dir`.
221pub fn find_binary(dir: &Path, name: &str) -> Option<PathBuf> {
222    let entries = std::fs::read_dir(dir).ok()?;
223    let mut subdirs = Vec::new();
224    for entry in entries.flatten() {
225        let path = entry.path();
226        if path.is_file() && path.file_name().and_then(|n| n.to_str()) == Some(name) {
227            return Some(path);
228        }
229        if path.is_dir() {
230            subdirs.push(path);
231        }
232    }
233    for sub in subdirs {
234        if let Some(found) = find_binary(&sub, name) {
235            return Some(found);
236        }
237    }
238    None
239}
240
241/// Rend un fichier exécutable (`chmod u+rwx,go+rx`).
242fn set_executable(path: &Path) -> Result<()> {
243    let mut perms = std::fs::metadata(path)?.permissions();
244    perms.set_mode(0o755);
245    std::fs::set_permissions(path, perms)?;
246    Ok(())
247}
248
249/// Vérifie que le binaire répond à `--version` (code de sortie nul).
250fn verify_binary_runs(path: &Path) -> Result<()> {
251    let status = std::process::Command::new(path)
252        .arg("--version")
253        .stdout(std::process::Stdio::null())
254        .stderr(std::process::Stdio::null())
255        .status()
256        .with_context(|| format!("exécution de {} impossible", path.display()))?;
257    if !status.success() {
258        anyhow::bail!("`--version` a renvoyé un code non nul");
259    }
260    Ok(())
261}
262
263/// Remplace `dest` par `src` de façon atomique (écriture dans le même dossier
264/// puis `rename`). Le remplacement du binaire en cours d'exécution est sûr sous
265/// Linux (l'inode reste valide jusqu'à la fin du processus).
266fn replace_binary(src: &Path, dest: &Path) -> Result<()> {
267    if let Some(parent) = dest.parent() {
268        std::fs::create_dir_all(parent).ok();
269    }
270    let tmp_dest = dest.with_extension("mnemo-new");
271    std::fs::copy(src, &tmp_dest)
272        .with_context(|| format!("copie vers {} échouée", tmp_dest.display()))?;
273    set_executable(&tmp_dest)?;
274    std::fs::rename(&tmp_dest, dest).map_err(|e| {
275        // Nettoyage : on ne laisse pas de binaire temporaire derrière nous.
276        let _ = std::fs::remove_file(&tmp_dest);
277        anyhow::anyhow!("renommage atomique échoué : {e}")
278    })?;
279    Ok(())
280}
281
282/// Dossier temporaire minimaliste supprimé à la destruction (sans dépendance
283/// supplémentaire : `tempfile` n'est disponible qu'en dev).
284struct TempDir {
285    path: PathBuf,
286}
287
288impl TempDir {
289    fn new() -> std::io::Result<Self> {
290        let base = std::env::temp_dir();
291        let nanos = std::time::SystemTime::now()
292            .duration_since(std::time::UNIX_EPOCH)
293            .map(|d| d.as_nanos())
294            .unwrap_or(0);
295        let path = base.join(format!("mnemo-upgrade-{}-{}", std::process::id(), nanos));
296        std::fs::create_dir_all(&path)?;
297        Ok(Self { path })
298    }
299
300    fn path(&self) -> &Path {
301        &self.path
302    }
303}
304
305impl Drop for TempDir {
306    fn drop(&mut self) {
307        let _ = std::fs::remove_dir_all(&self.path);
308    }
309}
310
311/// Petit helper de test : crée une archive `.tar.gz` contenant un binaire.
312#[cfg(test)]
313pub fn make_test_archive(bin_name: &str, dir_prefix: &str, content: &[u8]) -> Vec<u8> {
314    use flate2::write::GzEncoder;
315    use flate2::Compression;
316    let mut header = tar::Header::new_gnu();
317    header.set_size(content.len() as u64);
318    header.set_mode(0o755);
319    header.set_cksum();
320    let encoder = GzEncoder::new(Vec::new(), Compression::default());
321    let mut builder = tar::Builder::new(encoder);
322    let path = format!("{dir_prefix}/{bin_name}");
323    builder.append_data(&mut header, path, content).unwrap();
324    let encoder = builder.into_inner().unwrap();
325    encoder.finish().unwrap()
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331
332    #[test]
333    fn recherche_binaire_recursive() {
334        let tmp = TempDir::new().unwrap();
335        let sub = tmp.path().join("mnemo-v0.5.0-x86_64-unknown-linux-musl");
336        std::fs::create_dir_all(&sub).unwrap();
337        let bin = sub.join("mnemo");
338        std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
339        let found = find_binary(tmp.path(), "mnemo").unwrap();
340        assert_eq!(found, bin);
341        assert!(find_binary(tmp.path(), "absent").is_none());
342    }
343
344    #[test]
345    fn extraction_archive() {
346        let archive = make_test_archive(
347            "mnemo",
348            "mnemo-v0.5.0-x86_64-unknown-linux-musl",
349            b"#!/bin/sh\necho ok\n",
350        );
351        let tmp = TempDir::new().unwrap();
352        extract_targz(&archive, tmp.path()).unwrap();
353        let found = find_binary(tmp.path(), "mnemo").unwrap();
354        let content = std::fs::read(&found).unwrap();
355        assert!(content.starts_with(b"#!/bin/sh"));
356    }
357
358    #[test]
359    fn remplacement_atomique() {
360        let tmp = TempDir::new().unwrap();
361        let src = tmp.path().join("src");
362        let dest = tmp.path().join("dest");
363        std::fs::write(&src, b"nouveau").unwrap();
364        std::fs::write(&dest, b"ancien").unwrap();
365        replace_binary(&src, &dest).unwrap();
366        assert_eq!(std::fs::read(&dest).unwrap(), b"nouveau");
367    }
368}