diff --git a/CHANGELOG.md b/CHANGELOG.md index 0782fe2..98dda6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.9.5] - 2026-08-10 + +### Fixed + +- Support empty CBQ files in the `cbq` reader which previously failed due to index alignment error + ## [0.9.4] - 2026-07-15 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 01b01e1..b492d89 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "binseq" -version = "0.9.4" +version = "0.9.5" edition = "2024" description = "A high efficiency binary format for sequencing data" license = "MIT" @@ -32,6 +32,7 @@ anyhow = "1.0.103" parking_lot = "0.12.5" clap = { version = "4.6.2", features = ["derive"] } paraseq = "0.4.14" +tempfile = "3.27.0" [features] default = ["paraseq", "anyhow"] diff --git a/src/cbq/core/index.rs b/src/cbq/core/index.rs index b70dad7..e61657f 100644 --- a/src/cbq/core/index.rs +++ b/src/cbq/core/index.rs @@ -109,6 +109,13 @@ impl Index { /// Builds the index from a byte slice pub fn from_bytes(bytes: &[u8]) -> Result { + // A zero-record CBQ has an empty index whose decompressed buffer has + // a dangling pointer, which fails `try_cast_slice`'s alignment check. + if bytes.is_empty() { + return Ok(Self { + ranges: Vec::default(), + }); + } let ranges = match bytemuck::try_cast_slice(bytes) { Ok(ranges) => ranges.to_vec(), Err(_) => return Err(CbqError::IndexCastingError.into()), diff --git a/src/cbq/read.rs b/src/cbq/read.rs index 2058bf6..9d16cf2 100644 --- a/src/cbq/read.rs +++ b/src/cbq/read.rs @@ -332,6 +332,10 @@ impl ParallelReader for MmapReader { } #[cfg(test)] mod tests { + use std::io::Write; + + use tempfile::NamedTempFile; + use super::*; use crate::BinseqRecord; @@ -380,6 +384,53 @@ mod tests { assert!(num_blocks > 0, "Should have at least one block"); } + // ==================== Empty File Tests ==================== + + fn make_empty_cbq() -> Vec { + use crate::cbq::{ColumnarBlockWriter, core::FileHeaderBuilder}; + let header = FileHeaderBuilder::default() + .is_paired(false) + .with_headers(false) + .with_qualities(false) + .with_flags(false) + .with_block_size(64) + .build(); + let mut writer = ColumnarBlockWriter::new(Vec::new(), header).unwrap(); + writer.finish().unwrap(); + writer.inner_data().to_vec() + } + + /// A CBQ file containing zero records (e.g. produced when an upstream + /// filter discards every read) has an empty index. Reading the index + /// back must not fail: `bytemuck::try_cast_slice` rejects the empty + /// decompressed buffer because its dangling pointer is not aligned for + /// `BlockRange`, so `Index::from_bytes` needs an explicit empty guard. + #[test] + fn test_read_index_empty_file() { + use std::io::Cursor; + + let empty_cbq = make_empty_cbq(); + let mut reader = Reader::new(Cursor::new(empty_cbq)).unwrap(); + while reader.read_block().unwrap().is_some() {} + let index = reader.read_index().unwrap().expect("index should exist"); + assert_eq!(index.num_records(), 0); + assert_eq!(index.num_blocks(), 0); + } + + #[test] + fn test_read_index_empty_file_mmap() { + let empty_cbq = make_empty_cbq(); + + let ntf = NamedTempFile::new().unwrap(); + let (mut tmpfile, tmppath) = ntf.into_parts(); + tmpfile.write_all(&empty_cbq).unwrap(); + + let reader = MmapReader::new(tmppath).unwrap(); + let index = reader.index(); + assert_eq!(index.num_records(), 0); + assert_eq!(index.num_blocks(), 0); + } + // ==================== Default Quality Score Tests ==================== #[test]