1use 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#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
13#[non_exhaustive]
14pub enum ConflictPolicy {
15 Skip,
17 Overwrite,
19 Fail,
21}
22
23#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
25#[non_exhaustive]
26pub enum ArchiveFormat {
27 TarGz,
29 TarBz2,
31 Tar,
33 Zip,
35}
36
37impl ArchiveFormat {
38 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#[derive(Debug, Default, Clone)]
62pub struct ArchiveManager;
63
64impl ArchiveManager {
65 #[must_use]
67 pub const fn new() -> Self {
68 Self
69 }
70
71 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 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 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
137async 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
294async 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}