mirror of
https://git.pumpin.online/funkpower/catena.git
synced 2026-11-13 23:55:40 +00:00
Four formats a library of ebooks holds and this had no name for, so each came back as text, as HTML or as unknown. RTF is its opening group, after any byte-order mark and blank space. Microsoft Reader is its signature at the front. A FictionBook is XML whose root element is FictionBook, found by stepping over the declaration, comments and doctype in the first four kilobytes. That walk is over bytes rather than text, because a great many FictionBooks are windows-1251 and would otherwise be called binary; other XML is still HTML, as before. A zip whose only real member is one FictionBook is zip-wrapped-fb2, the same way a zipped PDF is zip-wrapped-pdf: handed to a converter as FictionBook it is still a zip. A zip whose every real member is a JPEG, PNG, GIF or WebP is cbz, once directories, ComicInfo.xml and desktop clutter are passed over. One PDF or one readme among the pages and it is not a comic. Only the names in the archive's directory are read, as for every other zip. The kind vocabulary grows by five: lit, fb2, rtf, cbz and zip-wrapped-fb2.
159 lines
6.2 KiB
Rust
159 lines
6.2 KiB
Rust
//! Listing what is inside a zip, without unpacking any of it.
|
|
//!
|
|
//! An EPUB, an htmlz and a PDF someone zipped and renamed are all zips, and
|
|
//! telling them apart needs only the member names. Those live in the central
|
|
//! directory at the end of the file, so reading them costs a few kilobytes
|
|
//! whatever the archive weighs — and nothing is ever decompressed, which
|
|
//! means a malicious archive cannot expand into anything here.
|
|
|
|
use crate::bytes::Bytes;
|
|
|
|
/// The end-of-central-directory record's signature.
|
|
const END_OF_DIRECTORY: &[u8] = b"PK\x05\x06";
|
|
/// A central directory entry's signature.
|
|
const ENTRY: &[u8] = b"PK\x01\x02";
|
|
/// A local file header, which is what a zip starts with.
|
|
pub const LOCAL_HEADER: &[u8] = b"PK\x03\x04";
|
|
|
|
/// How far back from the end the trailing record may be.
|
|
///
|
|
/// Its own size plus the largest comment a zip may carry.
|
|
const SEARCH_BACK: u64 = 22 + 0xffff;
|
|
|
|
/// Most members this will list.
|
|
///
|
|
/// A book in an archive has tens of members; an archive with a hundred
|
|
/// thousand is not a book, and reading all of its names would be the only
|
|
/// expensive thing this program does.
|
|
const MOST_MEMBERS: usize = 100_000;
|
|
|
|
/// The names of an archive's members, or `None` if it is not a zip this can
|
|
/// read.
|
|
pub fn members(source: &impl Bytes) -> Option<Vec<String>> {
|
|
if !source.magic_at(0, LOCAL_HEADER) {
|
|
return None;
|
|
}
|
|
let (count, mut at) = directory_start(source)?;
|
|
|
|
let mut names = Vec::with_capacity(count.min(1024));
|
|
for _ in 0..count.min(MOST_MEMBERS) {
|
|
let header = source.slice(at, 46).ok()?;
|
|
if header.len() < 46 || header[..4] != *ENTRY {
|
|
break;
|
|
}
|
|
let name_len = u16::from_le_bytes([header[28], header[29]]) as u64;
|
|
let extra_len = u16::from_le_bytes([header[30], header[31]]) as u64;
|
|
let comment_len = u16::from_le_bytes([header[32], header[33]]) as u64;
|
|
|
|
let name = source.slice(at + 46, name_len as usize).ok()?;
|
|
// Member names are bytes, and an archive written on another machine
|
|
// may not have written UTF-8. What is wanted here is a comparison
|
|
// against ASCII, so anything else may as well be a replacement
|
|
// character.
|
|
names.push(String::from_utf8_lossy(&name).into_owned());
|
|
at += 46 + name_len + extra_len + comment_len;
|
|
}
|
|
Some(names)
|
|
}
|
|
|
|
/// How many entries the central directory has and where it starts.
|
|
fn directory_start(source: &impl Bytes) -> Option<(usize, u64)> {
|
|
let len = source.len();
|
|
let window = SEARCH_BACK.min(len);
|
|
let from = len - window;
|
|
let tail = source.slice(from, window as usize).ok()?;
|
|
|
|
// The record is at the end, after a comment of unknown length, so it is
|
|
// found by searching backwards for its signature.
|
|
let at = tail
|
|
.windows(4)
|
|
.rposition(|window| window == END_OF_DIRECTORY)?;
|
|
let record = &tail[at..];
|
|
if record.len() < 22 {
|
|
return None;
|
|
}
|
|
let count = u16::from_le_bytes([record[10], record[11]]) as usize;
|
|
let offset = u32::from_le_bytes([record[16], record[17], record[18], record[19]]) as u64;
|
|
(offset < len).then_some((count, offset))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod tests {
|
|
use super::*;
|
|
|
|
/// Build a zip holding `names`, each with empty stored content.
|
|
pub(crate) fn zip(names: &[&str]) -> Vec<u8> {
|
|
let mut out: Vec<u8> = Vec::new();
|
|
let mut offsets = Vec::new();
|
|
for name in names {
|
|
offsets.push(out.len() as u32);
|
|
out.extend_from_slice(LOCAL_HEADER);
|
|
out.extend_from_slice(&[0; 22]); // version .. uncompressed size
|
|
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes());
|
|
out.extend_from_slice(name.as_bytes());
|
|
}
|
|
let directory_at = out.len() as u32;
|
|
for (name, offset) in names.iter().zip(&offsets) {
|
|
out.extend_from_slice(ENTRY);
|
|
out.extend_from_slice(&[0; 24]); // version .. uncompressed size
|
|
out.extend_from_slice(&(name.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // extra
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // comment
|
|
out.extend_from_slice(&[0; 8]); // disk, attributes
|
|
out.extend_from_slice(&offset.to_le_bytes());
|
|
out.extend_from_slice(name.as_bytes());
|
|
}
|
|
out.extend_from_slice(END_OF_DIRECTORY);
|
|
out.extend_from_slice(&[0; 4]); // disk numbers
|
|
out.extend_from_slice(&(names.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&(names.len() as u16).to_le_bytes());
|
|
out.extend_from_slice(&((out.len() as u32).saturating_sub(directory_at)).to_le_bytes());
|
|
out.extend_from_slice(&directory_at.to_le_bytes());
|
|
out.extend_from_slice(&0u16.to_le_bytes()); // comment length
|
|
out
|
|
}
|
|
|
|
#[test]
|
|
fn the_member_names_come_back() {
|
|
let archive = zip(&["mimetype", "META-INF/container.xml", "OEBPS/book.opf"]);
|
|
let names = members(&archive.as_slice()).expect("a zip");
|
|
assert_eq!(
|
|
names,
|
|
["mimetype", "META-INF/container.xml", "OEBPS/book.opf"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_archive_with_no_members_is_not_read_as_one() {
|
|
// It has no local header to start with, so nothing identifies it as
|
|
// an archive in the first place — and an empty archive is not a book
|
|
// under any reading.
|
|
assert_eq!(members(&zip(&[]).as_slice()), None);
|
|
}
|
|
|
|
#[test]
|
|
fn something_that_is_not_a_zip_is_not_read_as_one() {
|
|
assert_eq!(members(&b"%PDF-1.7".as_slice()), None);
|
|
assert_eq!(members(&b"".as_slice()), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_truncated_archive_does_not_panic() {
|
|
let archive = zip(&["a.pdf", "b.pdf"]);
|
|
for cut in 0..archive.len() {
|
|
let _ = members(&&archive[..cut]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_count_that_lies_stops_at_the_first_bad_entry() {
|
|
let mut archive = zip(&["a.pdf"]);
|
|
let at = archive.len() - 12;
|
|
// Claim a thousand entries where there is one.
|
|
archive[at - 6..at - 4].copy_from_slice(&1000u16.to_le_bytes());
|
|
let names = members(&archive.as_slice()).expect("a zip");
|
|
assert_eq!(names, ["a.pdf"]);
|
|
}
|
|
}
|