1use std::io::Read;
14use std::path::{Component, Path};
15
16use anyhow::{bail, Result};
17
18pub fn validate_entry_path(path: &Path) -> Result<()> {
23 for component in path.components() {
24 match component {
25 Component::Normal(_) | Component::CurDir => {}
26 Component::ParentDir => {
27 bail!(
28 "entrée d'archive rejetée (remontée de dossier `..`) : {}",
29 path.display()
30 )
31 }
32 Component::RootDir | Component::Prefix(_) => {
33 bail!(
34 "entrée d'archive rejetée (chemin absolu) : {}",
35 path.display()
36 )
37 }
38 }
39 }
40 Ok(())
41}
42
43pub fn safe_unpack<R: Read>(mut archive: tar::Archive<R>, dest: &Path) -> Result<()> {
47 for entry in archive.entries()? {
48 let mut entry = entry?;
49 let path = entry.path()?.into_owned();
50 validate_entry_path(&path)?;
51
52 if let Some(link) = entry.link_name()? {
55 validate_entry_path(&link)?;
56 }
57
58 let unpacked = entry.unpack_in(dest)?;
61 if !unpacked {
62 bail!(
63 "entrée d'archive refusée (hors du dossier cible) : {}",
64 path.display()
65 );
66 }
67 }
68 Ok(())
69}
70
71#[cfg(test)]
72mod tests {
73 use super::*;
74 use std::path::PathBuf;
75
76 #[test]
77 fn accepte_chemins_relatifs_normaux() {
78 for p in ["history.db", "dir/config.toml", "./a/b.json"] {
79 assert!(validate_entry_path(&PathBuf::from(p)).is_ok(), "{p}");
80 }
81 }
82
83 #[test]
84 fn rejette_remontee_parent() {
85 for p in ["../evil", "a/../../evil", "../../etc/passwd"] {
86 assert!(validate_entry_path(&PathBuf::from(p)).is_err(), "{p}");
87 }
88 }
89
90 #[test]
91 fn rejette_chemin_absolu() {
92 for p in ["/tmp/evil", "/etc/passwd"] {
93 assert!(validate_entry_path(&PathBuf::from(p)).is_err(), "{p}");
94 }
95 }
96
97 #[test]
98 fn safe_unpack_extrait_archive_saine() {
99 use flate2::write::GzEncoder;
100 use flate2::Compression;
101
102 let mut header = tar::Header::new_gnu();
103 let data = b"hello";
104 header.set_size(data.len() as u64);
105 header.set_mode(0o644);
106 header.set_cksum();
107 let enc = GzEncoder::new(Vec::new(), Compression::default());
108 let mut builder = tar::Builder::new(enc);
109 builder
110 .append_data(&mut header, "sub/file.txt", &data[..])
111 .unwrap();
112 let bytes = builder.into_inner().unwrap().finish().unwrap();
113
114 let tmp = std::env::temp_dir().join(format!("mnemo-archive-test-{}", std::process::id()));
115 let _ = std::fs::remove_dir_all(&tmp);
116 std::fs::create_dir_all(&tmp).unwrap();
117 let archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
118 safe_unpack(archive, &tmp).unwrap();
119 assert_eq!(std::fs::read(tmp.join("sub/file.txt")).unwrap(), data);
120 let _ = std::fs::remove_dir_all(&tmp);
121 }
122
123 #[test]
124 fn safe_unpack_rejette_path_traversal() {
125 use flate2::write::GzEncoder;
128 use flate2::Compression;
129
130 let data = b"evil";
131 let mut header = tar::Header::new_gnu();
132 header.set_size(data.len() as u64);
133 header.set_mode(0o644);
134 header.set_entry_type(tar::EntryType::Regular);
135 {
136 let gnu = header.as_gnu_mut().unwrap();
137 let name = b"../evil.txt";
138 gnu.name[..name.len()].copy_from_slice(name);
139 }
140 header.set_cksum();
141 let enc = GzEncoder::new(Vec::new(), Compression::default());
142 let mut builder = tar::Builder::new(enc);
143 builder.append(&header, &data[..]).unwrap();
144 let bytes = builder.into_inner().unwrap().finish().unwrap();
145
146 let tmp = std::env::temp_dir().join(format!(
147 "mnemo-archive-evil-{}-{}",
148 std::process::id(),
149 std::time::SystemTime::now()
150 .duration_since(std::time::UNIX_EPOCH)
151 .map(|d| d.as_nanos())
152 .unwrap_or(0)
153 ));
154 let _ = std::fs::remove_dir_all(&tmp);
155 std::fs::create_dir_all(&tmp).unwrap();
156 let archive = tar::Archive::new(flate2::read::GzDecoder::new(&bytes[..]));
157 let result = safe_unpack(archive, &tmp);
158 assert!(result.is_err(), "le path traversal doit être rejeté");
159 assert!(!tmp.parent().unwrap().join("evil.txt").exists());
161 let _ = std::fs::remove_dir_all(&tmp);
162 }
163}