1use 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
26pub 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 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 if !explicit && !update_available(¤t, &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 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 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 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 let tmp = tempdir()?;
103 enforce_signature(
104 &tag,
105 &archive_name,
106 &archive_bytes,
107 require_signature,
108 tmp.path(),
109 )?;
110
111 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 set_executable(&extracted)?;
118 verify_binary_runs(&extracted)
119 .context("le binaire téléchargé ne s'exécute pas correctement")?;
120
121 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 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
138fn 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 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 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
205fn tempdir() -> Result<TempDir> {
207 TempDir::new().context("création d'un dossier temporaire échouée")
208}
209
210fn 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
220pub 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
241fn 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
249fn 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
263fn 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 let _ = std::fs::remove_file(&tmp_dest);
277 anyhow::anyhow!("renommage atomique échoué : {e}")
278 })?;
279 Ok(())
280}
281
282struct 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#[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}