sherpack_core/
archive.rs

1//! Archive creation and extraction for Sherpack packages
2//!
3//! Provides functionality to create and extract `.tar.gz` archives
4//! with the standard Sherpack archive structure.
5
6use flate2::Compression;
7use flate2::read::GzDecoder;
8use flate2::write::GzEncoder;
9use std::collections::HashMap;
10use std::fs::File;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13use tar::{Archive, Builder, Header};
14
15use crate::error::{CoreError, Result};
16use crate::manifest::Manifest;
17use crate::pack::LoadedPack;
18
19/// Create a tar.gz archive from a loaded pack
20///
21/// Returns the path to the created archive file.
22/// The archive includes:
23/// - MANIFEST (generated)
24/// - Pack.yaml
25/// - values.yaml
26/// - values.schema.yaml (if present)
27/// - templates/* (all template files)
28pub fn create_archive(pack: &LoadedPack, output: &Path) -> Result<PathBuf> {
29    // Generate manifest
30    let manifest = Manifest::generate(pack)?;
31    let manifest_content = manifest.to_string();
32
33    // Create output file
34    let file = File::create(output)?;
35    let encoder = GzEncoder::new(file, Compression::default());
36    let mut builder = Builder::new(encoder);
37
38    // Add MANIFEST first
39    add_bytes_to_archive(&mut builder, "MANIFEST", manifest_content.as_bytes())?;
40
41    // Add Pack.yaml
42    let pack_yaml = pack.root.join("Pack.yaml");
43    if pack_yaml.exists() {
44        add_file_to_archive(&mut builder, &pack_yaml, "Pack.yaml")?;
45    }
46
47    // Add values.yaml
48    if pack.values_path.exists() {
49        add_file_to_archive(&mut builder, &pack.values_path, "values.yaml")?;
50    }
51
52    // Add schema file if present
53    if let Some(schema_path) = &pack.schema_path
54        && schema_path.exists()
55    {
56        let schema_name = schema_path
57            .file_name()
58            .map(|n| n.to_string_lossy().to_string())
59            .unwrap_or_else(|| "values.schema.yaml".to_string());
60        add_file_to_archive(&mut builder, schema_path, &schema_name)?;
61    }
62
63    // Add template files
64    let template_files = pack.template_files()?;
65    for file_path in template_files {
66        let rel_path = file_path
67            .strip_prefix(&pack.root)
68            .unwrap_or(&file_path)
69            .to_string_lossy()
70            // Normalize path separators for cross-platform compatibility
71            .replace('\\', "/");
72        add_file_to_archive(&mut builder, &file_path, &rel_path)?;
73    }
74
75    // Finish the archive
76    let encoder = builder.into_inner()?;
77    encoder.finish()?;
78
79    Ok(output.to_path_buf())
80}
81
82/// Extract an archive to a destination directory
83pub fn extract_archive(archive_path: &Path, dest: &Path) -> Result<()> {
84    let file = File::open(archive_path)?;
85    let decoder = GzDecoder::new(file);
86    let mut archive = Archive::new(decoder);
87
88    // Create destination directory if it doesn't exist
89    std::fs::create_dir_all(dest)?;
90
91    archive.unpack(dest)?;
92
93    Ok(())
94}
95
96/// List files in an archive
97pub fn list_archive(archive_path: &Path) -> Result<Vec<ArchiveEntry>> {
98    let file = File::open(archive_path)?;
99    let decoder = GzDecoder::new(file);
100    let mut archive = Archive::new(decoder);
101
102    let mut entries = Vec::new();
103
104    for entry in archive.entries()? {
105        let entry = entry?;
106        let path = entry.path()?.to_string_lossy().to_string();
107        let size = entry.header().size()?;
108        let is_dir = entry.header().entry_type().is_dir();
109
110        entries.push(ArchiveEntry { path, size, is_dir });
111    }
112
113    Ok(entries)
114}
115
116/// Read a specific file from an archive
117pub fn read_file_from_archive(archive_path: &Path, file_path: &str) -> Result<Vec<u8>> {
118    let file = File::open(archive_path)?;
119    let decoder = GzDecoder::new(file);
120    let mut archive = Archive::new(decoder);
121
122    for entry in archive.entries()? {
123        let mut entry = entry?;
124        let path = entry.path()?.to_string_lossy().to_string();
125
126        if path == file_path {
127            let mut content = Vec::new();
128            entry.read_to_end(&mut content)?;
129            return Ok(content);
130        }
131    }
132
133    Err(CoreError::Archive {
134        message: format!("File not found in archive: {}", file_path),
135    })
136}
137
138/// Read the MANIFEST from an archive
139pub fn read_manifest_from_archive(archive_path: &Path) -> Result<Manifest> {
140    let content = read_file_from_archive(archive_path, "MANIFEST")?;
141    let text = String::from_utf8(content).map_err(|e| CoreError::Archive {
142        message: format!("Invalid UTF-8 in MANIFEST: {}", e),
143    })?;
144    Manifest::parse(&text)
145}
146
147/// Read all files from an archive in a single pass
148///
149/// Returns a HashMap mapping file paths to their contents.
150/// This is more efficient than multiple calls to `read_file_from_archive`.
151fn read_all_files_from_archive(archive_path: &Path) -> Result<HashMap<String, Vec<u8>>> {
152    let file = File::open(archive_path)?;
153    let decoder = GzDecoder::new(file);
154    let mut archive = Archive::new(decoder);
155    let mut contents = HashMap::new();
156
157    for entry in archive.entries()? {
158        let mut entry = entry?;
159        if entry.header().entry_type().is_dir() {
160            continue;
161        }
162
163        let path = entry.path()?.to_string_lossy().to_string();
164        let mut data = Vec::new();
165        entry.read_to_end(&mut data)?;
166        contents.insert(path, data);
167    }
168
169    Ok(contents)
170}
171
172/// Verify archive integrity by checking all file checksums
173///
174/// Uses single-pass reading for O(n) performance instead of O(n²).
175pub fn verify_archive(archive_path: &Path) -> Result<crate::manifest::VerificationResult> {
176    let manifest = read_manifest_from_archive(archive_path)?;
177
178    // Read all files in a single pass for O(n) performance
179    let file_contents = read_all_files_from_archive(archive_path)?;
180
181    manifest.verify_files(|path| {
182        file_contents
183            .get(path)
184            .cloned()
185            .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"))
186    })
187}
188
189/// Information about a file in an archive
190#[derive(Debug, Clone)]
191pub struct ArchiveEntry {
192    /// Relative path within the archive
193    pub path: String,
194    /// File size in bytes
195    pub size: u64,
196    /// Whether this is a directory
197    pub is_dir: bool,
198}
199
200/// Add a file to a tar archive
201fn add_file_to_archive<W: Write>(
202    builder: &mut Builder<W>,
203    file_path: &Path,
204    archive_path: &str,
205) -> Result<()> {
206    let content = std::fs::read(file_path)?;
207    add_bytes_to_archive(builder, archive_path, &content)
208}
209
210/// Add bytes to a tar archive with a given path
211fn add_bytes_to_archive<W: Write>(
212    builder: &mut Builder<W>,
213    archive_path: &str,
214    content: &[u8],
215) -> Result<()> {
216    let mut header = Header::new_gnu();
217    header.set_size(content.len() as u64);
218    header.set_mode(0o644);
219    header.set_mtime(0); // Reproducible builds: use epoch time
220    header.set_cksum();
221
222    builder.append_data(&mut header, archive_path, content)?;
223
224    Ok(())
225}
226
227/// Generate the default archive filename for a pack
228#[must_use]
229pub fn default_archive_name(pack: &LoadedPack) -> String {
230    format!(
231        "{}-{}.tar.gz",
232        pack.pack.metadata.name, pack.pack.metadata.version
233    )
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use tempfile::TempDir;
240
241    fn create_test_pack(dir: &Path) {
242        // Create Pack.yaml
243        std::fs::write(
244            dir.join("Pack.yaml"),
245            r#"apiVersion: sherpack/v1
246kind: application
247metadata:
248  name: testpack
249  version: 1.0.0
250"#,
251        )
252        .unwrap();
253
254        // Create values.yaml
255        std::fs::write(dir.join("values.yaml"), "replicas: 3\n").unwrap();
256
257        // Create templates directory
258        let templates_dir = dir.join("templates");
259        std::fs::create_dir_all(&templates_dir).unwrap();
260
261        // Create a template file
262        std::fs::write(
263            templates_dir.join("deployment.yaml"),
264            "apiVersion: apps/v1\nkind: Deployment\n",
265        )
266        .unwrap();
267    }
268
269    #[test]
270    fn test_create_and_extract_archive() {
271        let temp = TempDir::new().unwrap();
272        let pack_dir = temp.path().join("pack");
273        std::fs::create_dir_all(&pack_dir).unwrap();
274        create_test_pack(&pack_dir);
275
276        // Load pack
277        let pack = LoadedPack::load(&pack_dir).unwrap();
278
279        // Create archive
280        let archive_path = temp.path().join("test.tar.gz");
281        create_archive(&pack, &archive_path).unwrap();
282
283        assert!(archive_path.exists());
284
285        // List archive contents
286        let entries = list_archive(&archive_path).unwrap();
287        let paths: Vec<_> = entries.iter().map(|e| e.path.as_str()).collect();
288
289        assert!(paths.contains(&"MANIFEST"));
290        assert!(paths.contains(&"Pack.yaml"));
291        assert!(paths.contains(&"values.yaml"));
292        assert!(paths.iter().any(|p| p.contains("deployment.yaml")));
293
294        // Extract archive
295        let extract_dir = temp.path().join("extracted");
296        extract_archive(&archive_path, &extract_dir).unwrap();
297
298        assert!(extract_dir.join("MANIFEST").exists());
299        assert!(extract_dir.join("Pack.yaml").exists());
300        assert!(extract_dir.join("values.yaml").exists());
301    }
302
303    #[test]
304    fn test_read_manifest_from_archive() {
305        let temp = TempDir::new().unwrap();
306        let pack_dir = temp.path().join("pack");
307        std::fs::create_dir_all(&pack_dir).unwrap();
308        create_test_pack(&pack_dir);
309
310        let pack = LoadedPack::load(&pack_dir).unwrap();
311        let archive_path = temp.path().join("test.tar.gz");
312        create_archive(&pack, &archive_path).unwrap();
313
314        // Read manifest
315        let manifest = read_manifest_from_archive(&archive_path).unwrap();
316        assert_eq!(manifest.name, "testpack");
317        assert_eq!(manifest.pack_version.to_string(), "1.0.0");
318    }
319
320    #[test]
321    fn test_verify_archive() {
322        let temp = TempDir::new().unwrap();
323        let pack_dir = temp.path().join("pack");
324        std::fs::create_dir_all(&pack_dir).unwrap();
325        create_test_pack(&pack_dir);
326
327        let pack = LoadedPack::load(&pack_dir).unwrap();
328        let archive_path = temp.path().join("test.tar.gz");
329        create_archive(&pack, &archive_path).unwrap();
330
331        // Verify archive
332        let result = verify_archive(&archive_path).unwrap();
333        assert!(result.valid);
334        assert!(result.mismatched.is_empty());
335        assert!(result.missing.is_empty());
336    }
337
338    #[test]
339    fn test_default_archive_name() {
340        let temp = TempDir::new().unwrap();
341        create_test_pack(temp.path());
342
343        let pack = LoadedPack::load(temp.path()).unwrap();
344        let name = default_archive_name(&pack);
345
346        assert_eq!(name, "testpack-1.0.0.tar.gz");
347    }
348}