From d6f65e29e024ab8ce788d89247ae6907b7f7838b Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 06:58:38 +0000 Subject: [PATCH] feat(figcraft-format): support seekable native readers --- Cargo.lock | 1 + crates/figcraft-format/Cargo.toml | 1 + crates/figcraft-format/src/lib.rs | 106 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fda532570..d0e9f1e6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1453,6 +1453,7 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.21", + "zip", ] [[package]] diff --git a/crates/figcraft-format/Cargo.toml b/crates/figcraft-format/Cargo.toml index 22682034b..009e40c01 100644 --- a/crates/figcraft-format/Cargo.toml +++ b/crates/figcraft-format/Cargo.toml @@ -14,4 +14,5 @@ workspace = true serde = { workspace = true } serde_json = { workspace = true } craft-zip = { workspace = true } +zip = { version = "9", default-features = false, features = ["deflate-flate2"] } thiserror = { workspace = true } diff --git a/crates/figcraft-format/src/lib.rs b/crates/figcraft-format/src/lib.rs index 19e5f1175..9e973475e 100644 --- a/crates/figcraft-format/src/lib.rs +++ b/crates/figcraft-format/src/lib.rs @@ -256,3 +256,109 @@ mod streaming_writer_tests { /// ZIP compression modes for native archive members. pub use craft_zip::Compression; + +/// A native ZIP reader for seekable sources that should not be copied into memory first. +pub struct SeekArchive { + inner: zip::ZipArchive, +} + +impl SeekArchive { + pub fn open(reader: R, max_entries: u64) -> Result { + let inner = zip::ZipArchive::new(reader).map_err(|e| craft_zip::Error::NotZip(e.to_string()))?; + let count = inner.len() as u64; + if count > max_entries { + return Err(craft_zip::Error::TooManyEntries { declared: count, limit: max_entries }); + } + Ok(Self { inner }) + } + + pub fn contains(&self, name: &str) -> bool { + self.inner.index_for_name(name).is_some() + } + + pub fn names(&self) -> Vec { + self.inner.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect() + } + + pub fn declared_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.size())) + } + + pub fn read(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + let Some(index) = self.inner.index_for_name(name) else { return Ok(None) }; + let mut file = self.inner.by_index(index).map_err(|e| craft_zip::Error::Entry { name: name.to_owned(), msg: e.to_string() })?; + if file.size() > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + let mut bytes = Vec::new(); + let mut limited = std::io::Read::take(&mut file, max_bytes.saturating_add(1)); + let read = std::io::Read::read_to_end(&mut limited, &mut bytes); + if let Err(error) = read { + return Err(craft_zip::Error::Entry { name: name.to_owned(), msg: error.to_string() }); + } + if bytes.len() as u64 > max_bytes { + return Err(craft_zip::Error::EntryTooLarge { name: name.to_owned(), limit: max_bytes }); + } + Ok(Some(bytes)) + } +} + +/// Shared access contract implemented by in-memory and seekable native archive readers. +pub trait NativeArchive { + fn contains(&self, name: &str) -> bool; + fn names(&self) -> Vec; + fn declared_size(&mut self, name: &str) -> Option; + fn compressed_size(&mut self, name: &str) -> Option; + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error>; +} + +impl NativeArchive for Archive<'_> { + fn contains(&self, name: &str) -> bool { + Archive::contains(self, name) + } + fn names(&self) -> Vec { + Archive::names(self).map(|n| n.into_owned()).collect() + } + fn declared_size(&mut self, name: &str) -> Option { + Archive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + Archive::compressed_size(self, name) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + Archive::read(self, name, max_bytes) + } +} + +impl NativeArchive for SeekArchive { + fn contains(&self, name: &str) -> bool { + SeekArchive::contains(self, name) + } + fn names(&self) -> Vec { + SeekArchive::names(self) + } + fn declared_size(&mut self, name: &str) -> Option { + SeekArchive::declared_size(self, name) + } + fn compressed_size(&mut self, name: &str) -> Option { + self.inner.index_for_name(name).and_then(|i| self.inner.by_index_raw(i).ok().map(|f| f.compressed_size())) + } + fn read_member(&mut self, name: &str, max_bytes: u64) -> Result>, craft_zip::Error> { + SeekArchive::read(self, name, max_bytes) + } +} + +#[cfg(test)] +mod seek_archive_tests { + use super::*; + + #[test] + fn seekable_reader_matches_bounded_in_memory_reader() { + let bytes = write_archive([Member { name: "document.json", bytes: b"payload", compression: Compression::Stored }]).unwrap(); + let mut reader = SeekArchive::open(std::io::Cursor::new(bytes), 4).unwrap(); + assert!(reader.contains("document.json")); + assert_eq!(reader.read_member("document.json", 16).unwrap(), Some(b"payload".to_vec())); + assert_eq!(reader.declared_size("document.json"), Some(7)); + assert!(reader.read_member("document.json", 3).is_err()); + } +}