Skip to main content

roboticus_plugin_sdk/
archive.rs

1//! Plugin archive (.ic.zip) creation, extraction, and verification.
2//!
3//! An `.ic.zip` is a standard ZIP archive whose root contains `plugin.toml`.
4//! The naming convention is `<name>-<version>.ic.zip`.
5
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10
11use crate::manifest::PluginManifest;
12
13/// Result of packing a plugin directory into an archive.
14#[derive(Debug)]
15pub struct PackResult {
16    /// Path to the created `.ic.zip` file.
17    pub archive_path: PathBuf,
18    /// SHA-256 hex digest of the archive bytes.
19    pub sha256: String,
20    /// Plugin name from the manifest.
21    pub name: String,
22    /// Plugin version from the manifest.
23    pub version: String,
24    /// Number of files included in the archive.
25    pub file_count: usize,
26    /// Total uncompressed size in bytes.
27    pub uncompressed_bytes: u64,
28}
29
30/// Result of unpacking an archive to a staging directory.
31#[derive(Debug)]
32pub struct UnpackResult {
33    /// Directory where the plugin was extracted.
34    pub dest_dir: PathBuf,
35    /// Parsed manifest from the extracted `plugin.toml`.
36    pub manifest: PluginManifest,
37    /// SHA-256 hex digest of the archive bytes (for verification).
38    pub sha256: String,
39    /// Number of files extracted.
40    pub file_count: usize,
41}
42
43/// Errors specific to archive operations.
44#[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
66/// Compute SHA-256 hex digest of a byte slice.
67pub fn sha256_hex(data: &[u8]) -> String {
68    let hash = Sha256::digest(data);
69    hex::encode(hash)
70}
71
72/// Compute SHA-256 hex digest of a file.
73pub fn file_sha256(path: &Path) -> Result<String, ArchiveError> {
74    let bytes = std::fs::read(path)?;
75    Ok(sha256_hex(&bytes))
76}
77
78/// Pack a plugin directory into a `.ic.zip` archive.
79///
80/// The archive is written to `output_dir/<name>-<version>.ic.zip`.
81/// The plugin directory must contain a valid `plugin.toml` at its root.
82pub fn pack(plugin_dir: &Path, output_dir: &Path) -> Result<PackResult, ArchiveError> {
83    // Parse and validate manifest first
84    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    // Collect all files relative to plugin_dir
97    let mut entries: Vec<(PathBuf, PathBuf)> = Vec::new(); // (absolute, relative)
98    collect_files(plugin_dir, plugin_dir, &mut entries)?;
99
100    // Create zip
101    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    // Compute checksum of the final archive
119    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
131/// Unpack a `.ic.zip` archive into a destination directory.
132///
133/// Extracts to `dest_dir/<plugin-name>/`. Validates the manifest after extraction.
134/// Returns the parsed manifest and checksum for verification.
135pub 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
142/// Unpack from in-memory bytes (useful after downloading).
143pub 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    // First pass: verify plugin.toml exists and check for path traversal
152    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        // Security: reject path traversal attempts (covers Unix and Windows patterns)
158        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    // Create a uniquely-named temp extraction dir to avoid races on concurrent installs
178    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    // Run extraction in a helper so we can clean up temp_dir on ANY error
192    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
199/// Inner helper for `unpack_bytes` — extracted so the caller can guarantee
200/// temp dir cleanup on any error path (IO, manifest parse, rename, etc.).
201fn 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        // Defense-in-depth: verify joined path stays inside temp_dir
216        if let Ok(canonical) = out_path.canonicalize().or_else(|_| {
217            // Path doesn't exist yet; canonicalize parent and append filename
218            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    // Parse manifest from extracted files
241    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    // Move to final destination: dest_dir/<plugin-name>/
246    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
260/// Verify an archive's SHA-256 matches an expected value.
261pub 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
272/// Verify in-memory bytes against an expected checksum.
273pub 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
284// ── Helpers ──────────────────────────────────────────────────
285
286/// Directories excluded from archive packing (not meaningful to plugin runtime).
287const EXCLUDED_DIRS: &[&str] = &[
288    ".git",
289    ".svn",
290    ".hg",
291    "node_modules",
292    "target",
293    "__pycache__",
294];
295
296/// Individual files excluded from archive packing.
297const 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        // Add a subdirectory with a file
346        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); // plugin.toml, greet.sh, skills/guide.md
361        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        // No plugin.toml
378        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        // Verify extracted files exist
400        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); // 32 bytes = 64 hex chars
455    }
456}