fastskill_core/storage/
zip.rs1use crate::core::service::ServiceError;
4use crate::security::path::normalize_path;
5use std::io;
6use std::path::Path;
7
8pub struct ZipHandler;
9
10impl ZipHandler {
11 pub fn new() -> Result<Self, ServiceError> {
12 Ok(Self)
13 }
14
15 pub async fn validate_package(&self, _zip_path: &Path) -> Result<(), ServiceError> {
17 Ok(())
18 }
19
20 pub fn extract_to_dir(&self, zip_path: &Path, dest_dir: &Path) -> Result<(), ServiceError> {
36 let file = std::fs::File::open(zip_path).map_err(ServiceError::Io)?;
37 let mut archive = zip::ZipArchive::new(file)
38 .map_err(|e| ServiceError::Validation(format!("Invalid ZIP file: {}", e)))?;
39
40 let dest_canonical = dest_dir.canonicalize().map_err(|e| {
42 ServiceError::Io(io::Error::new(
43 e.kind(),
44 format!("Failed to canonicalize destination: {}", e),
45 ))
46 })?;
47
48 for i in 0..archive.len() {
49 let mut file = archive.by_index(i).map_err(|e| {
50 ServiceError::Validation(format!("Failed to read ZIP entry: {}", e))
51 })?;
52
53 let entry_name = file.name().to_string();
54
55 #[cfg(unix)]
57 if matches!(file.unix_mode(), Some(mode) if (mode & 0o170000) == 0o120000) {
58 return Err(ServiceError::Validation(format!(
59 "Symlink entry rejected for security: {}",
60 entry_name
61 )));
62 }
63
64 let normalized_entry_name = normalize_path(Path::new(&entry_name));
66 if normalized_entry_name.as_os_str().is_empty() {
67 return Err(ServiceError::Validation(format!(
68 "Path traversal attempt detected in ZIP entry: '{}'",
69 entry_name
70 )));
71 }
72
73 let outpath = dest_dir.join(&normalized_entry_name);
75
76 let outpath_str = outpath.to_string_lossy().to_string();
78 let dest_str = dest_canonical.to_string_lossy().to_string();
79 if !outpath_str.starts_with(&dest_str) {
80 return Err(ServiceError::Validation(format!(
81 "Path traversal attempt detected in ZIP entry: '{}' would resolve outside extraction directory",
82 entry_name
83 )));
84 }
85
86 let path_is_directory = normalized_entry_name.ends_with("/");
89
90 let outpath_canonical = if outpath.exists() {
93 outpath.canonicalize().map_err(|e| {
94 ServiceError::Io(io::Error::new(
95 e.kind(),
96 format!("Failed to resolve path for ZIP entry: {}", entry_name),
97 ))
98 })?
99 } else if path_is_directory {
100 std::fs::create_dir_all(&outpath).map_err(ServiceError::Io)?;
102 outpath.canonicalize().map_err(|e| {
103 ServiceError::Io(io::Error::new(
104 e.kind(),
105 format!("Failed to resolve path for ZIP entry: {}", entry_name),
106 ))
107 })?
108 } else {
109 if let Some(parent) = outpath.parent() {
111 if !parent.exists() {
112 std::fs::create_dir_all(parent).map_err(ServiceError::Io)?;
114 }
115 let parent_canonical = parent.canonicalize().map_err(|e| {
117 ServiceError::Io(io::Error::new(
118 e.kind(),
119 format!(
120 "Failed to resolve parent path for ZIP entry: {}",
121 entry_name
122 ),
123 ))
124 })?;
125 if !parent_canonical.starts_with(&dest_canonical) {
126 return Err(ServiceError::Validation(format!(
127 "Path traversal attempt detected in parent directory for ZIP entry: '{}'",
128 entry_name
129 )));
130 }
131 }
132 let mut outfile = std::fs::File::create(&outpath).map_err(ServiceError::Io)?;
134 io::copy(&mut file, &mut outfile).map_err(ServiceError::Io)?;
135 outpath.canonicalize().map_err(|e| {
137 ServiceError::Io(io::Error::new(
138 e.kind(),
139 format!("Failed to resolve path for ZIP entry: {}", entry_name),
140 ))
141 })?
142 };
143
144 if !outpath_canonical.starts_with(&dest_canonical) {
146 return Err(ServiceError::Validation(format!(
147 "Path traversal attempt detected in ZIP entry: '{}' resolves outside extraction directory",
148 entry_name
149 )));
150 }
151 }
152
153 Ok(())
154 }
155}
156
157#[cfg(test)]
158#[allow(clippy::unwrap_used)]
159mod tests {
160 use super::*;
161 use std::fs::File;
162 use std::io::Write;
163 use tempfile::TempDir;
164 use zip::write::FileOptions;
165 use zip::ZipWriter;
166
167 fn create_test_zip(entries: &[(&str, &[u8])]) -> (TempDir, std::path::PathBuf) {
169 let temp_dir = TempDir::new().unwrap();
170 let zip_path = temp_dir.path().join("test.zip");
171
172 let file = File::create(&zip_path).unwrap();
173 let mut zip = ZipWriter::new(file);
174 let options = FileOptions::default().compression_method(zip::CompressionMethod::Stored);
175
176 for (name, content) in entries {
177 if name.ends_with('/') {
178 zip.add_directory(*name, options).unwrap();
179 } else {
180 zip.start_file(*name, options).unwrap();
181 zip.write_all(content).unwrap();
182 }
183 }
184
185 zip.finish().unwrap();
186 (temp_dir, zip_path)
187 }
188
189 #[test]
190 fn test_safe_extract_normal_files() {
191 let (_temp_dir, zip_path) = create_test_zip(&[
192 ("SKILL.md", b"Test content"),
193 ("README.md", b"Readme content"),
194 ("src/main.rs", b"source code"),
195 ]);
196
197 let extract_dir = TempDir::new().unwrap();
198 let handler = ZipHandler::new().unwrap();
199
200 handler
201 .extract_to_dir(&zip_path, extract_dir.path())
202 .unwrap();
203
204 assert!(extract_dir.path().join("SKILL.md").exists());
205 assert!(extract_dir.path().join("README.md").exists());
206 assert!(extract_dir.path().join("src/main.rs").exists());
207 }
208
209 #[test]
210 fn test_safe_extract_rejects_path_traversal() {
211 let (_temp_dir, zip_path) = create_test_zip(&[
212 ("normal.txt", b"safe content"),
213 ("../../../evil.txt", b"malicious content"),
214 ]);
215
216 let extract_dir = TempDir::new().unwrap();
217 let handler = ZipHandler::new().unwrap();
218
219 let result = handler.extract_to_dir(&zip_path, extract_dir.path());
220
221 assert!(result.is_err());
222 match result {
223 Err(ServiceError::Validation(msg)) => {
224 assert!(
225 msg.contains("path traversal") || msg.contains("Path traversal"),
226 "Error should mention path traversal: {}",
227 msg
228 );
229 }
230 _ => unreachable!("Expected ServiceError::Validation for path traversal"),
231 }
232
233 assert!(!extract_dir.path().join("../../../evil.txt").exists());
235 assert!(extract_dir.path().join("normal.txt").exists());
236 }
237
238 #[test]
239 fn test_safe_extract_rejects_windows_path_traversal() {
240 let (_temp_dir, zip_path) = create_test_zip(&[
241 ("normal.txt", b"safe content"),
242 ("..\\..\\..\\evil.txt", b"malicious content"),
243 ]);
244
245 let extract_dir = TempDir::new().unwrap();
246 let handler = ZipHandler::new().unwrap();
247
248 let result = handler.extract_to_dir(&zip_path, extract_dir.path());
249
250 if cfg!(windows) {
254 assert!(
255 result.is_err(),
256 "Windows-style path traversal should be rejected on Windows"
257 );
258 } else {
259 assert!(matches!(result, Ok(_) | Err(_)));
262 }
263 }
264
265 #[test]
266 fn test_safe_extract_rejects_absolute_paths() {
267 let (_temp_dir, zip_path) = create_test_zip(&[
268 ("/etc/passwd", b"malicious content"),
269 ("normal.txt", b"safe content"),
270 ]);
271
272 let extract_dir = TempDir::new().unwrap();
273 let handler = ZipHandler::new().unwrap();
274
275 let result = handler.extract_to_dir(&zip_path, extract_dir.path());
276
277 assert!(result.is_err());
278 }
280
281 #[test]
282 fn test_safe_extract_nested_directories() {
283 let (_temp_dir, zip_path) = create_test_zip(&[
284 ("deep/nested/file.txt", b"content"),
285 ("another/nested/path/README.md", b"readme"),
286 ]);
287
288 let extract_dir = TempDir::new().unwrap();
289 let handler = ZipHandler::new().unwrap();
290
291 handler
292 .extract_to_dir(&zip_path, extract_dir.path())
293 .unwrap();
294
295 assert!(extract_dir.path().join("deep/nested/file.txt").exists());
296 assert!(extract_dir
297 .path()
298 .join("another/nested/path/README.md")
299 .exists());
300 }
301
302 #[test]
303 fn test_safe_extract_rejects_mixed_traversal() {
304 let (_temp_dir, zip_path) = create_test_zip(&[
305 ("safe/file.txt", b"safe"),
306 ("safe/../../evil.txt", b"malicious"),
307 ]);
308
309 let extract_dir = TempDir::new().unwrap();
310 let handler = ZipHandler::new().unwrap();
311
312 let result = handler.extract_to_dir(&zip_path, extract_dir.path());
313
314 assert!(result.is_err());
315 assert!(extract_dir.path().join("safe/file.txt").exists());
317 }
318
319 #[test]
320 fn test_safe_extract_does_not_create_dirs_outside_root() {
321 let base = TempDir::new().unwrap();
322 let extract_dir = base.path().join("extract");
323 std::fs::create_dir_all(&extract_dir).unwrap();
324
325 let (_temp_dir, zip_path) =
327 create_test_zip(&[("../../../escape_evil/file.txt", b"malicious content")]);
328
329 let handler = ZipHandler::new().unwrap();
330 let result = handler.extract_to_dir(&zip_path, &extract_dir);
331
332 assert!(result.is_err());
334
335 assert!(!extract_dir.join("escape_evil").exists());
337 assert!(!extract_dir.join("escape_evil/file.txt").exists());
338 }
339}