Skip to main content

synwire_agent/sandbox/
archive.rs

1//! Archive backend for tar/gzip/zip operations.
2
3use std::io::{Read, Write};
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7use synwire_core::BoxFuture;
8use synwire_core::vfs::error::VfsError;
9use synwire_core::vfs::types::ArchiveInfo;
10
11/// Conflict resolution policy for extraction.
12#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum ConflictPolicy {
15    /// Skip existing files.
16    Skip,
17    /// Overwrite existing files.
18    Overwrite,
19    /// Fail with an error.
20    Fail,
21}
22
23/// Archive format.
24#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
25#[non_exhaustive]
26pub enum ArchiveFormat {
27    /// Tar with gzip compression.
28    TarGz,
29    /// Tar with bzip2 compression.
30    TarBz2,
31    /// Tar without compression.
32    Tar,
33    /// Zip archive.
34    Zip,
35}
36
37impl ArchiveFormat {
38    /// Detect format from file extension.
39    pub fn from_path(path: &str) -> Option<Self> {
40        let p = std::path::Path::new(path);
41        let ext = p
42            .extension()
43            .and_then(|e| e.to_str())
44            .map(str::to_ascii_lowercase);
45        let ext = ext.as_deref().unwrap_or("");
46        if path.ends_with(".tar.gz") || ext == "tgz" {
47            Some(Self::TarGz)
48        } else if path.ends_with(".tar.bz2") || ext == "tbz2" {
49            Some(Self::TarBz2)
50        } else if ext == "tar" {
51            Some(Self::Tar)
52        } else if ext == "zip" {
53            Some(Self::Zip)
54        } else {
55            None
56        }
57    }
58}
59
60/// Archive backend for creating, extracting, and listing archives.
61#[derive(Debug, Default, Clone)]
62pub struct ArchiveManager;
63
64impl ArchiveManager {
65    /// Create a new archive backend.
66    #[must_use]
67    pub const fn new() -> Self {
68        Self
69    }
70
71    /// Create an archive from a directory.
72    pub fn create_archive<'a>(
73        &'a self,
74        source_dir: &'a str,
75        output_path: &'a str,
76        format: ArchiveFormat,
77    ) -> BoxFuture<'a, Result<ArchiveInfo, VfsError>> {
78        Box::pin(async move {
79            let source = Path::new(source_dir);
80            if !source.exists() {
81                return Err(VfsError::NotFound(source_dir.to_string()));
82            }
83
84            match format {
85                ArchiveFormat::TarGz => create_tar_gz(source, output_path).await,
86                ArchiveFormat::Tar => create_tar(source, output_path).await,
87                ArchiveFormat::Zip => create_zip(source, output_path).await,
88                ArchiveFormat::TarBz2 => create_tar_bz2(source, output_path).await,
89            }
90        })
91    }
92
93    /// Extract an archive to a directory.
94    pub fn extract_archive<'a>(
95        &'a self,
96        archive_path: &'a str,
97        dest_dir: &'a str,
98        policy: ConflictPolicy,
99    ) -> BoxFuture<'a, Result<(), VfsError>> {
100        Box::pin(async move {
101            let format = ArchiveFormat::from_path(archive_path).ok_or_else(|| {
102                VfsError::Unsupported(format!("unknown archive format: {archive_path}"))
103            })?;
104            let dest = Path::new(dest_dir);
105            tokio::fs::create_dir_all(dest)
106                .await
107                .map_err(VfsError::Io)?;
108
109            match format {
110                ArchiveFormat::TarGz | ArchiveFormat::Tar | ArchiveFormat::TarBz2 => {
111                    extract_tar(archive_path, dest, format, policy).await
112                }
113                ArchiveFormat::Zip => extract_zip(archive_path, dest, policy).await,
114            }
115        })
116    }
117
118    /// List archive contents.
119    pub fn list_contents<'a>(
120        &'a self,
121        archive_path: &'a str,
122    ) -> BoxFuture<'a, Result<ArchiveInfo, VfsError>> {
123        Box::pin(async move {
124            let format = ArchiveFormat::from_path(archive_path).ok_or_else(|| {
125                VfsError::Unsupported(format!("unknown archive format: {archive_path}"))
126            })?;
127            match format {
128                ArchiveFormat::TarGz | ArchiveFormat::Tar | ArchiveFormat::TarBz2 => {
129                    list_tar(archive_path, format).await
130                }
131                ArchiveFormat::Zip => list_zip(archive_path).await,
132            }
133        })
134    }
135}
136
137// ── tar helpers ─────────────────────────────────────────────────────────────
138
139async fn create_tar_gz(source: &Path, output_path: &str) -> Result<ArchiveInfo, VfsError> {
140    let output = output_path.to_string();
141    let source = source.to_path_buf();
142    tokio::task::spawn_blocking(move || {
143        let file = std::fs::File::create(&output).map_err(VfsError::Io)?;
144        let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
145        let mut tar = tar::Builder::new(encoder);
146        tar.append_dir_all(".", &source).map_err(VfsError::Io)?;
147        tar.finish().map_err(VfsError::Io)?;
148        let compressed_size = std::fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
149        Ok(ArchiveInfo {
150            entries: Vec::new(),
151            format: "tar.gz".to_string(),
152            compressed_size,
153        })
154    })
155    .await
156    .map_err(|e| VfsError::Unsupported(e.to_string()))?
157}
158
159async fn create_tar_bz2(source: &Path, output_path: &str) -> Result<ArchiveInfo, VfsError> {
160    let output = output_path.to_string();
161    let source = source.to_path_buf();
162    tokio::task::spawn_blocking(move || {
163        let file = std::fs::File::create(&output).map_err(VfsError::Io)?;
164        let encoder = bzip2::write::BzEncoder::new(file, bzip2::Compression::default());
165        let mut tar = tar::Builder::new(encoder);
166        tar.append_dir_all(".", &source).map_err(VfsError::Io)?;
167        let encoder = tar.into_inner().map_err(VfsError::Io)?;
168        let _ = encoder.finish().map_err(VfsError::Io)?;
169        let compressed_size = std::fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
170        Ok(ArchiveInfo {
171            entries: Vec::new(),
172            format: "tar.bz2".to_string(),
173            compressed_size,
174        })
175    })
176    .await
177    .map_err(|e| VfsError::Unsupported(e.to_string()))?
178}
179
180async fn create_tar(source: &Path, output_path: &str) -> Result<ArchiveInfo, VfsError> {
181    let output = output_path.to_string();
182    let source = source.to_path_buf();
183    tokio::task::spawn_blocking(move || {
184        let file = std::fs::File::create(&output).map_err(VfsError::Io)?;
185        let mut tar = tar::Builder::new(file);
186        tar.append_dir_all(".", &source).map_err(VfsError::Io)?;
187        tar.finish().map_err(VfsError::Io)?;
188        let compressed_size = std::fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
189        Ok(ArchiveInfo {
190            entries: Vec::new(),
191            format: "tar".to_string(),
192            compressed_size,
193        })
194    })
195    .await
196    .map_err(|e| VfsError::Unsupported(e.to_string()))?
197}
198
199async fn extract_tar(
200    archive_path: &str,
201    dest: &Path,
202    format: ArchiveFormat,
203    _policy: ConflictPolicy,
204) -> Result<(), VfsError> {
205    let archive = archive_path.to_string();
206    let dest = dest.to_path_buf();
207    tokio::task::spawn_blocking(move || {
208        let file = std::fs::File::open(&archive).map_err(VfsError::Io)?;
209        match format {
210            ArchiveFormat::TarGz => {
211                let decoder = flate2::read::GzDecoder::new(file);
212                let mut tar = tar::Archive::new(decoder);
213                tar.unpack(&dest).map_err(VfsError::Io)?;
214            }
215            ArchiveFormat::TarBz2 => {
216                let decoder = bzip2::read::BzDecoder::new(file);
217                let mut tar = tar::Archive::new(decoder);
218                tar.unpack(&dest).map_err(VfsError::Io)?;
219            }
220            ArchiveFormat::Tar => {
221                let mut tar = tar::Archive::new(file);
222                tar.unpack(&dest).map_err(VfsError::Io)?;
223            }
224            ArchiveFormat::Zip => {
225                return Err(VfsError::Unsupported(
226                    "zip extraction handled separately".into(),
227                ));
228            }
229        }
230        Ok(())
231    })
232    .await
233    .map_err(|e| VfsError::Unsupported(e.to_string()))?
234}
235
236async fn list_tar(archive_path: &str, format: ArchiveFormat) -> Result<ArchiveInfo, VfsError> {
237    let archive = archive_path.to_string();
238    tokio::task::spawn_blocking(move || {
239        let file = std::fs::File::open(&archive).map_err(VfsError::Io)?;
240        let mut entries_out = Vec::new();
241        match format {
242            ArchiveFormat::TarGz => {
243                let decoder = flate2::read::GzDecoder::new(file);
244                let mut tar = tar::Archive::new(decoder);
245                for e in tar.entries().map_err(VfsError::Io)? {
246                    let e = e.map_err(VfsError::Io)?;
247                    entries_out.push(synwire_core::vfs::types::ArchiveEntry {
248                        path: e.path().map_err(VfsError::Io)?.display().to_string(),
249                        is_dir: e.header().entry_type().is_dir(),
250                        size: e.header().size().unwrap_or(0),
251                    });
252                }
253            }
254            ArchiveFormat::TarBz2 => {
255                let decoder = bzip2::read::BzDecoder::new(file);
256                let mut tar = tar::Archive::new(decoder);
257                for e in tar.entries().map_err(VfsError::Io)? {
258                    let e = e.map_err(VfsError::Io)?;
259                    entries_out.push(synwire_core::vfs::types::ArchiveEntry {
260                        path: e.path().map_err(VfsError::Io)?.display().to_string(),
261                        is_dir: e.header().entry_type().is_dir(),
262                        size: e.header().size().unwrap_or(0),
263                    });
264                }
265            }
266            ArchiveFormat::Tar => {
267                let mut tar = tar::Archive::new(file);
268                for e in tar.entries().map_err(VfsError::Io)? {
269                    let e = e.map_err(VfsError::Io)?;
270                    entries_out.push(synwire_core::vfs::types::ArchiveEntry {
271                        path: e.path().map_err(VfsError::Io)?.display().to_string(),
272                        is_dir: e.header().entry_type().is_dir(),
273                        size: e.header().size().unwrap_or(0),
274                    });
275                }
276            }
277            ArchiveFormat::Zip => {
278                return Err(VfsError::Unsupported(
279                    "zip listing handled separately".into(),
280                ));
281            }
282        }
283        let compressed_size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
284        Ok(ArchiveInfo {
285            entries: entries_out,
286            format: "tar".to_string(),
287            compressed_size,
288        })
289    })
290    .await
291    .map_err(|e| VfsError::Unsupported(e.to_string()))?
292}
293
294// ── zip helpers ──────────────────────────────────────────────────────────────
295
296async fn create_zip(source: &Path, output_path: &str) -> Result<ArchiveInfo, VfsError> {
297    let output = output_path.to_string();
298    let source = source.to_path_buf();
299    tokio::task::spawn_blocking(move || {
300        let file = std::fs::File::create(&output).map_err(VfsError::Io)?;
301        let mut zip = zip::ZipWriter::new(file);
302        let opts: zip::write::FileOptions<'_, ()> = zip::write::FileOptions::default();
303        write_dir_to_zip(&mut zip, &source, &source, opts)?;
304        let _ = zip
305            .finish()
306            .map_err(|e| VfsError::Unsupported(e.to_string()))?;
307        let compressed_size = std::fs::metadata(&output).map(|m| m.len()).unwrap_or(0);
308        Ok(ArchiveInfo {
309            entries: Vec::new(),
310            format: "zip".to_string(),
311            compressed_size,
312        })
313    })
314    .await
315    .map_err(|e| VfsError::Unsupported(e.to_string()))?
316}
317
318fn write_dir_to_zip(
319    zip: &mut zip::ZipWriter<std::fs::File>,
320    base: &Path,
321    dir: &Path,
322    opts: zip::write::FileOptions<'_, ()>,
323) -> Result<(), VfsError> {
324    for entry in std::fs::read_dir(dir).map_err(VfsError::Io)? {
325        let entry = entry.map_err(VfsError::Io)?;
326        let path = entry.path();
327        let name = path
328            .strip_prefix(base)
329            .map_err(|e| VfsError::Unsupported(e.to_string()))?
330            .display()
331            .to_string();
332        if path.is_dir() {
333            zip.add_directory(&name, opts)
334                .map_err(|e| VfsError::Unsupported(e.to_string()))?;
335            write_dir_to_zip(zip, base, &path, opts)?;
336        } else {
337            zip.start_file(&name, opts)
338                .map_err(|e| VfsError::Unsupported(e.to_string()))?;
339            let mut f = std::fs::File::open(&path).map_err(VfsError::Io)?;
340            let mut buf = Vec::new();
341            let _ = f.read_to_end(&mut buf).map_err(VfsError::Io)?;
342            zip.write_all(&buf)
343                .map_err(|e| VfsError::Unsupported(e.to_string()))?;
344        }
345    }
346    Ok(())
347}
348
349async fn extract_zip(
350    archive_path: &str,
351    dest: &Path,
352    policy: ConflictPolicy,
353) -> Result<(), VfsError> {
354    let archive = archive_path.to_string();
355    let dest = dest.to_path_buf();
356    tokio::task::spawn_blocking(move || {
357        let file = std::fs::File::open(&archive).map_err(VfsError::Io)?;
358        let mut zip =
359            zip::ZipArchive::new(file).map_err(|e| VfsError::Unsupported(e.to_string()))?;
360        for i in 0..zip.len() {
361            let mut entry = zip
362                .by_index(i)
363                .map_err(|e| VfsError::Unsupported(e.to_string()))?;
364            let out_path = dest.join(
365                entry
366                    .enclosed_name()
367                    .ok_or_else(|| VfsError::Unsupported("circular symlink".into()))?,
368            );
369            if entry.is_dir() {
370                std::fs::create_dir_all(&out_path).map_err(VfsError::Io)?;
371            } else {
372                if out_path.exists() {
373                    match policy {
374                        ConflictPolicy::Skip => continue,
375                        ConflictPolicy::Fail => {
376                            return Err(VfsError::Unsupported(format!(
377                                "conflict: {} already exists",
378                                out_path.display()
379                            )));
380                        }
381                        ConflictPolicy::Overwrite => {}
382                    }
383                }
384                if let Some(parent) = out_path.parent() {
385                    std::fs::create_dir_all(parent).map_err(VfsError::Io)?;
386                }
387                let mut out_file = std::fs::File::create(&out_path).map_err(VfsError::Io)?;
388                let _ = std::io::copy(&mut entry, &mut out_file).map_err(VfsError::Io)?;
389            }
390        }
391        Ok(())
392    })
393    .await
394    .map_err(|e| VfsError::Unsupported(e.to_string()))?
395}
396
397async fn list_zip(archive_path: &str) -> Result<ArchiveInfo, VfsError> {
398    let archive = archive_path.to_string();
399    tokio::task::spawn_blocking(move || {
400        let file = std::fs::File::open(&archive).map_err(VfsError::Io)?;
401        let mut zip =
402            zip::ZipArchive::new(file).map_err(|e| VfsError::Unsupported(e.to_string()))?;
403        let mut entries = Vec::new();
404        for i in 0..zip.len() {
405            let entry = zip
406                .by_index(i)
407                .map_err(|e| VfsError::Unsupported(e.to_string()))?;
408            entries.push(synwire_core::vfs::types::ArchiveEntry {
409                path: entry.name().to_string(),
410                is_dir: entry.is_dir(),
411                size: entry.size(),
412            });
413        }
414        let compressed_size = std::fs::metadata(&archive).map(|m| m.len()).unwrap_or(0);
415        Ok(ArchiveInfo {
416            entries,
417            format: "zip".to_string(),
418            compressed_size,
419        })
420    })
421    .await
422    .map_err(|e| VfsError::Unsupported(e.to_string()))?
423}
424
425#[cfg(test)]
426#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
427mod tests {
428    use super::*;
429
430    #[tokio::test]
431    async fn test_tar_gz_round_trip() {
432        let tmp = tempdir();
433        let src = tmp.join("src");
434        std::fs::create_dir_all(&src).expect("mkdir");
435        std::fs::write(src.join("hello.txt"), b"hello").expect("write");
436
437        let archive_path = tmp.join("out.tar.gz").display().to_string();
438        let backend = ArchiveManager::new();
439        let _ = backend
440            .create_archive(
441                &src.display().to_string(),
442                &archive_path,
443                ArchiveFormat::TarGz,
444            )
445            .await
446            .expect("create");
447
448        let dst = tmp.join("dst");
449        std::fs::create_dir_all(&dst).expect("mkdir dst");
450        backend
451            .extract_archive(
452                &archive_path,
453                &dst.display().to_string(),
454                ConflictPolicy::Overwrite,
455            )
456            .await
457            .expect("extract");
458
459        assert!(dst.join("hello.txt").exists());
460    }
461
462    #[tokio::test]
463    async fn test_tar_bz2_round_trip() {
464        let tmp = tempdir();
465        let src = tmp.join("src");
466        std::fs::create_dir_all(&src).expect("mkdir");
467        std::fs::write(src.join("hello.txt"), b"hello bz2").expect("write");
468
469        let archive_path = tmp.join("out.tar.bz2").display().to_string();
470        let backend = ArchiveManager::new();
471        let info = backend
472            .create_archive(
473                &src.display().to_string(),
474                &archive_path,
475                ArchiveFormat::TarBz2,
476            )
477            .await
478            .expect("create");
479        assert_eq!(info.format, "tar.bz2");
480        assert!(info.compressed_size > 0);
481
482        let listing = backend.list_contents(&archive_path).await.expect("list");
483        assert!(!listing.entries.is_empty());
484
485        let dst = tmp.join("dst");
486        std::fs::create_dir_all(&dst).expect("mkdir dst");
487        backend
488            .extract_archive(
489                &archive_path,
490                &dst.display().to_string(),
491                ConflictPolicy::Overwrite,
492            )
493            .await
494            .expect("extract");
495
496        assert!(dst.join("hello.txt").exists());
497        assert_eq!(
498            std::fs::read_to_string(dst.join("hello.txt")).expect("read"),
499            "hello bz2"
500        );
501    }
502
503    #[tokio::test]
504    async fn test_zip_round_trip() {
505        let tmp = tempdir();
506        let src = tmp.join("src");
507        std::fs::create_dir_all(&src).expect("mkdir");
508        std::fs::write(src.join("data.txt"), b"data").expect("write");
509
510        let archive_path = tmp.join("out.zip").display().to_string();
511        let backend = ArchiveManager::new();
512        let _ = backend
513            .create_archive(
514                &src.display().to_string(),
515                &archive_path,
516                ArchiveFormat::Zip,
517            )
518            .await
519            .expect("create");
520
521        let dst = tmp.join("dst");
522        std::fs::create_dir_all(&dst).expect("mkdir dst");
523        backend
524            .extract_archive(
525                &archive_path,
526                &dst.display().to_string(),
527                ConflictPolicy::Overwrite,
528            )
529            .await
530            .expect("extract");
531
532        assert!(dst.join("data.txt").exists());
533    }
534
535    #[tokio::test]
536    async fn test_conflict_policy_fail() {
537        let tmp = tempdir();
538        let src = tmp.join("src");
539        std::fs::create_dir_all(&src).expect("mkdir");
540        std::fs::write(src.join("f.txt"), b"original").expect("write");
541
542        let archive_path = tmp.join("out.zip").display().to_string();
543        let backend = ArchiveManager::new();
544        let _ = backend
545            .create_archive(
546                &src.display().to_string(),
547                &archive_path,
548                ArchiveFormat::Zip,
549            )
550            .await
551            .expect("create");
552
553        let dst = tmp.join("dst");
554        std::fs::create_dir_all(&dst).expect("mkdir");
555        std::fs::write(dst.join("f.txt"), b"existing").expect("prewrite");
556
557        let err = backend
558            .extract_archive(
559                &archive_path,
560                &dst.display().to_string(),
561                ConflictPolicy::Fail,
562            )
563            .await;
564        assert!(err.is_err());
565    }
566
567    fn tempdir() -> std::path::PathBuf {
568        let path = std::env::temp_dir().join(format!("synwire-test-{}", uuid::Uuid::new_v4()));
569        std::fs::create_dir_all(&path).expect("tempdir");
570        path
571    }
572}