1use bytes::{BufMut, BytesMut};
4use flate2::write::GzEncoder;
5use flate2::Compression;
6use std::io::Write;
7
8use crate::error::{CompatError, Result};
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ArchiveFormat {
13 TarGz,
15 Zip,
17}
18
19impl ArchiveFormat {
20 pub fn content_type(&self) -> &'static str {
22 match self {
23 Self::TarGz => "application/gzip",
24 Self::Zip => "application/zip",
25 }
26 }
27
28 pub fn extension(&self) -> &'static str {
30 match self {
31 Self::TarGz => ".tar.gz",
32 Self::Zip => ".zip",
33 }
34 }
35
36 pub fn filename(&self, repo_name: &str, ref_name: &str) -> String {
38 let safe_ref = ref_name.replace(['/', '\\', ':'], "-");
40 format!("{}-{}{}", repo_name, safe_ref, self.extension())
41 }
42}
43
44#[derive(Debug, Clone)]
46pub struct ArchiveEntry {
47 pub path: String,
49 pub content: Vec<u8>,
51 pub mode: u32,
53 pub executable: bool,
55}
56
57impl ArchiveEntry {
58 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 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
79pub struct TarGzBuilder {
81 entries: Vec<ArchiveEntry>,
82 prefix: String,
83}
84
85impl TarGzBuilder {
86 pub fn new(prefix: String) -> Self {
88 Self {
89 entries: Vec::new(),
90 prefix,
91 }
92 }
93
94 pub fn add(&mut self, entry: ArchiveEntry) {
96 self.entries.push(entry);
97 }
98
99 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); 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
131pub struct ZipBuilder {
133 entries: Vec<ArchiveEntry>,
134 prefix: String,
135}
136
137impl ZipBuilder {
138 pub fn new(prefix: String) -> Self {
140 Self {
141 entries: Vec::new(),
142 prefix,
143 }
144 }
145
146 pub fn add(&mut self, entry: ArchiveEntry) {
148 self.entries.push(entry);
149 }
150
151 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
190pub 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
214pub struct StreamingArchive {
219 buffer: BytesMut,
220 format: ArchiveFormat,
221}
222
223impl StreamingArchive {
224 pub fn new(format: ArchiveFormat, capacity: usize) -> Self {
226 Self {
227 buffer: BytesMut::with_capacity(capacity),
228 format,
229 }
230 }
231
232 pub fn format(&self) -> ArchiveFormat {
234 self.format
235 }
236
237 pub fn append(&mut self, data: &[u8]) {
239 self.buffer.put_slice(data);
240 }
241
242 pub fn len(&self) -> usize {
244 self.buffer.len()
245 }
246
247 pub fn is_empty(&self) -> bool {
249 self.buffer.is_empty()
250 }
251
252 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 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 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}