1use std::io::Write;
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10
11use crate::manifest::PluginManifest;
12
13#[derive(Debug)]
15pub struct PackResult {
16 pub archive_path: PathBuf,
18 pub sha256: String,
20 pub name: String,
22 pub version: String,
24 pub file_count: usize,
26 pub uncompressed_bytes: u64,
28}
29
30#[derive(Debug)]
32pub struct UnpackResult {
33 pub dest_dir: PathBuf,
35 pub manifest: PluginManifest,
37 pub sha256: String,
39 pub file_count: usize,
41}
42
43#[derive(Debug, thiserror::Error)]
45pub enum ArchiveError {
46 #[error("IO error: {0}")]
47 Io(#[from] std::io::Error),
48 #[error("ZIP error: {0}")]
49 Zip(#[from] zip::result::ZipError),
50 #[error("manifest error: {0}")]
51 Manifest(String),
52 #[error("archive verification failed: expected {expected}, got {actual}")]
53 ChecksumMismatch { expected: String, actual: String },
54 #[error("archive missing plugin.toml at root")]
55 MissingManifest,
56 #[error("path traversal detected in archive entry: {0}")]
57 PathTraversal(String),
58}
59
60impl From<ArchiveError> for roboticus_core::error::RoboticusError {
61 fn from(e: ArchiveError) -> Self {
62 Self::Config(format!("archive error: {e}"))
63 }
64}
65
66pub fn sha256_hex(data: &[u8]) -> String {
68 let hash = Sha256::digest(data);
69 hex::encode(hash)
70}
71
72pub fn file_sha256(path: &Path) -> Result<String, ArchiveError> {
74 let bytes = std::fs::read(path)?;
75 Ok(sha256_hex(&bytes))
76}
77
78pub fn pack(plugin_dir: &Path, output_dir: &Path) -> Result<PackResult, ArchiveError> {
83 let manifest_path = plugin_dir.join("plugin.toml");
85 if !manifest_path.exists() {
86 return Err(ArchiveError::MissingManifest);
87 }
88 let manifest = PluginManifest::from_file(&manifest_path)
89 .map_err(|e| ArchiveError::Manifest(e.to_string()))?;
90
91 let archive_name = format!("{}-{}.ic.zip", manifest.name, manifest.version);
92 let archive_path = output_dir.join(&archive_name);
93
94 std::fs::create_dir_all(output_dir)?;
95
96 let mut entries: Vec<(PathBuf, PathBuf)> = Vec::new(); collect_files(plugin_dir, plugin_dir, &mut entries)?;
99
100 let file = std::fs::File::create(&archive_path)?;
102 let mut zip = zip::ZipWriter::new(file);
103 let options = zip::write::SimpleFileOptions::default()
104 .compression_method(zip::CompressionMethod::Deflated);
105
106 let mut uncompressed_bytes: u64 = 0;
107
108 for (abs_path, rel_path) in &entries {
109 let rel_str = rel_path.to_string_lossy().replace('\\', "/");
110 zip.start_file(&rel_str, options)?;
111 let data = std::fs::read(abs_path)?;
112 uncompressed_bytes += data.len() as u64;
113 zip.write_all(&data)?;
114 }
115
116 zip.finish()?;
117
118 let sha256 = file_sha256(&archive_path)?;
120
121 Ok(PackResult {
122 archive_path,
123 sha256,
124 name: manifest.name.clone(),
125 version: manifest.version.clone(),
126 file_count: entries.len(),
127 uncompressed_bytes,
128 })
129}
130
131pub fn unpack(archive_path: &Path, dest_dir: &Path) -> Result<UnpackResult, ArchiveError> {
136 let archive_bytes = std::fs::read(archive_path)?;
137 let sha256 = sha256_hex(&archive_bytes);
138
139 unpack_bytes(&archive_bytes, dest_dir, sha256)
140}
141
142pub fn unpack_bytes(
144 data: &[u8],
145 dest_dir: &Path,
146 sha256: String,
147) -> Result<UnpackResult, ArchiveError> {
148 let cursor = std::io::Cursor::new(data);
149 let mut archive = zip::ZipArchive::new(cursor)?;
150
151 let mut has_manifest = false;
153 for i in 0..archive.len() {
154 let entry = archive.by_index(i)?;
155 let name = entry.name().to_string();
156
157 if name.contains("..")
159 || name.starts_with('/')
160 || name.starts_with('\\')
161 || name.chars().nth(1) == Some(':')
162 {
163 return Err(ArchiveError::PathTraversal(name));
164 }
165
166 if name == "plugin.toml"
167 || (name.ends_with("/plugin.toml") && name.matches('/').count() == 1)
168 {
169 has_manifest = true;
170 }
171 }
172
173 if !has_manifest {
174 return Err(ArchiveError::MissingManifest);
175 }
176
177 std::fs::create_dir_all(dest_dir)?;
179
180 let temp_suffix: u64 = std::time::SystemTime::now()
181 .duration_since(std::time::UNIX_EPOCH)
182 .map(|d| d.as_nanos() as u64)
183 .unwrap_or(0)
184 ^ std::process::id() as u64;
185 let temp_dir = dest_dir.join(format!(".unpack_{temp_suffix:x}"));
186 if temp_dir.exists() {
187 std::fs::remove_dir_all(&temp_dir)?;
188 }
189 std::fs::create_dir_all(&temp_dir)?;
190
191 let result = extract_and_finalize(&temp_dir, &mut archive, dest_dir, sha256);
193 if result.is_err() {
194 let _ = std::fs::remove_dir_all(&temp_dir);
195 }
196 result
197}
198
199fn extract_and_finalize(
202 temp_dir: &Path,
203 archive: &mut zip::ZipArchive<std::io::Cursor<&[u8]>>,
204 dest_dir: &Path,
205 sha256: String,
206) -> Result<UnpackResult, ArchiveError> {
207 let canonical_temp = temp_dir.canonicalize()?;
208 let mut file_count = 0;
209 for i in 0..archive.len() {
210 let mut entry = archive.by_index(i)?;
211 let name = entry.name().to_string();
212
213 let out_path = temp_dir.join(&name);
214
215 if let Ok(canonical) = out_path.canonicalize().or_else(|_| {
217 out_path
219 .parent()
220 .and_then(|p| p.canonicalize().ok())
221 .map(|p| p.join(out_path.file_name().unwrap_or_default()))
222 .ok_or_else(|| std::io::Error::other("no parent"))
223 }) && !canonical.starts_with(&canonical_temp)
224 {
225 return Err(ArchiveError::PathTraversal(name));
226 }
227
228 if entry.is_dir() {
229 std::fs::create_dir_all(&out_path)?;
230 } else {
231 if let Some(parent) = out_path.parent() {
232 std::fs::create_dir_all(parent)?;
233 }
234 let mut out_file = std::fs::File::create(&out_path)?;
235 std::io::copy(&mut entry, &mut out_file)?;
236 file_count += 1;
237 }
238 }
239
240 let manifest_path = temp_dir.join("plugin.toml");
242 let manifest = PluginManifest::from_file(&manifest_path)
243 .map_err(|e| ArchiveError::Manifest(e.to_string()))?;
244
245 let final_dir = dest_dir.join(&manifest.name);
247 if final_dir.exists() {
248 std::fs::remove_dir_all(&final_dir)?;
249 }
250 std::fs::rename(temp_dir, &final_dir)?;
251
252 Ok(UnpackResult {
253 dest_dir: final_dir,
254 manifest,
255 sha256,
256 file_count,
257 })
258}
259
260pub fn verify_checksum(archive_path: &Path, expected_sha256: &str) -> Result<bool, ArchiveError> {
262 let actual = file_sha256(archive_path)?;
263 if actual != expected_sha256 {
264 return Err(ArchiveError::ChecksumMismatch {
265 expected: expected_sha256.to_string(),
266 actual,
267 });
268 }
269 Ok(true)
270}
271
272pub fn verify_bytes_checksum(data: &[u8], expected_sha256: &str) -> Result<bool, ArchiveError> {
274 let actual = sha256_hex(data);
275 if actual != expected_sha256 {
276 return Err(ArchiveError::ChecksumMismatch {
277 expected: expected_sha256.to_string(),
278 actual,
279 });
280 }
281 Ok(true)
282}
283
284const EXCLUDED_DIRS: &[&str] = &[
288 ".git",
289 ".svn",
290 ".hg",
291 "node_modules",
292 "target",
293 "__pycache__",
294];
295
296const EXCLUDED_FILES: &[&str] = &[".DS_Store", "Thumbs.db", ".env"];
298
299fn collect_files(
300 base: &Path,
301 dir: &Path,
302 out: &mut Vec<(PathBuf, PathBuf)>,
303) -> Result<(), ArchiveError> {
304 for entry in std::fs::read_dir(dir)? {
305 let entry = entry?;
306 let path = entry.path();
307 let name_str = entry.file_name().to_string_lossy().to_string();
308
309 if path.is_dir() {
310 if EXCLUDED_DIRS.contains(&name_str.as_str()) {
311 continue;
312 }
313 collect_files(base, &path, out)?;
314 } else {
315 if EXCLUDED_FILES.contains(&name_str.as_str()) {
316 continue;
317 }
318 let rel = path.strip_prefix(base).unwrap_or(&path).to_path_buf();
319 out.push((path, rel));
320 }
321 }
322 Ok(())
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 fn make_test_plugin(dir: &Path) {
330 std::fs::write(
331 dir.join("plugin.toml"),
332 r#"
333name = "test-archive"
334version = "1.2.3"
335description = "Archive test plugin"
336
337[[tools]]
338name = "greet"
339description = "Say hello"
340"#,
341 )
342 .unwrap();
343 std::fs::write(dir.join("greet.sh"), "#!/bin/sh\necho hello").unwrap();
344
345 let sub = dir.join("skills");
347 std::fs::create_dir_all(&sub).unwrap();
348 std::fs::write(sub.join("guide.md"), "# Guide\nSome guidance.").unwrap();
349 }
350
351 #[test]
352 fn pack_creates_archive_with_correct_name() {
353 let src = tempfile::tempdir().unwrap();
354 let out = tempfile::tempdir().unwrap();
355 make_test_plugin(src.path());
356
357 let result = pack(src.path(), out.path()).unwrap();
358 assert_eq!(result.name, "test-archive");
359 assert_eq!(result.version, "1.2.3");
360 assert_eq!(result.file_count, 3); assert!(result.archive_path.exists());
362 assert!(
363 result
364 .archive_path
365 .file_name()
366 .unwrap()
367 .to_string_lossy()
368 .contains("test-archive-1.2.3.ic.zip")
369 );
370 assert!(!result.sha256.is_empty());
371 }
372
373 #[test]
374 fn pack_fails_without_manifest() {
375 let src = tempfile::tempdir().unwrap();
376 let out = tempfile::tempdir().unwrap();
377 std::fs::write(src.path().join("hello.sh"), "echo hi").unwrap();
379
380 let err = pack(src.path(), out.path()).unwrap_err();
381 assert!(matches!(err, ArchiveError::MissingManifest));
382 }
383
384 #[test]
385 fn roundtrip_pack_unpack() {
386 let src = tempfile::tempdir().unwrap();
387 let out = tempfile::tempdir().unwrap();
388 let staging = tempfile::tempdir().unwrap();
389 make_test_plugin(src.path());
390
391 let packed = pack(src.path(), out.path()).unwrap();
392 let unpacked = unpack(&packed.archive_path, staging.path()).unwrap();
393
394 assert_eq!(unpacked.manifest.name, "test-archive");
395 assert_eq!(unpacked.manifest.version, "1.2.3");
396 assert_eq!(unpacked.file_count, 3);
397 assert_eq!(unpacked.sha256, packed.sha256);
398
399 let plugin_dir = staging.path().join("test-archive");
401 assert!(plugin_dir.join("plugin.toml").exists());
402 assert!(plugin_dir.join("greet.sh").exists());
403 assert!(plugin_dir.join("skills").join("guide.md").exists());
404 }
405
406 #[test]
407 fn checksum_verification_passes() {
408 let src = tempfile::tempdir().unwrap();
409 let out = tempfile::tempdir().unwrap();
410 make_test_plugin(src.path());
411
412 let packed = pack(src.path(), out.path()).unwrap();
413 assert!(verify_checksum(&packed.archive_path, &packed.sha256).unwrap());
414 }
415
416 #[test]
417 fn checksum_verification_fails_on_mismatch() {
418 let src = tempfile::tempdir().unwrap();
419 let out = tempfile::tempdir().unwrap();
420 make_test_plugin(src.path());
421
422 let packed = pack(src.path(), out.path()).unwrap();
423 let err = verify_checksum(&packed.archive_path, "deadbeef").unwrap_err();
424 assert!(matches!(err, ArchiveError::ChecksumMismatch { .. }));
425 }
426
427 #[test]
428 fn unpack_bytes_works() {
429 let src = tempfile::tempdir().unwrap();
430 let out = tempfile::tempdir().unwrap();
431 let staging = tempfile::tempdir().unwrap();
432 make_test_plugin(src.path());
433
434 let packed = pack(src.path(), out.path()).unwrap();
435 let bytes = std::fs::read(&packed.archive_path).unwrap();
436 let sha = sha256_hex(&bytes);
437
438 let unpacked = unpack_bytes(&bytes, staging.path(), sha).unwrap();
439 assert_eq!(unpacked.manifest.name, "test-archive");
440 assert!(
441 staging
442 .path()
443 .join("test-archive")
444 .join("plugin.toml")
445 .exists()
446 );
447 }
448
449 #[test]
450 fn sha256_hex_is_deterministic() {
451 let a = sha256_hex(b"hello world");
452 let b = sha256_hex(b"hello world");
453 assert_eq!(a, b);
454 assert_eq!(a.len(), 64); }
456}