arcthis 0.5.1

A command-line tool for reading and managing compressed files
use std::io::{self, Read, Write};
use std::path::Path;

use super::ArchiveSource;
use crate::archive::codec::{StreamCompression, decoder};
use crate::error::{ArcthisError, Result};
use crate::extract::{ExtractionStats, ExtractionWriter, PlannedEntry};
use crate::model::{
    ArchiveCapabilities, ArchiveEntry, ArchiveFormat, EntryCopyResult, EntryKind,
    EntryPathEncoding, VerificationResult,
};
use crate::security::ExtractionLimits;

pub(crate) struct StreamBackend {
    source: ArchiveSource,
    format: ArchiveFormat,
    entry_name: String,
}

impl StreamBackend {
    pub(crate) fn new(source: ArchiveSource, format: ArchiveFormat) -> Result<Self> {
        let entry_name = derive_entry_name(source.name(), format)?;
        Ok(Self {
            source,
            format,
            entry_name,
        })
    }

    fn compression(&self) -> Result<StreamCompression> {
        match self.format {
            ArchiveFormat::Gzip => Ok(StreamCompression::Gzip),
            ArchiveFormat::Bzip2 => Ok(StreamCompression::Bzip2),
            ArchiveFormat::Xz => Ok(StreamCompression::Xz),
            ArchiveFormat::Zstd => Ok(StreamCompression::Zstd),
            _ => Err(ArcthisError::UnsupportedOperation {
                message: format!("{} is not a single-stream format", self.format),
            }),
        }
    }

    fn reader(&self) -> Result<Box<dyn Read>> {
        decoder(self.source.reader()?, self.compression()?)
    }

    fn decoded_size(&self) -> Result<u64> {
        let mut reader = self.reader()?;
        io::copy(&mut reader, &mut io::sink()).map_err(|error| ArcthisError::InvalidArchive {
            message: format!("invalid {} stream: {error}", self.format),
        })
    }
}

impl super::ArchiveBackend for StreamBackend {
    fn format(&self) -> ArchiveFormat {
        self.format
    }

    fn capabilities(&self) -> ArchiveCapabilities {
        ArchiveCapabilities::single_stream()
    }

    fn validate(&self) -> Result<()> {
        let mut reader = self.reader()?;
        let mut byte = [0_u8; 1];
        reader
            .read(&mut byte)
            .map_err(|error| ArcthisError::InvalidArchive {
                message: format!("invalid {} stream: {error}", self.format),
            })?;
        Ok(())
    }

    fn entries(&self) -> Result<Vec<ArchiveEntry>> {
        let compressed_size = self.source.len()?;
        Ok(vec![ArchiveEntry {
            archive_index: 0,
            path: self.entry_name.clone(),
            path_encoding: EntryPathEncoding::Utf8,
            kind: EntryKind::File,
            size: self.decoded_size()?,
            compressed_size: Some(compressed_size),
            modified_time: None,
            encrypted: false,
            executable: false,
            symlink_target: None,
            crc32: None,
            mime_guess: None,
        }])
    }

    fn copy_entry_to(&self, path: &str, writer: &mut dyn Write) -> Result<EntryCopyResult> {
        if path != self.entry_name {
            return Err(ArcthisError::EntryNotFound {
                entry: path.to_owned(),
            });
        }
        let mut reader = self.reader()?;
        let bytes_written = io::copy(&mut reader, writer)
            .map_err(|error| ArcthisError::io("streaming compressed payload", error))?;
        Ok(EntryCopyResult { bytes_written })
    }

    fn extract_plan(
        &self,
        plan: &[PlannedEntry],
        staging_root: &Path,
        limits: &ExtractionLimits,
    ) -> Result<ExtractionStats> {
        let Some(entry) = plan
            .iter()
            .find(|entry| entry.archive_path == self.entry_name)
        else {
            return Ok(ExtractionStats::default());
        };
        let mut reader = self.reader()?;
        let mut writer = ExtractionWriter::new(staging_root, limits);
        writer.write_file(entry, &mut reader)?;
        Ok(writer.stats())
    }

    fn verify(&self) -> Result<VerificationResult> {
        let mut reader = self.reader()?;
        let bytes_checked = io::copy(&mut reader, &mut io::sink()).map_err(|error| {
            ArcthisError::VerificationFailed {
                message: format!("{} stream verification failed: {error}", self.format),
            }
        })?;
        Ok(VerificationResult {
            verified: true,
            entries_checked: 1,
            bytes_checked,
        })
    }
}

fn derive_entry_name(path: &Path, format: ArchiveFormat) -> Result<String> {
    let name = path
        .file_name()
        .and_then(|name| name.to_str())
        .ok_or_else(|| ArcthisError::UnsupportedOperation {
            message: "single-stream access requires a UTF-8 source filename".to_owned(),
        })?;
    let suffix = match format {
        ArchiveFormat::Gzip => ".gz",
        ArchiveFormat::Bzip2 => ".bz2",
        ArchiveFormat::Xz => ".xz",
        ArchiveFormat::Zstd => ".zst",
        _ => {
            return Err(ArcthisError::UnsupportedOperation {
                message: format!("{format} is not a single-stream format"),
            });
        }
    };
    if name.len() > suffix.len() && name.to_ascii_lowercase().ends_with(suffix) {
        Ok(name[..name.len() - suffix.len()].to_owned())
    } else {
        Ok(format!("{name}.out"))
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::derive_entry_name;
    use crate::model::ArchiveFormat;

    #[test]
    fn derives_payload_name_from_matching_suffix() {
        assert_eq!(
            derive_entry_name(Path::new("report.txt.gz"), ArchiveFormat::Gzip)
                .expect("derive stream entry"),
            "report.txt"
        );
    }

    #[test]
    fn preserves_disguised_source_name_with_out_suffix() {
        assert_eq!(
            derive_entry_name(Path::new("payload.bin"), ArchiveFormat::Xz)
                .expect("derive stream entry"),
            "payload.bin.out"
        );
    }
}