Skip to main content

guts_compat/
archive.rs

1//! Archive generation for repository downloads.
2
3use bytes::{BufMut, BytesMut};
4use flate2::write::GzEncoder;
5use flate2::Compression;
6use std::io::Write;
7
8use crate::error::{CompatError, Result};
9
10/// Archive format.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ArchiveFormat {
13    /// Gzipped tar archive.
14    TarGz,
15    /// Zip archive.
16    Zip,
17}
18
19impl ArchiveFormat {
20    /// Get the content type for this format.
21    pub fn content_type(&self) -> &'static str {
22        match self {
23            Self::TarGz => "application/gzip",
24            Self::Zip => "application/zip",
25        }
26    }
27
28    /// Get the file extension for this format.
29    pub fn extension(&self) -> &'static str {
30        match self {
31            Self::TarGz => ".tar.gz",
32            Self::Zip => ".zip",
33        }
34    }
35
36    /// Get the Content-Disposition filename.
37    pub fn filename(&self, repo_name: &str, ref_name: &str) -> String {
38        // Sanitize ref name for filename
39        let safe_ref = ref_name.replace(['/', '\\', ':'], "-");
40        format!("{}-{}{}", repo_name, safe_ref, self.extension())
41    }
42}
43
44/// A file entry to include in an archive.
45#[derive(Debug, Clone)]
46pub struct ArchiveEntry {
47    /// Path within the archive (relative).
48    pub path: String,
49    /// File content.
50    pub content: Vec<u8>,
51    /// Unix file mode (e.g., 0o644 for regular file, 0o755 for executable).
52    pub mode: u32,
53    /// Whether this is an executable file.
54    pub executable: bool,
55}
56
57impl ArchiveEntry {
58    /// Create a new regular file entry.
59    pub fn file(path: String, content: Vec<u8>) -> Self {
60        Self {
61            path,
62            content,
63            mode: 0o644,
64            executable: false,
65        }
66    }
67
68    /// Create a new executable file entry.
69    pub fn executable(path: String, content: Vec<u8>) -> Self {
70        Self {
71            path,
72            content,
73            mode: 0o755,
74            executable: true,
75        }
76    }
77}
78
79/// Builder for creating tar.gz archives.
80pub struct TarGzBuilder {
81    entries: Vec<ArchiveEntry>,
82    prefix: String,
83}
84
85impl TarGzBuilder {
86    /// Create a new tar.gz builder with a prefix directory.
87    pub fn new(prefix: String) -> Self {
88        Self {
89            entries: Vec::new(),
90            prefix,
91        }
92    }
93
94    /// Add an entry to the archive.
95    pub fn add(&mut self, entry: ArchiveEntry) {
96        self.entries.push(entry);
97    }
98
99    /// Build the archive and return the bytes.
100    pub fn build(self) -> Result<Vec<u8>> {
101        let mut buffer = Vec::new();
102        let encoder = GzEncoder::new(&mut buffer, Compression::default());
103        let mut tar = tar::Builder::new(encoder);
104
105        for entry in self.entries {
106            let path = if self.prefix.is_empty() {
107                entry.path
108            } else {
109                format!("{}/{}", self.prefix, entry.path)
110            };
111
112            let mut header = tar::Header::new_gnu();
113            header.set_size(entry.content.len() as u64);
114            header.set_mode(entry.mode);
115            header.set_mtime(0); // Use 0 for reproducible builds
116            header.set_cksum();
117
118            tar.append_data(&mut header, &path, entry.content.as_slice())
119                .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
120        }
121
122        tar.into_inner()
123            .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?
124            .finish()
125            .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
126
127        Ok(buffer)
128    }
129}
130
131/// Builder for creating zip archives.
132pub struct ZipBuilder {
133    entries: Vec<ArchiveEntry>,
134    prefix: String,
135}
136
137impl ZipBuilder {
138    /// Create a new zip builder with a prefix directory.
139    pub fn new(prefix: String) -> Self {
140        Self {
141            entries: Vec::new(),
142            prefix,
143        }
144    }
145
146    /// Add an entry to the archive.
147    pub fn add(&mut self, entry: ArchiveEntry) {
148        self.entries.push(entry);
149    }
150
151    /// Build the archive and return the bytes.
152    pub fn build(self) -> Result<Vec<u8>> {
153        use std::io::Cursor;
154        use zip::write::SimpleFileOptions;
155        use zip::ZipWriter;
156
157        let mut buffer = Cursor::new(Vec::new());
158        let mut zip = ZipWriter::new(&mut buffer);
159
160        let options = SimpleFileOptions::default()
161            .compression_method(zip::CompressionMethod::Deflated)
162            .unix_permissions(0o644);
163
164        for entry in self.entries {
165            let path = if self.prefix.is_empty() {
166                entry.path
167            } else {
168                format!("{}/{}", self.prefix, entry.path)
169            };
170
171            let file_options = if entry.executable {
172                options.unix_permissions(0o755)
173            } else {
174                options
175            };
176
177            zip.start_file(&path, file_options)
178                .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
179            zip.write_all(&entry.content)
180                .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
181        }
182
183        zip.finish()
184            .map_err(|e| CompatError::ArchiveFailed(e.to_string()))?;
185
186        Ok(buffer.into_inner())
187    }
188}
189
190/// Create an archive from entries.
191pub fn create_archive(
192    format: ArchiveFormat,
193    prefix: String,
194    entries: Vec<ArchiveEntry>,
195) -> Result<Vec<u8>> {
196    match format {
197        ArchiveFormat::TarGz => {
198            let mut builder = TarGzBuilder::new(prefix);
199            for entry in entries {
200                builder.add(entry);
201            }
202            builder.build()
203        }
204        ArchiveFormat::Zip => {
205            let mut builder = ZipBuilder::new(prefix);
206            for entry in entries {
207                builder.add(entry);
208            }
209            builder.build()
210        }
211    }
212}
213
214/// Streaming archive builder for large repositories.
215///
216/// This allows building archives incrementally to avoid loading
217/// all files into memory at once.
218pub struct StreamingArchive {
219    buffer: BytesMut,
220    format: ArchiveFormat,
221}
222
223impl StreamingArchive {
224    /// Create a new streaming archive builder.
225    pub fn new(format: ArchiveFormat, capacity: usize) -> Self {
226        Self {
227            buffer: BytesMut::with_capacity(capacity),
228            format,
229        }
230    }
231
232    /// Get the format of this archive.
233    pub fn format(&self) -> ArchiveFormat {
234        self.format
235    }
236
237    /// Append raw bytes to the buffer.
238    pub fn append(&mut self, data: &[u8]) {
239        self.buffer.put_slice(data);
240    }
241
242    /// Get the current size.
243    pub fn len(&self) -> usize {
244        self.buffer.len()
245    }
246
247    /// Check if empty.
248    pub fn is_empty(&self) -> bool {
249        self.buffer.is_empty()
250    }
251
252    /// Take the buffer contents.
253    pub fn take(self) -> Vec<u8> {
254        self.buffer.to_vec()
255    }
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_archive_format() {
264        assert_eq!(ArchiveFormat::TarGz.content_type(), "application/gzip");
265        assert_eq!(ArchiveFormat::Zip.content_type(), "application/zip");
266        assert_eq!(ArchiveFormat::TarGz.extension(), ".tar.gz");
267        assert_eq!(ArchiveFormat::Zip.extension(), ".zip");
268    }
269
270    #[test]
271    fn test_archive_filename() {
272        assert_eq!(
273            ArchiveFormat::TarGz.filename("repo", "v1.0.0"),
274            "repo-v1.0.0.tar.gz"
275        );
276        assert_eq!(
277            ArchiveFormat::Zip.filename("repo", "feature/test"),
278            "repo-feature-test.zip"
279        );
280    }
281
282    #[test]
283    fn test_tar_gz_archive() {
284        let entries = vec![
285            ArchiveEntry::file("file1.txt".to_string(), b"Hello".to_vec()),
286            ArchiveEntry::file("dir/file2.txt".to_string(), b"World".to_vec()),
287        ];
288
289        let archive = create_archive(ArchiveFormat::TarGz, "test-repo".to_string(), entries);
290        assert!(archive.is_ok());
291
292        let bytes = archive.unwrap();
293        assert!(!bytes.is_empty());
294        // Check gzip magic bytes
295        assert_eq!(bytes[0], 0x1f);
296        assert_eq!(bytes[1], 0x8b);
297    }
298
299    #[test]
300    fn test_zip_archive() {
301        let entries = vec![
302            ArchiveEntry::file("file1.txt".to_string(), b"Hello".to_vec()),
303            ArchiveEntry::executable("script.sh".to_string(), b"#!/bin/bash".to_vec()),
304        ];
305
306        let archive = create_archive(ArchiveFormat::Zip, "test-repo".to_string(), entries);
307        assert!(archive.is_ok());
308
309        let bytes = archive.unwrap();
310        assert!(!bytes.is_empty());
311        // Check zip magic bytes
312        assert_eq!(bytes[0], 0x50);
313        assert_eq!(bytes[1], 0x4b);
314    }
315
316    #[test]
317    fn test_archive_entry() {
318        let entry = ArchiveEntry::file("test.txt".to_string(), b"content".to_vec());
319        assert_eq!(entry.mode, 0o644);
320        assert!(!entry.executable);
321
322        let exec = ArchiveEntry::executable("run.sh".to_string(), b"#!/bin/sh".to_vec());
323        assert_eq!(exec.mode, 0o755);
324        assert!(exec.executable);
325    }
326}