Skip to main content

conduit_cli/core/engine/
archive.rs

1use crate::errors::{ConduitError, ConduitResult};
2use std::fs::File;
3use std::io::{Read, Write};
4use std::path::Path;
5use zip::write::FileOptions;
6use zip::{ZipArchive, ZipWriter};
7
8pub struct SafeArchive;
9
10impl SafeArchive {
11    pub fn open<P: AsRef<Path>>(path: P) -> ConduitResult<ZipArchive<File>> {
12        let file = File::open(&path)?;
13        ZipArchive::new(file).map_err(|e| {
14            ConduitError::Storage(format!(
15                "Failed to open archive '{}': {}",
16                path.as_ref().display(),
17                e
18            ))
19        })
20    }
21
22    pub fn create<P: AsRef<Path>>(path: P) -> ConduitResult<ZipWriter<File>> {
23        let file = File::create(&path)?;
24        Ok(ZipWriter::new(file))
25    }
26
27    pub fn read_metadata(archive: &mut ZipArchive<File>, name: &str) -> ConduitResult<String> {
28        let mut content = String::new();
29        let mut file = Self::get_validated_file(archive, name, 25 * 1024 * 1024)?;
30        file.read_to_string(&mut content).map_err(|e| {
31            ConduitError::Io(std::io::Error::other(format!(
32                "Failed to read metadata '{name}': {e}"
33            )))
34        })?;
35        Ok(content)
36    }
37
38    pub fn read_bytes(archive: &mut ZipArchive<File>, name: &str) -> ConduitResult<Vec<u8>> {
39        let mut buffer = Vec::new();
40        let mut file = Self::get_validated_file(archive, name, 100 * 1024 * 1024)?;
41        file.read_to_end(&mut buffer).map_err(|e| {
42            ConduitError::Io(std::io::Error::other(format!(
43                "Failed to read bytes from '{name}': {e}"
44            )))
45        })?;
46        Ok(buffer)
47    }
48
49    pub fn add_file<W: Write + std::io::Seek>(
50        writer: &mut ZipWriter<W>,
51        name: &str,
52        content: &[u8],
53    ) -> ConduitResult<()> {
54        let options: FileOptions<()> = FileOptions::default()
55            .compression_method(zip::CompressionMethod::Deflated)
56            .unix_permissions(0o644);
57
58        writer.start_file(name, options).map_err(|e| {
59            ConduitError::Storage(format!("Failed to create entry '{name}' in archive: {e}"))
60        })?;
61        writer.write_all(content)?;
62        Ok(())
63    }
64
65    fn get_validated_file<'a>(
66        archive: &'a mut ZipArchive<File>,
67        name: &str,
68        size_limit: u64,
69    ) -> ConduitResult<zip::read::ZipFile<'a, File>> {
70        let normalized_name = name.replace('\\', "/");
71
72        if normalized_name.contains("..") || normalized_name.starts_with('/') {
73            return Err(ConduitError::Storage(format!(
74                "Security violation: malicious path detected: {name}"
75            )));
76        }
77
78        let file = archive
79            .by_name(name)
80            .map_err(|_| ConduitError::NotFound(format!("Entry '{name}' not found in archive")))?;
81
82        if file.size() > size_limit {
83            return Err(ConduitError::Storage(format!(
84                "Security violation: entry '{name}' size ({} MB) exceeds limit ({} MB)",
85                file.size() / 1024 / 1024,
86                size_limit / 1024 / 1024
87            )));
88        }
89
90        Ok(file)
91    }
92
93    pub fn read_and_deserialize<T>(archive: &mut ZipArchive<File>, name: &str) -> ConduitResult<T>
94    where
95        T: serde::de::DeserializeOwned,
96    {
97        let raw = Self::read_metadata(archive, name)?;
98
99        let extension = Path::new(name)
100            .extension()
101            .and_then(|ext| ext.to_str())
102            .map(str::to_lowercase);
103
104        match extension.as_deref() {
105            Some("json") => serde_json::from_str(&raw)
106                .map_err(|e| ConduitError::Parsing(format!("JSON error in {name}: {e}"))),
107            Some("toml") => toml::from_str(&raw)
108                .map_err(|e| ConduitError::Parsing(format!("TOML error in {name}: {e}"))),
109            _ => Err(ConduitError::Parsing(format!(
110                "Unsupported or missing file extension for deserialization: {name}"
111            ))),
112        }
113    }
114
115    pub fn serialize_and_add<T, W>(
116        writer: &mut zip::ZipWriter<W>,
117        name: &str,
118        data: &T,
119    ) -> ConduitResult<()>
120    where
121        T: serde::Serialize,
122        W: std::io::Write + std::io::Seek,
123    {
124        let extension = std::path::Path::new(name)
125            .extension()
126            .and_then(|ext| ext.to_str())
127            .map(str::to_lowercase);
128
129        let bytes = match extension.as_deref() {
130            Some("json") => serde_json::to_vec(data)
131                .map_err(|e| ConduitError::Parsing(format!("JSON serialize error: {e}")))?,
132            Some("toml" | "lock") => toml::to_string(data)
133                .map_err(|e| ConduitError::Parsing(format!("TOML serialize error: {e}")))?
134                .into_bytes(),
135            _ => {
136                return Err(ConduitError::Parsing(
137                    "Unsupported export format".to_string(),
138                ));
139            }
140        };
141
142        Self::add_file(writer, name, &bytes)
143    }
144
145    pub fn add_file_from_reader<W, R>(
146        writer: &mut ZipWriter<W>,
147        name: &str,
148        mut reader: R,
149    ) -> ConduitResult<()>
150    where
151        W: Write + std::io::Seek,
152        R: Read,
153    {
154        let options: FileOptions<()> = FileOptions::default()
155            .compression_method(zip::CompressionMethod::Deflated)
156            .unix_permissions(0o644);
157
158        writer.start_file(name, options).map_err(|e| {
159            ConduitError::Storage(format!("Failed to create entry '{name}' in archive: {e}"))
160        })?;
161
162        std::io::copy(&mut reader, writer).map_err(ConduitError::Io)?;
163
164        Ok(())
165    }
166}