///|
/// In-memory ZIP archive used to read DOCX parts. OPC part-name comparison is
/// case-insensitive, while `canonical_names` retains the archive's exact
/// spelling so preservation edits always address the physical entry.
pub struct ZipArchive {
priv entries : StableStringMap[BytesView]
priv canonical_names : StableStringMap[String]
priv logical_names : StableStringMap[String]
} derive(Debug, Eq)
///|
const MAX_ZIP_ENTRY_NAME_CHARS : Int = 65_535
///|
const MAX_ZIP_TOTAL_ENTRY_NAME_CHARS : Int = 4 * 1024 * 1024
///|
fn canonical_part_name(path : String) -> String {
@opc.part_name_key(path)
}
///|
fn checked_zip_entry_name_total(
total : Int,
name : String,
) -> Int raise DocxError {
let length = name.length()
if length > MAX_ZIP_ENTRY_NAME_CHARS {
raise InvalidZip(
message="ZIP entry name exceeds \{MAX_ZIP_ENTRY_NAME_CHARS} characters",
)
}
if length > MAX_ZIP_TOTAL_ENTRY_NAME_CHARS - total {
raise InvalidZip(
message="ZIP entry names exceed \{MAX_ZIP_TOTAL_ENTRY_NAME_CHARS} aggregate characters",
)
}
total + length
}
///|
/// Builds an owned part view and rejects case-equivalent aliases before they
/// can collapse into the canonical lookup index.
pub fn zip_archive(
source : Map[String, BytesView],
) -> ZipArchive raise DocxError {
zip_archive_entries(source.iter())
}
///|
/// Builds an archive view from a collision-independent iterator. This is the
/// preferred entry point for already validated or adversarial entry streams;
/// the map overload remains a convenience for programmatic callers.
pub fn zip_archive_entries(
source : Iter[(String, BytesView)],
) -> ZipArchive raise DocxError {
let entries : StableStringMap[BytesView] = SortedMap([])
let canonical_names : StableStringMap[String] = SortedMap([])
let logical_names : StableStringMap[String] = SortedMap([])
let physical_names : StableStringMap[Bool] = SortedMap([])
let part_names = @opc.PartNameRegistry::new()
let mut name_chars = 0
for pair in source {
let (name, bytes) = pair
name_chars = checked_zip_entry_name_total(name_chars, name)
register_physical_zip_name(physical_names, name)
insert_zip_entry(
entries, canonical_names, logical_names, part_names, name, bytes,
)
}
{ entries, canonical_names, logical_names }
}
///|
/// Registers every physical ZIP record before directory metadata is removed
/// from the OPC part projection. Directory identity is exact and
/// case-sensitive; file aliases are checked separately as OPC PartNames.
fn register_physical_zip_name(
physical_names : StableStringMap[Bool],
name : String,
) -> Unit raise DocxError {
if physical_names.contains(name) {
raise InvalidZip(message="duplicate ZIP entry name: \{name}")
}
physical_names[name] = true
}
///|
fn insert_zip_entry(
entries : StableStringMap[BytesView],
canonical_names : StableStringMap[String],
logical_names : StableStringMap[String],
part_names : @opc.PartNameRegistry,
name : String,
bytes : BytesView,
) -> Unit raise DocxError {
// Explicit ZIP directory records are package metadata, not OPC parts. The
// structural validator applies the same rule, so case-equivalent directory
// spellings cannot make validation and reader construction disagree.
if name.has_suffix("/") {
return
}
if entries.contains(name) {
raise InvalidZip(message="duplicate ZIP entry name: \{name}")
}
let logical_name = if name == "[Content_Types].xml" {
"[Content_Types].xml"
} else if canonical_part_name(name) ==
canonical_part_name("[Content_Types].xml") {
raise InvalidZip(
message="the reserved content-types entry must use exact spelling '[Content_Types].xml': \{name}",
)
} else {
match @opc.logical_part_name_from_zip_item_name(name) {
Some(value) => value
None =>
raise InvalidZip(
message="ZIP entry does not map to a canonical OPC part name: \{name}",
)
}
}
let key = canonical_part_name(logical_name)
if logical_name != "[Content_Types].xml" {
match part_names.register(logical_name, name) {
Some(Equivalent(existing)) =>
raise InvalidZip(
message="duplicate ZIP entry names after case normalization: \{existing}, \{name}",
)
Some(Derivable(existing)) =>
raise InvalidZip(
message="OPC part names must not be derivable from one another: \{existing}, \{name}",
)
None => ()
}
}
entries[name] = bytes
canonical_names[key] = name
logical_names[name] = logical_name
}
///|
/// Builds the DOCX reader's immutable part view from an already materialized
/// archive. Entry names remain fail-closed: the map view must never silently
/// collapse duplicate ZIP records.
pub fn open_zip_archive(
archive : @mbtzip.Archive,
) -> ZipArchive raise DocxError {
let entries : StableStringMap[BytesView] = SortedMap([])
let canonical_names : StableStringMap[String] = SortedMap([])
let logical_names : StableStringMap[String] = SortedMap([])
let physical_names : StableStringMap[Bool] = SortedMap([])
let part_names = @opc.PartNameRegistry::new()
let mut name_chars = 0
for entry in archive.entries() {
name_chars = checked_zip_entry_name_total(name_chars, entry.name())
register_physical_zip_name(physical_names, entry.name())
insert_zip_entry(
entries,
canonical_names,
logical_names,
part_names,
entry.name(),
entry.data(),
)
}
{ entries, canonical_names, logical_names }
}
///|
/// Opens ZIP bytes into an in-memory archive.
pub fn open_zip(data : BytesView) -> ZipArchive raise DocxError {
let archive = @mbtzip.read(data) catch {
err => raise InvalidZip(message="invalid ZIP archive: \{repr(err)}")
}
open_zip_archive(archive)
}
///|
/// Returns whether a ZIP entry exists.
pub fn ZipArchive::exists(self : ZipArchive, path : String) -> Bool {
self.resolve_path(path) is Some(_)
}
///|
/// Resolves an OPC path to the archive's exact entry spelling.
pub fn ZipArchive::resolve_path(self : ZipArchive, path : String) -> String? {
if self.entries.contains(path) {
Some(path)
} else {
self.canonical_names.get(canonical_part_name(path))
}
}
///|
/// Resolves either an exact physical ZIP spelling or a logical OPC name to
/// the canonical logical part name carried by this archive.
pub fn ZipArchive::logical_path(self : ZipArchive, path : String) -> String? {
match self.logical_names.get(path) {
Some(logical) => Some(logical)
None =>
match self.canonical_names.get(canonical_part_name(path)) {
Some(physical) => self.logical_names.get(physical)
None => None
}
}
}
///|
/// Reads a ZIP entry as bytes.
pub fn ZipArchive::read_bytes(self : ZipArchive, path : String) -> BytesView? {
match self.resolve_path(path) {
Some(actual) => self.entries.get(actual)
None => None
}
}
///|
/// Reads a ZIP entry as UTF-8 text.
pub fn ZipArchive::read_text(
self : ZipArchive,
path : String,
) -> String? raise DocxError {
match self.read_bytes(path) {
Some(bytes) =>
Some(
@utf8.decode(bytes, ignore_bom=true) catch {
_ => raise InvalidXml(message="part is not valid UTF-8: " + path)
},
)
None => None
}
}
///|
/// Splits a ZIP path into directory and filename.
pub fn split_zip_path(path : String) -> (String, String) {
match path.rev_find("/") {
Some(index) => (path[:index].to_owned(), path[index + 1:].to_owned())
None => ("", path)
}
}
///|
/// Joins ZIP path components with normalized separators.
pub fn join_zip_path(parts : Array[String]) -> String {
let relevant : Array[String] = []
for part in parts {
if part != "" {
if part.has_prefix("/") {
relevant.clear()
}
relevant.push(part)
}
}
relevant.join("/")
}