this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at tangled-ci 454 lines 16 kB view raw
1use super::containers::total_input_bytes; 2use crate::progress::{OutputTarget, ProgressArgs, ProgressReader, create_progress_bar}; 3use crate::utils::{ 4 CmprssInput, CmprssOutput, CommonArgs, CompressionLevelValidator, Compressor, 5 DefaultCompressionValidator, ExtractedTarget, LevelArgs, Result, 6}; 7use anyhow::{anyhow, bail}; 8use clap::Args; 9use indicatif::ProgressBar; 10use std::fs::{File, OpenOptions}; 11use std::io::{self, Seek, SeekFrom, Write}; 12use std::path::Path; 13use tempfile::tempfile; 14use zip::read::ZipArchive; 15use zip::write::FileOptions; 16use zip::{CompressionMethod, ZipWriter}; 17 18#[derive(Args, Debug)] 19pub struct ZipArgs { 20 #[clap(flatten)] 21 pub common_args: CommonArgs, 22 23 #[clap(flatten)] 24 pub level_args: LevelArgs, 25 26 #[clap(flatten)] 27 pub progress_args: ProgressArgs, 28} 29 30#[derive(Clone)] 31pub struct Zip { 32 pub compression_level: i32, 33 pub progress_args: ProgressArgs, 34} 35 36impl Default for Zip { 37 fn default() -> Self { 38 Zip { 39 compression_level: DefaultCompressionValidator.default_level(), 40 progress_args: ProgressArgs::default(), 41 } 42 } 43} 44 45impl Zip { 46 pub fn new(args: &ZipArgs) -> Zip { 47 Zip { 48 compression_level: args.level_args.resolve(&DefaultCompressionValidator), 49 progress_args: args.progress_args, 50 } 51 } 52 53 fn file_options(&self) -> FileOptions<'static, ()> { 54 FileOptions::<()>::default() 55 .compression_method(CompressionMethod::Deflated) 56 .compression_level(Some(self.compression_level as i64)) 57 .large_file(true) 58 } 59 60 fn extract_seekable<R: std::io::Read + Seek>( 61 &self, 62 reader: R, 63 size: u64, 64 out_dir: &Path, 65 ) -> Result { 66 let bar = create_progress_bar(Some(size), self.progress_args.progress, OutputTarget::File); 67 let reader = ProgressReader::new(reader, bar.clone()); 68 let mut archive = ZipArchive::new(reader)?; 69 archive.extract(out_dir)?; 70 if let Some(b) = bar { 71 b.finish(); 72 } 73 Ok(()) 74 } 75 76 fn compress_to_file<W: Write + Seek>( 77 &self, 78 input: CmprssInput, 79 writer: W, 80 bar: Option<&ProgressBar>, 81 ) -> Result { 82 let mut zip_writer = ZipWriter::new(writer); 83 self.add_entries(&mut zip_writer, input, bar)?; 84 zip_writer.finish()?; 85 Ok(()) 86 } 87 88 /// Add the given input as entries to an existing `ZipWriter`. Shared by 89 /// `compress_to_file` and the append path, which respectively initialize 90 /// the writer via `ZipWriter::new` and `ZipWriter::new_append`. 91 fn add_entries<W: Write + Seek>( 92 &self, 93 zip_writer: &mut ZipWriter<W>, 94 input: CmprssInput, 95 bar: Option<&ProgressBar>, 96 ) -> Result { 97 let options = self.file_options(); 98 match input { 99 CmprssInput::Path(paths) => { 100 for path in paths { 101 if path.is_file() { 102 let name = path 103 .file_name() 104 .ok_or_else(|| anyhow!("input path has no file name: {:?}", path))? 105 .to_string_lossy(); 106 zip_writer.start_file(name, options)?; 107 let f = File::open(&path)?; 108 let mut reader = ProgressReader::new(f, bar.cloned()); 109 io::copy(&mut reader, zip_writer)?; 110 } else if path.is_dir() { 111 // Use the directory as the base and add its contents 112 let base = path.parent().unwrap_or(&path); 113 add_directory(zip_writer, base, &path, options, bar)?; 114 } else { 115 bail!("zip does not support this file type"); 116 } 117 } 118 } 119 CmprssInput::Pipe(mut pipe) => { 120 // For pipe input, we'll create a single file named "archive" 121 zip_writer.start_file("archive", options)?; 122 io::copy(&mut pipe, zip_writer)?; 123 } 124 CmprssInput::Reader(_) => { 125 bail!("zip does not accept an in-memory reader input"); 126 } 127 } 128 Ok(()) 129 } 130} 131 132impl Compressor for Zip { 133 fn name(&self) -> &str { 134 "zip" 135 } 136 137 /// Zip extracts to a directory by default 138 fn default_extracted_target(&self) -> ExtractedTarget { 139 ExtractedTarget::Directory 140 } 141 142 fn compress(&self, input: CmprssInput, output: CmprssOutput) -> Result { 143 match output { 144 CmprssOutput::Path(ref path) => { 145 let total = match &input { 146 CmprssInput::Path(paths) => Some(total_input_bytes(paths)), 147 _ => None, 148 }; 149 let bar = 150 create_progress_bar(total, self.progress_args.progress, OutputTarget::File); 151 let file = File::create(path)?; 152 self.compress_to_file(input, file, bar.as_ref())?; 153 if let Some(b) = bar { 154 b.finish(); 155 } 156 Ok(()) 157 } 158 CmprssOutput::Pipe(mut pipe) => { 159 // Create a temporary file to write the zip to 160 let mut temp_file = tempfile()?; 161 self.compress_to_file(input, &mut temp_file, None)?; 162 163 // Reset the file position to the beginning 164 temp_file.seek(SeekFrom::Start(0))?; 165 166 // Copy the temporary file to the pipe 167 io::copy(&mut temp_file, &mut pipe)?; 168 Ok(()) 169 } 170 CmprssOutput::Writer(mut writer) => { 171 let mut temp_file = tempfile()?; 172 self.compress_to_file(input, &mut temp_file, None)?; 173 temp_file.seek(SeekFrom::Start(0))?; 174 io::copy(&mut temp_file, &mut writer)?; 175 Ok(()) 176 } 177 } 178 } 179 180 fn extract(&self, input: CmprssInput, output: CmprssOutput) -> Result { 181 match output { 182 CmprssOutput::Path(ref out_dir) => { 183 // Create the output directory if it doesn't exist 184 if !out_dir.exists() { 185 std::fs::create_dir_all(out_dir)?; 186 } else if !out_dir.is_dir() { 187 bail!("zip extraction output must be a directory"); 188 } 189 190 match input { 191 CmprssInput::Path(paths) => { 192 if paths.len() != 1 { 193 bail!("zip extraction expects exactly one archive file"); 194 } 195 let file = File::open(&paths[0])?; 196 let size = file.metadata()?.len(); 197 self.extract_seekable(file, size, out_dir) 198 } 199 CmprssInput::Pipe(mut pipe) => { 200 // Create a temporary file to store the zip content 201 let mut temp_file = tempfile()?; 202 203 // Copy from pipe to temporary file 204 io::copy(&mut pipe, &mut temp_file)?; 205 206 // Reset the file position to the beginning 207 temp_file.seek(SeekFrom::Start(0))?; 208 let size = temp_file.metadata()?.len(); 209 self.extract_seekable(temp_file, size, out_dir) 210 } 211 CmprssInput::Reader(_) => { 212 bail!( 213 "zip extraction does not accept an in-memory reader input (requires seekable input)" 214 ) 215 } 216 } 217 } 218 CmprssOutput::Pipe(_) => bail!("zip extraction to stdout is not supported"), 219 CmprssOutput::Writer(mut writer) => match input { 220 CmprssInput::Path(paths) => { 221 if paths.len() != 1 { 222 bail!("zip extraction expects exactly one archive file"); 223 } 224 let mut file = File::open(&paths[0])?; 225 io::copy(&mut file, &mut writer)?; 226 Ok(()) 227 } 228 CmprssInput::Pipe(mut pipe) => { 229 io::copy(&mut pipe, &mut writer)?; 230 Ok(()) 231 } 232 CmprssInput::Reader(mut reader) => { 233 io::copy(&mut reader, &mut writer)?; 234 Ok(()) 235 } 236 }, 237 } 238 } 239 240 fn append(&self, input: CmprssInput, output: CmprssOutput) -> Result { 241 let path = match output { 242 CmprssOutput::Path(p) => p, 243 _ => bail!("zip append requires the archive path as the output target"), 244 }; 245 if !path.is_file() { 246 bail!("zip append target must be an existing file: {:?}", path); 247 } 248 249 let total = match &input { 250 CmprssInput::Path(paths) => Some(total_input_bytes(paths)), 251 _ => None, 252 }; 253 let bar = create_progress_bar(total, self.progress_args.progress, OutputTarget::File); 254 255 let file = OpenOptions::new().read(true).write(true).open(&path)?; 256 let mut zip_writer = ZipWriter::new_append(file)?; 257 self.add_entries(&mut zip_writer, input, bar.as_ref())?; 258 zip_writer.finish()?; 259 if let Some(b) = bar { 260 b.finish(); 261 } 262 Ok(()) 263 } 264 265 fn list(&self, input: CmprssInput) -> Result { 266 // ZipArchive requires a seekable reader. For non-path inputs we must 267 // buffer into a tempfile first. 268 let stdout = io::stdout(); 269 let mut out = stdout.lock(); 270 match input { 271 CmprssInput::Path(paths) => { 272 if paths.len() != 1 { 273 bail!("zip listing expects exactly one archive file"); 274 } 275 let archive = ZipArchive::new(File::open(&paths[0])?)?; 276 for name in archive.file_names() { 277 writeln!(out, "{}", name)?; 278 } 279 } 280 CmprssInput::Pipe(mut pipe) => { 281 let mut temp = tempfile()?; 282 io::copy(&mut pipe, &mut temp)?; 283 temp.seek(SeekFrom::Start(0))?; 284 let archive = ZipArchive::new(temp)?; 285 for name in archive.file_names() { 286 writeln!(out, "{}", name)?; 287 } 288 } 289 CmprssInput::Reader(mut reader) => { 290 let mut temp = tempfile()?; 291 io::copy(&mut reader, &mut temp)?; 292 temp.seek(SeekFrom::Start(0))?; 293 let archive = ZipArchive::new(temp)?; 294 for name in archive.file_names() { 295 writeln!(out, "{}", name)?; 296 } 297 } 298 } 299 Ok(()) 300 } 301} 302 303fn add_directory<W: Write + Seek>( 304 zip: &mut ZipWriter<W>, 305 base: &Path, 306 path: &Path, 307 options: FileOptions<'static, ()>, 308 bar: Option<&ProgressBar>, 309) -> Result { 310 for entry in std::fs::read_dir(path)? { 311 let entry = entry?; 312 let entry_path = entry.path(); 313 // Get relative path for archive entry 314 // `entry_path` is a direct child of `path`, which itself sits under 315 // `base`, so stripping always succeeds. 316 let name = entry_path 317 .strip_prefix(base) 318 .expect("entry path is under base") 319 .to_string_lossy() 320 .replace('\\', "/"); 321 if entry_path.is_file() { 322 zip.start_file(name, options)?; 323 let f = File::open(&entry_path)?; 324 let mut reader = ProgressReader::new(f, bar.cloned()); 325 io::copy(&mut reader, zip)?; 326 } else if entry_path.is_dir() { 327 // Ensure directory entry ends with '/' 328 let dir_name = name.clone() + "/"; 329 zip.add_directory(dir_name, options)?; 330 add_directory(zip, base, &entry_path, options, bar)?; 331 } 332 } 333 Ok(()) 334} 335 336#[cfg(test)] 337mod tests { 338 use super::*; 339 use crate::test_utils::*; 340 use assert_fs::prelude::*; 341 use predicates::prelude::*; 342 use std::path::PathBuf; 343 344 /// Test the basic interface of the Zip compressor 345 #[test] 346 fn test_zip_interface() { 347 let compressor = Zip::default(); 348 test_compressor_interface(&compressor, "zip", Some("zip")); 349 } 350 351 /// Test the default compression level 352 #[test] 353 fn test_zip_default_compression() -> Result { 354 let compressor = Zip::default(); 355 test_compression(&compressor) 356 } 357 358 /// Test fast compression level 359 #[test] 360 fn test_zip_fast_compression() -> Result { 361 let fast_compressor = Zip { 362 compression_level: 1, 363 progress_args: ProgressArgs::default(), 364 }; 365 test_compression(&fast_compressor) 366 } 367 368 /// Test best compression level 369 #[test] 370 fn test_zip_best_compression() -> Result { 371 let best_compressor = Zip { 372 compression_level: 9, 373 progress_args: ProgressArgs::default(), 374 }; 375 test_compression(&best_compressor) 376 } 377 378 /// Append new entries into an existing zip and confirm both old and new 379 /// entries extract correctly. 380 #[test] 381 fn test_append_adds_entries() -> Result { 382 let compressor = Zip::default(); 383 let working_dir = assert_fs::TempDir::new()?; 384 385 let original = working_dir.child("original.txt"); 386 original.write_str("original contents")?; 387 let extra = working_dir.child("extra.txt"); 388 extra.write_str("appended contents")?; 389 390 let archive = working_dir.child("archive.zip"); 391 compressor.compress( 392 CmprssInput::Path(vec![original.path().to_path_buf()]), 393 CmprssOutput::Path(archive.path().to_path_buf()), 394 )?; 395 let size_before = std::fs::metadata(archive.path())?.len(); 396 397 compressor.append( 398 CmprssInput::Path(vec![extra.path().to_path_buf()]), 399 CmprssOutput::Path(archive.path().to_path_buf()), 400 )?; 401 let size_after = std::fs::metadata(archive.path())?.len(); 402 assert!( 403 size_after > size_before, 404 "archive did not grow after append: {size_before} -> {size_after}", 405 ); 406 407 let extract_dir = working_dir.child("extracted"); 408 std::fs::create_dir_all(extract_dir.path())?; 409 compressor.extract( 410 CmprssInput::Path(vec![archive.path().to_path_buf()]), 411 CmprssOutput::Path(extract_dir.path().to_path_buf()), 412 )?; 413 extract_dir 414 .child("original.txt") 415 .assert(predicate::path::eq_file(original.path())); 416 extract_dir 417 .child("extra.txt") 418 .assert(predicate::path::eq_file(extra.path())); 419 Ok(()) 420 } 421 422 /// Test zip-specific functionality: directory handling 423 #[test] 424 fn test_directory_handling() -> Result { 425 let compressor = Zip::default(); 426 let dir = assert_fs::TempDir::new()?; 427 let file_path = dir.child("file.txt"); 428 file_path.write_str("directory test data")?; 429 let working_dir = assert_fs::TempDir::new()?; 430 let archive = working_dir.child("dir_archive.zip"); 431 archive.assert(predicate::path::missing()); 432 433 compressor.compress( 434 CmprssInput::Path(vec![dir.path().to_path_buf()]), 435 CmprssOutput::Path(archive.path().to_path_buf()), 436 )?; 437 archive.assert(predicate::path::is_file()); 438 439 let extract_dir = working_dir.child("extracted"); 440 std::fs::create_dir_all(extract_dir.path())?; 441 compressor.extract( 442 CmprssInput::Path(vec![archive.path().to_path_buf()]), 443 CmprssOutput::Path(extract_dir.path().to_path_buf()), 444 )?; 445 // When extracting a directory from a zip, the directory name is included in the path 446 // Since the archive stores the entire directory, the extracted file is contained in the directory 447 let dir_name: PathBuf = dir.path().file_name().unwrap().into(); 448 extract_dir 449 .child(dir_name) 450 .child("file.txt") 451 .assert(predicate::path::eq_file(file_path.path())); 452 Ok(()) 453 } 454}