use std::fs::File;
use std::io::{BufRead, BufReader, Read};
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{Context as _, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compression {
None,
Gzip,
Bzip2,
Zstd,
}
impl Compression {
pub fn label(self) -> &'static str {
match self {
Compression::None => "plain text",
Compression::Gzip => "gzip",
Compression::Bzip2 => "bzip2",
Compression::Zstd => "zstd",
}
}
}
pub struct Opened {
pub reader: Box<dyn BufRead + Send>,
pub total_bytes: u64,
pub compression: Compression,
pub consumed: Arc<AtomicU64>,
}
#[derive(Debug, Clone)]
pub struct Inspection {
pub total_bytes: u64,
pub compression: Compression,
pub head: String,
}
pub fn open(path: &Path) -> Result<Opened> {
let file = File::open(path).with_context(|| format!("could not open {}", path.display()))?;
let total_bytes = file.metadata().map(|meta| meta.len()).unwrap_or(0);
let consumed = Arc::new(AtomicU64::new(0));
let mut counter = CountingReader {
inner: file,
consumed: consumed.clone(),
};
let mut magic = [0u8; 4];
let mut filled = 0;
while filled < magic.len() {
match counter.read(&mut magic[filled..]) {
Ok(0) => break,
Ok(read) => filled += read,
Err(error) => {
return Err(error).with_context(|| format!("could not read {}", path.display()));
}
}
}
let prefix = magic[..filled].to_vec();
let compression = detect(&prefix, path);
let source = std::io::Cursor::new(prefix).chain(counter);
let reader: Box<dyn BufRead + Send> = match compression {
Compression::None => Box::new(BufReader::new(source)),
Compression::Gzip => Box::new(BufReader::new(flate2::read::MultiGzDecoder::new(source))),
Compression::Bzip2 => Box::new(BufReader::new(bzip2::read::MultiBzDecoder::new(source))),
Compression::Zstd => Box::new(BufReader::new(
zstd::stream::read::Decoder::new(source)
.with_context(|| format!("could not read {}", path.display()))?,
)),
};
Ok(Opened {
reader,
total_bytes,
compression,
consumed,
})
}
pub fn inspect(path: &Path, limit: usize) -> Result<Inspection> {
let mut opened = open(path)?;
let mut head = vec![0u8; limit];
let mut filled = 0;
while filled < limit {
match opened.reader.read(&mut head[filled..]) {
Ok(0) => break,
Ok(read) => filled += read,
Err(error) => {
return Err(error).with_context(|| format!("could not read {}", path.display()));
}
}
}
head.truncate(filled);
Ok(Inspection {
total_bytes: opened.total_bytes,
compression: opened.compression,
head: String::from_utf8_lossy(&head).into_owned(),
})
}
fn detect(head: &[u8], path: &Path) -> Compression {
if head.starts_with(&[0x1f, 0x8b]) {
return Compression::Gzip;
}
if head.starts_with(b"BZh") {
return Compression::Bzip2;
}
if head.starts_with(&[0x28, 0xb5, 0x2f, 0xfd]) {
return Compression::Zstd;
}
let extension = path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase);
match extension.as_deref() {
Some("gz" | "tgz") => Compression::Gzip,
Some("bz2") => Compression::Bzip2,
Some("zst" | "zstd") => Compression::Zstd,
_ => Compression::None,
}
}
struct CountingReader<R> {
inner: R,
consumed: Arc<AtomicU64>,
}
impl<R: Read> Read for CountingReader<R> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let read = self.inner.read(buffer)?;
self.consumed.fetch_add(read as u64, Ordering::Relaxed);
Ok(read)
}
}
#[cfg(test)]
mod tests {
use std::io::Write;
use super::*;
fn file(dir: &Path, name: &str, bytes: &[u8]) -> std::path::PathBuf {
let path = dir.join(name);
std::fs::write(&path, bytes).expect("could not write the scratch dump");
path
}
fn scratch() -> std::path::PathBuf {
let path = std::env::temp_dir().join(format!("zippa-import-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&path).expect("could not create the scratch directory");
path
}
#[test]
fn plain_files_are_read_as_they_are() {
let dir = scratch();
let path = file(&dir, "dump.sql", b"SELECT 1;\n");
let opened = open(&path).expect("could not open the dump");
assert_eq!(opened.compression, Compression::None);
assert_eq!(opened.total_bytes, 10);
let mut text = String::new();
let mut reader = opened.reader;
reader
.read_to_string(&mut text)
.expect("could not read the dump");
assert_eq!(text, "SELECT 1;\n");
assert_eq!(opened.consumed.load(Ordering::Relaxed), 10);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn gzip_is_detected_by_its_magic() {
let dir = scratch();
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
encoder
.write_all(b"SELECT 1;\n")
.expect("could not compress");
let bytes = encoder.finish().expect("could not compress");
let path = file(&dir, "dump.sql", &bytes);
let mut opened = open(&path).expect("could not open the dump");
assert_eq!(opened.compression, Compression::Gzip);
let mut text = String::new();
opened
.reader
.read_to_string(&mut text)
.expect("could not read the dump");
assert_eq!(text, "SELECT 1;\n");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn bzip2_is_detected_by_its_magic() {
let dir = scratch();
let mut encoder = bzip2::write::BzEncoder::new(Vec::new(), bzip2::Compression::default());
encoder
.write_all(b"SELECT 1;\n")
.expect("could not compress");
let bytes = encoder.finish().expect("could not compress");
let path = file(&dir, "dump.sql", &bytes);
let mut opened = open(&path).expect("could not open the dump");
assert_eq!(opened.compression, Compression::Bzip2);
let mut text = String::new();
opened
.reader
.read_to_string(&mut text)
.expect("could not read the dump");
assert_eq!(text, "SELECT 1;\n");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn the_extension_is_a_fallback_for_an_empty_header() {
let dir = scratch();
let path = file(&dir, "dump.sql.gz", b"");
let opened = open(&path).expect("could not open the dump");
assert_eq!(opened.compression, Compression::Gzip);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn inspect_reads_the_head_and_the_size() {
let dir = scratch();
let path = file(
&dir,
"dump.sql",
b"CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n",
);
let inspection = inspect(&path, 1024).expect("could not inspect the dump");
assert_eq!(inspection.compression, Compression::None);
assert!(inspection.head.contains("CREATE TABLE"));
std::fs::remove_dir_all(dir).ok();
}
}