Add document-container crate (#4191)

* Add document-container crate: container backends and archive codecs

* Address PR review: path/prefix split, safe size casts, OPFS stream aborts

* Address PR review round 2: mmap read check, UTF8 entry names, prefix normalization

* Make MmappedBytes::new fallible so mmap reads can't silently degrade

* Address PR review round 3: backend contract uniformity (symlinks, remove, list)

* Apply symlink-component check to FolderBackend listing paths

* Address PR review: idempotent OPFS delete logging, tar default-features, shared entry-size cap

* Fix validate_path doc: dotfiles pass, CurDir/ParentDir rejected

* Omit symlink entries from FolderBackend listings for consistency with resolve

* Preserve zip I/O errors and reject non-canonical paths in validate_path

* Extend archive apis to return the archive writer

* Rename document/document-container directory to document/container

* Add archive format sniffing and deserialize_auto

* Drop temporal hedge from checked_entry_size comment

* Tighten verbose doc comments in document-container

* Coalesce consecutive same-path OPFS appends to avoid O(n^2) file copies

* Review

* Update document-container for the deserialize/store_non_blocking renames

---------

Co-authored-by: Timon <me@timon.zip>
This commit is contained in:
Dennis Kobert
2026-06-05 13:28:02 -04:00
committed by Keavon Chambers
co-authored by Timon
parent 08c6d02e5b
commit 6fe1af3afe
13 changed files with 1897 additions and 0 deletions
+97
View File
@@ -0,0 +1,97 @@
//! Xz-compressed tarball archive codec.
use crate::archive::{Archive, ArchiveWriter, MAX_DECOMPRESSED_SIZE, checked_entry_size};
use crate::{Container, ContainerError, Result, validate_path};
use lzma_rust2::{XzOptions, XzReader, XzWriter as InnerXzWriter};
use std::io::{Read, Seek, Write};
pub struct Xz;
/// xz-tar writer. Held as an `Option` so `finish` can take ownership and unwind the layered
/// writers in the right order: drop the tar builder first to flush its trailer, then finish xz.
pub struct XzWriter<W: Write + Seek> {
tar: Option<tar::Builder<InnerXzWriter<W>>>,
}
impl Archive for Xz {
type Writer<W: Write + Seek> = XzWriter<W>;
fn writer<W: Write + Seek>(output: W) -> Result<Self::Writer<W>> {
let xz_writer = InnerXzWriter::new(output, XzOptions::default()).map_err(lzma_err)?;
Ok(XzWriter {
tar: Some(tar::Builder::new(xz_writer)),
})
}
fn open<R: Read + Seek, C: Container>(source: R, dest: &mut C) -> Result<()> {
// `take` bounds how many bytes we decompress from the xz stream, but each tar entry's declared
// size is fed to `write_sized`, which pre-allocates from it before reading. Cap the cumulative
// declared size too so a header claiming a huge size can't trigger a giant allocation up front.
let xz_reader = XzReader::new(source, false);
let bounded = xz_reader.take(MAX_DECOMPRESSED_SIZE);
let mut tar_reader = tar::Archive::new(bounded);
let mut total_size = 0u64;
for entry in tar_reader.entries()? {
let mut entry = entry?;
if entry.header().entry_type() != tar::EntryType::Regular {
continue;
}
// Reject non-UTF8 entry names rather than lossily rewriting them, so the path we store matches
// the archive exactly.
let path = entry.path()?;
let path = path.to_str().ok_or_else(|| ContainerError::Codec(format!("tar: non-UTF8 entry name {path:?}")))?.to_owned();
validate_path(&path)?;
let size = checked_entry_size(&mut total_size, entry.size())?;
dest.write_sized(&path, size, &mut |buffer| {
entry.read_exact(buffer).map_err(ContainerError::Io)?;
Ok(())
})?;
}
Ok(())
}
}
impl<W: Write + Seek> ArchiveWriter for XzWriter<W> {
fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<()> {
validate_path(path)?;
let tar = self.tar.as_mut().ok_or_else(|| ContainerError::Codec("XzWriter already finished".into()))?;
let mut header = tar::Header::new_gnu();
header.set_path(path).map_err(|error| ContainerError::Codec(format!("tar: invalid path {path}: {error}")))?;
header.set_size(bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, bytes)?;
Ok(())
}
fn finish(mut self) -> Result<()> {
self.finish_inner()?;
Ok(())
}
}
impl<W: Write + Seek> XzWriter<W> {
/// Finish the archive and return the underlying sink, for in-memory archives where the caller
/// wants the written bytes (e.g. `Cursor<Vec<u8>>`) back.
pub fn finish_into(mut self) -> Result<W> {
self.finish_inner()
}
/// Unwind the layered writers in order (flush the tar trailer, then finish xz) and hand back the
/// innermost sink. Shared by `finish` and `finish_into`.
fn finish_inner(&mut self) -> Result<W> {
let mut tar = self.tar.take().ok_or_else(|| ContainerError::Codec("XzWriter already finished".into()))?;
tar.finish()?;
let xz_writer = tar.into_inner()?;
xz_writer.finish().map_err(lzma_err)
}
}
fn lzma_err(error: std::io::Error) -> ContainerError {
ContainerError::Codec(format!("lzma: {error}"))
}
+83
View File
@@ -0,0 +1,83 @@
//! Zip archive codec.
use crate::archive::{Archive, ArchiveWriter, checked_entry_size};
use crate::{Container, ContainerError, Result, validate_path};
use std::io::{Read, Seek, Write};
use zip::ZipArchive;
use zip::write::{SimpleFileOptions, ZipWriter as InnerZipWriter};
pub struct Zip;
pub struct ZipWriter<W: Write + Seek> {
inner: InnerZipWriter<W>,
options: SimpleFileOptions,
}
impl Archive for Zip {
type Writer<W: Write + Seek> = ZipWriter<W>;
fn writer<W: Write + Seek>(output: W) -> Result<Self::Writer<W>> {
Ok(ZipWriter {
inner: InnerZipWriter::new(output),
options: SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated),
})
}
fn open<R: Read + Seek, C: Container>(source: R, dest: &mut C) -> Result<()> {
let mut archive = ZipArchive::new(source).map_err(zip_err)?;
// Zip headers declare each entry's uncompressed size up front; `checked_entry_size` caps the running
// total so a malicious archive can't exhaust memory or disk before any bytes are read.
let mut total_size = 0u64;
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(zip_err)?;
if !entry.is_file() {
continue;
}
let name = entry.name().to_string();
validate_path(&name)?;
let size = checked_entry_size(&mut total_size, entry.size())?;
dest.write_sized(&name, size, &mut |buffer| {
entry.read_exact(buffer).map_err(ContainerError::Io)?;
Ok(())
})?;
}
Ok(())
}
}
impl<W: Write + Seek> ArchiveWriter for ZipWriter<W> {
fn write_entry(&mut self, path: &str, bytes: &[u8]) -> Result<()> {
validate_path(path)?;
self.inner.start_file(path, self.options).map_err(zip_err)?;
self.inner.write_all(bytes)?;
Ok(())
}
fn finish(self) -> Result<()> {
self.inner.finish().map_err(zip_err)?;
Ok(())
}
}
impl<W: Write + Seek> ZipWriter<W> {
/// Finish the archive and return the underlying sink, for in-memory archives where the caller
/// wants the written bytes (e.g. `Cursor<Vec<u8>>`) back.
pub fn finish_into(self) -> Result<W> {
self.inner.finish().map_err(zip_err)
}
}
fn zip_err(error: zip::result::ZipError) -> ContainerError {
// Preserve a real I/O failure (disk full, etc.) as a structured `Io` so callers can tell it apart from a
// corrupt-archive `Codec` error; only the genuinely archive-format errors collapse into `Codec`.
match error {
zip::result::ZipError::Io(io) => ContainerError::Io(io),
other => ContainerError::Codec(format!("zip: {other}")),
}
}