// Builder side of the OPC primitives (the read/validation side lives in
// validate.mbt): assemble a package from parts, content types, and
// relationships, fail-closed. Defense in depth: the add-time checks give
// early errors, the struct's state is private, and `build()` re-validates
// EVERYTHING from the authoritative part list anyway — name shape,
// reserved/duplicate names, coverage, override targets, relationship
// sources AND targets, and metadata well-formedness — so no sequence of
// calls (or future code path) can emit a package `docx validate` would
// reject.
//
// Follow-up (flagged, not this PR): docx/embedded_style_map.mbt hand-emits
// the same Types/Relationships XML; migrate it onto these emitters.
///|
/// A package that cannot be built as specified. `PackageBuildError` carries
/// a caller-facing message naming the offending part/relationship;
/// `PackageLimitExceeded` is the TYPED resource-limit refusal from
/// `build_limited` — it survives to the office transaction layer as a
/// machine-readable ceiling breach (kind ∈ output_bytes / entries /
/// entry_uncompressed_bytes / total_uncompressed_bytes) rather than being
/// flattened into a string.
pub(all) suberror PackageBuildError {
PackageBuildError(String)
PackageLimitExceeded(kind~ : String, limit~ : Int64, actual~ : Int64)
} derive(Eq)
///|
pub impl Show for PackageBuildError with fn output(self, logger) {
match self {
PackageBuildError(message) => logger.write_string(message)
PackageLimitExceeded(kind~, limit~, actual~) =>
logger.write_string(
"package \{kind} ceiling exceeded: limit \{limit}, actual \{actual}",
)
}
}
///|
/// Pre-allocation ceilings for `PackageBuilder::build_limited`, mirroring the
/// four `TransactionBudget` dimensions. Every bound is checked BEFORE the
/// package archive materializes its (potentially large) entry payloads.
pub(all) struct PackageLimits {
max_output_bytes : Int
max_entries : Int
max_entry_uncompressed_bytes : Int
max_total_uncompressed_bytes : Int
}
///|
priv struct PackageRelationship {
id : String
relationship_type : String
target : String
external : Bool
}
///|
/// Assembles an OPC package. State is private — mutate only through the
/// `add_*` methods. `build()` emits `[Content_Types].xml`, then every
/// relationships part, then the parts in insertion order.
pub struct PackageBuilder {
priv parts : Array[(String, Bytes)]
priv defaults : StableStringMap[String]
priv overrides : StableStringMap[String]
priv relationships : StableStringMap[Array[PackageRelationship]]
priv part_names : PartNameRegistry
priv mut zip_name_chars : Int
}
///|
/// An empty builder: no parts, no content types, no relationships.
pub fn PackageBuilder::new() -> PackageBuilder {
{
parts: [],
defaults: SortedMap([]),
overrides: SortedMap([]),
relationships: SortedMap([]),
part_names: PartNameRegistry::new(),
zip_name_chars: 0,
}
}
///|
/// Adds a part by logical OPC name (`word/document.xml`, no leading slash).
/// The builder maps non-ASCII scalars to their ASCII ZIP item spelling at
/// serialization time. Malformed, reserved (builder-emitted), or duplicate
/// names raise.
pub fn PackageBuilder::add_part(
self : PackageBuilder,
name : String,
data : Bytes,
) -> Unit raise PackageBuildError {
let zip_name_length = check_part_name(name)
if zip_name_length >
DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS - self.zip_name_chars {
raise PackageBuildError(
"part names exceed \{DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS} aggregate characters",
)
}
match self.part_names.register(name, name) {
Some(Equivalent(existing)) if existing == name =>
raise PackageBuildError("duplicate part name: \{name}")
Some(Equivalent(existing)) =>
raise PackageBuildError(
"duplicate part name after case normalization: \{existing} and \{name}",
)
Some(Derivable(existing)) =>
raise PackageBuildError(
"part names must not be derivable from one another: \{existing} and \{name}",
)
None => ()
}
self.zip_name_chars = self.zip_name_chars + zip_name_length
self.parts.push((name, data))
}
///|
/// Registers an extension default (`xml` → content type).
pub fn PackageBuilder::add_default(
self : PackageBuilder,
extension : String,
content_type : String,
) -> Unit raise PackageBuildError {
check_metadata("Default extension", extension)
check_metadata("Default content type", content_type)
if !is_valid_content_type_extension(extension) {
raise PackageBuildError(
"Default extension is not a valid OPC ST_Extension: \{extension}",
)
}
if !is_valid_media_type(content_type) {
raise PackageBuildError(
"Default content type is not a valid media type: \{content_type}",
)
}
self.defaults[extension.to_lower()] = content_type
}
///|
/// Registers a content-type override. `part_name` is pack-form and must
/// start with `/` (`/word/document.xml`).
pub fn PackageBuilder::add_override(
self : PackageBuilder,
part_name : String,
content_type : String,
) -> Unit raise PackageBuildError {
if !part_name.has_prefix("/") {
raise PackageBuildError(
"override PartName must start with '/': \{part_name}",
)
}
if normalize_override_part_name(part_name) != Some(part_name) {
raise PackageBuildError(
"override PartName is not a canonical logical part name: \{part_name}",
)
}
check_metadata("Override PartName", part_name)
check_metadata("Override content type", content_type)
if !is_valid_media_type(content_type) {
raise PackageBuildError(
"Override content type is not a valid media type: \{content_type}",
)
}
let key = builder_part_identity(part_name)
for existing, _ in self.overrides {
if builder_part_identity(existing) == key && existing != part_name {
raise PackageBuildError(
"duplicate Override PartName after case normalization: \{existing} and \{part_name}",
)
}
}
self.overrides[part_name] = content_type
}
///|
/// Adds a relationship. `source~` is the part the relationship belongs to
/// (`""` = the package root `_rels/.rels`); `target` is source-relative (or
/// pack-absolute with a leading `/`). Ids must be unique per source.
/// `external~` marks a `TargetMode="External"` relationship (e.g. a
/// hyperlink URL). Id, Type, and Target are normalized to their XML Schema
/// value spaces; external targets are exempt from internal-target resolution.
pub fn PackageBuilder::add_relationship(
self : PackageBuilder,
id : String,
relationship_type : String,
target : String,
source? : String = "",
external? : Bool = false,
) -> Unit raise PackageBuildError {
let normalized_id = @xml.collapse_xml_schema_whitespace(id)
let normalized_type = @xml.collapse_xml_schema_whitespace(relationship_type)
let normalized_target = @xml.collapse_xml_schema_whitespace(target)
check_metadata("relationship Id", normalized_id)
if !is_valid_relationship_id(normalized_id) {
raise PackageBuildError(
"relationship Id is not an XML NCName: \{normalized_id}",
)
}
check_metadata("relationship Type", normalized_type)
if !is_absolute_relationship_type(normalized_type) {
raise PackageBuildError(
"relationship Type must be an absolute IRI without a fragment: \{normalized_type}",
)
}
check_metadata("relationship Target", normalized_target)
if external && !is_valid_external_relationship_target(normalized_target) {
raise PackageBuildError(
"external relationship Target is not a valid IRI reference: \{normalized_target}",
)
}
let rels = match self.relationships.get(source) {
Some(existing) => existing
None => {
let fresh : Array[PackageRelationship] = []
self.relationships[source] = fresh
fresh
}
}
for existing in rels {
if existing.id == normalized_id {
raise PackageBuildError(
"duplicate relationship Id '\{normalized_id}' for source '\{source}'",
)
}
}
rels.push({
id: normalized_id,
relationship_type: normalized_type,
target: normalized_target,
external,
})
}
///|
/// Emits the package bytes. Everything is re-validated here from the
/// authoritative state: part names (shape, reserved, duplicates — including
/// collisions with the builder's own emitted rels parts), content-type
/// coverage for every emitted entry, override targets, relationship sources
/// AND internal targets, and metadata well-formedness. Output is
/// deterministic for a given call sequence.
pub fn PackageBuilder::build(
self : PackageBuilder,
) -> Bytes raise PackageBuildError {
self.build_internal(None)
}
///|
/// Like `build()`, but enforces the given resource ceilings BEFORE the
/// archive payloads are copied/compressed. Over-limit packages raise a typed
/// `PackageLimitExceeded` (never a flattened string), so callers can map the
/// breach to a machine-readable transaction refusal.
pub fn PackageBuilder::build_limited(
self : PackageBuilder,
limits : PackageLimits,
) -> Bytes raise PackageBuildError {
self.build_internal(Some(limits))
}
///|
fn PackageBuilder::build_internal(
self : PackageBuilder,
limits : PackageLimits?,
) -> Bytes raise PackageBuildError {
// Authoritative name set, revalidated from scratch.
let part_names : StableStringMap[String] = SortedMap([])
let package_names : StableStringMap[String] = SortedMap([])
let package_name_registry = PartNameRegistry::new()
let mut package_name_chars = CONTENT_TYPES_PART.length()
for entry in self.parts {
let (name, _) = entry
let zip_name_length = check_part_name(name)
if zip_name_length >
DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS - package_name_chars {
raise PackageBuildError(
"package entry names exceed \{DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS} aggregate characters",
)
}
package_name_chars = package_name_chars + zip_name_length
register_builder_package_name(name, package_names, package_name_registry)
let key = builder_part_identity(name)
part_names[key] = name
}
// Generated entries are builder-owned; a collision with an explicit part
// would emit duplicate zip entries (Archive::add appends blindly), which
// is exactly what validation rejects.
let emitted_rels : Array[String] = []
for source, rels in self.relationships {
// Every non-root source must be an added part, or its rels part would
// be an orphan describing nothing.
if source != "" && !part_names.contains(builder_part_identity(source)) {
raise PackageBuildError(
"relationships declared for source '\{source}', which was not added as a part",
)
}
let seen_ids : StableStringSet = SortedSet([])
for relationship in rels {
check_metadata("relationship Id", relationship.id)
if !is_valid_relationship_id(relationship.id) {
raise PackageBuildError(
"relationship Id is not an XML NCName: \{relationship.id}",
)
}
check_metadata("relationship Type", relationship.relationship_type)
if !is_absolute_relationship_type(relationship.relationship_type) {
raise PackageBuildError(
"relationship Type must be an absolute IRI without a fragment: \{relationship.relationship_type}",
)
}
check_metadata("relationship Target", relationship.target)
if relationship.external &&
!is_valid_external_relationship_target(relationship.target) {
raise PackageBuildError(
"external relationship Target is not a valid IRI reference: \{relationship.target}",
)
}
if seen_ids.contains(relationship.id) {
raise PackageBuildError(
"duplicate relationship Id '\{relationship.id}' for source '\{source}'",
)
}
seen_ids.add(relationship.id)
}
let name = relationships_part_name_for_source(source)
guard zip_item_name_from_logical_part_name(name) is Some(zip_name) else {
raise PackageBuildError(
"builder-emitted relationship part is not a canonical OPC PartName: \{name}",
)
}
if zip_name.length() > MAX_OPC_ZIP_ENTRY_NAME_BYTES_WITHOUT_METADATA {
raise PackageBuildError(
"builder-emitted relationship part makes the OPC central-directory file header exceed \{MAX_OPC_CENTRAL_DIRECTORY_FILE_HEADER_BYTES} bytes: \{name}",
)
}
if zip_name.length() >
DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS - package_name_chars {
raise PackageBuildError(
"package entry names exceed \{DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS} aggregate characters",
)
}
package_name_chars = package_name_chars + zip_name.length()
register_builder_package_name(name, package_names, package_name_registry)
emitted_rels.push(name)
}
let override_names : StableStringMap[String] = SortedMap([])
for part_name, content_type in self.overrides {
check_metadata("Override PartName", part_name)
check_metadata("Override content type", content_type)
if !is_valid_media_type(content_type) {
raise PackageBuildError(
"Override content type is not a valid media type: \{content_type}",
)
}
guard part_name.has_prefix("/") else {
raise PackageBuildError(
"override PartName must start with '/': \{part_name}",
)
}
if normalize_override_part_name(part_name) != Some(part_name) {
raise PackageBuildError(
"override PartName is not a canonical logical part name: \{part_name}",
)
}
let override_key = builder_part_identity(part_name)
match override_names.get(override_key) {
Some(existing) if existing != part_name =>
raise PackageBuildError(
"duplicate Override PartName after case normalization: \{existing} and \{part_name}",
)
_ => override_names[override_key] = part_name
}
let archive_name = part_name[1:].to_owned()
// Overrides name package parts, including relationship parts generated by
// this builder. Validate against the complete authoritative output set so
// Override-before-Default precedence is reachable for generated `.rels`
// entries while nonexistent targets still fail closed.
if !package_names.contains(builder_part_identity(archive_name)) {
raise PackageBuildError(
"override names a part that will not be emitted: \{part_name}",
)
}
}
for extension, content_type in self.defaults {
check_metadata("Default extension", extension)
check_metadata("Default content type", content_type)
if !is_valid_content_type_extension(extension) {
raise PackageBuildError(
"Default extension is not a valid OPC ST_Extension: \{extension}",
)
}
if !is_valid_media_type(content_type) {
raise PackageBuildError(
"Default content type is not a valid media type: \{content_type}",
)
}
}
for entry in self.parts {
let (name, _) = entry
self.check_coverage(name)
}
for name in emitted_rels {
self.check_coverage(name)
self.check_relationship_content_type(name)
}
for source, rels in self.relationships {
let base = split_part_dir(source)
for relationship in rels {
if relationship.external {
continue
}
guard resolve_part_target(base, relationship.target) is Some(resolved) else {
raise PackageBuildError(
"relationship \{relationship.id} of '\{source}' has an invalid internal target: \{relationship.target}",
)
}
if is_relationship_part_name(resolved) {
raise PackageBuildError(
"relationship \{relationship.id} of '\{source}' must not target a Relationships part: \{relationship.target}",
)
}
if !part_names.contains(builder_part_identity(resolved)) {
raise PackageBuildError(
"relationship \{relationship.id} of '\{source}' targets a part that was not added: \{relationship.target} (resolved \{resolved})",
)
}
}
}
// Collect the full entry set as (zip_name, data) FIRST. `data` here is the
// caller's immutable Bytes reference (no copy yet — Archive::add is what
// calls to_owned), so building this list is cheap even for a package that
// references one large asset thousands of times. The limit preflight then
// runs BEFORE any copy/compression, so an over-budget package is refused
// without materializing gigabytes of archive storage.
let entries : Array[(String, Bytes)] = []
entries.push((CONTENT_TYPES_PART, @utf8.encode(self.content_types_xml())))
for source, rels in self.relationships {
let name = relationships_part_name_for_source(source)
let zip_name = zip_item_name_from_logical_part_name(name).unwrap()
entries.push((zip_name, @utf8.encode(relationships_xml(rels))))
}
for entry in self.parts {
let (name, data) = entry
let zip_name = zip_item_name_from_logical_part_name(name).unwrap()
entries.push((zip_name, data))
}
match limits {
Some(l) => {
if entries.length() > l.max_entries {
raise PackageLimitExceeded(
kind="entries",
limit=l.max_entries.to_int64(),
actual=entries.length().to_int64(),
)
}
let mut total = 0L
for entry in entries {
let (_, data) = entry
let n = data.length()
if n > l.max_entry_uncompressed_bytes {
raise PackageLimitExceeded(
kind="entry_uncompressed_bytes",
limit=l.max_entry_uncompressed_bytes.to_int64(),
actual=n.to_int64(),
)
}
total = total + n.to_int64()
if total > l.max_total_uncompressed_bytes.to_int64() {
raise PackageLimitExceeded(
kind="total_uncompressed_bytes",
limit=l.max_total_uncompressed_bytes.to_int64(),
actual=total,
)
}
}
}
None => ()
}
let archive = @mbtzip.Archive::new()
for entry in entries {
let (zip_name, data) = entry
archive.add(zip_name, data, compression=Deflate)
}
match limits {
Some(l) =>
@mbtzip.write_limited(archive, max_output_bytes=l.max_output_bytes) catch {
OutputLimitExceeded(limit~) =>
// The zip sizing pass short-circuits at the ceiling (bounded work),
// so the exact serialized size is not computed; `actual` is the
// truthful strict lower bound "at least one byte over the limit".
raise PackageLimitExceeded(
kind="output_bytes",
limit=limit.to_int64(),
actual=limit.to_int64() + 1L,
)
err =>
raise PackageBuildError(
"could not assemble the archive: \{repr(err)}",
)
}
None =>
@mbtzip.write(archive) catch {
err =>
raise PackageBuildError(
"could not assemble the archive: \{repr(err)}",
)
}
}
}
///|
/// Part-name policy: canonical logical PartName syntax plus builder specifics
/// — no trailing slash (a "directory" is not a part) and no
/// builder-owned names (`[Content_Types].xml` and anything shaped like a
/// relationships part: the builder emits all relationships itself).
fn check_part_name(name : String) -> Int raise PackageBuildError {
if name == "" || name.has_prefix("/") || name.has_suffix("/") {
raise PackageBuildError(
"invalid part name '\{name}': expected a non-empty logical part name without a leading or trailing slash",
)
}
let key = builder_part_identity(name)
if key == builder_part_identity(CONTENT_TYPES_PART) ||
key == builder_part_identity(ROOT_RELS_PART) ||
is_relationship_part_name(name) {
raise PackageBuildError(
"reserved part name '\{name}': the builder emits content types and relationships itself",
)
}
for segment in name.split("/") {
if !is_valid_part_segment(segment) {
raise PackageBuildError(
"invalid part name '\{name}': not a canonical OPC logical PartName",
)
}
}
guard zip_item_name_from_logical_part_name(name) is Some(zip_name) else {
raise PackageBuildError(
"invalid part name '\{name}': not a canonical OPC logical PartName",
)
}
if zip_name.length() > MAX_OPC_ZIP_ENTRY_NAME_BYTES_WITHOUT_METADATA {
raise PackageBuildError(
"part name makes the OPC central-directory file header exceed \{MAX_OPC_CENTRAL_DIRECTORY_FILE_HEADER_BYTES} bytes: \{name}",
)
}
zip_name.length()
}
///|
fn builder_part_identity(name : String) -> String {
part_name_key(name)
}
///|
fn register_builder_package_name(
name : String,
names : StableStringMap[String],
registry : PartNameRegistry,
) -> Unit raise PackageBuildError {
let key = builder_part_identity(name)
match registry.register(name, name) {
Some(Equivalent(existing)) if existing == name =>
raise PackageBuildError("duplicate part name: \{name}")
Some(Equivalent(existing)) =>
raise PackageBuildError(
"duplicate part name after case normalization: \{existing} and \{name}",
)
Some(Derivable(existing)) =>
raise PackageBuildError(
"part names must not be derivable from one another: \{existing} and \{name}",
)
None => ()
}
names[key] = name
}
///|
/// Metadata strings are emitted into XML attributes: they must be
/// non-empty and free of XML-1.0-illegal control characters (the escaper
/// passes those through, and the SDK rejects the result).
fn check_metadata(
what : String,
value : String,
) -> Unit raise PackageBuildError {
if value == "" {
raise PackageBuildError("\{what} must not be empty")
}
for unit in value {
let code = unit.to_int()
if code < 0x20 && !(unit is ('\t' | '\n' | '\r')) {
raise PackageBuildError(
"\{what} contains an XML-illegal control character (code \{code})",
)
}
}
}
///|
fn split_part_dir(part : String) -> String {
match part.rev_find("/") {
Some(index) => part[:index].to_owned()
None => ""
}
}
///|
fn PackageBuilder::check_coverage(
self : PackageBuilder,
name : String,
) -> Unit raise PackageBuildError {
if name == CONTENT_TYPES_PART {
return
}
if self.content_type_for_part(name) is Some(_) {
return
}
raise PackageBuildError(
"no content type for part '\{name}': add a Default for its extension or an Override",
)
}
///|
/// Resolves the authoritative content type with OPC Override-before-Default
/// precedence. Builder validation calls this only after both declaration maps
/// have been revalidated from their private state.
fn PackageBuilder::content_type_for_part(
self : PackageBuilder,
name : String,
) -> String? {
let override_key = builder_part_identity("/" + name)
for part_name, content_type in self.overrides {
if builder_part_identity(part_name) == override_key {
return Some(content_type)
}
}
match part_extension(name) {
Some(extension) => self.defaults.get(extension)
None => None
}
}
///|
fn PackageBuilder::check_relationship_content_type(
self : PackageBuilder,
name : String,
) -> Unit raise PackageBuildError {
match self.content_type_for_part(name) {
Some(value) if value.trim().to_lower() == RELATIONSHIPS_CONTENT_TYPE => ()
Some(value) =>
raise PackageBuildError(
"builder-generated relationship part '\{name}' has content type '\{value}', expected '\{RELATIONSHIPS_CONTENT_TYPE}'",
)
None => () // `check_coverage` reports the more actionable missing mapping.
}
}
///|
fn PackageBuilder::content_types_xml(self : PackageBuilder) -> String {
let children : Array[@xml.XmlNode] = []
for extension, content_type in self.defaults {
children.push(
XmlElement(
@xml.xml_element("Default", attributes={
"Extension": extension,
"ContentType": content_type,
}),
),
)
}
for part_name, content_type in self.overrides {
children.push(
XmlElement(
@xml.xml_element("Override", attributes={
"PartName": part_name,
"ContentType": content_type,
}),
),
)
}
// write_xml_string supplies the XML declaration.
@xml.write_xml_string(@xml.xml_element("Types", children~), namespaces={
"": "http://schemas.openxmlformats.org/package/2006/content-types",
})
}
///|
fn relationships_xml(rels : Array[PackageRelationship]) -> String {
let children : Array[@xml.XmlNode] = []
for relationship in rels {
let attributes : Map[String, String] = {
"Id": relationship.id,
"Type": relationship.relationship_type,
"Target": relationship.target,
}
if relationship.external {
attributes["TargetMode"] = "External"
}
children.push(XmlElement(@xml.xml_element("Relationship", attributes~)))
}
@xml.write_xml_string(@xml.xml_element("Relationships", children~), namespaces={
"": "http://schemas.openxmlformats.org/package/2006/relationships",
})
}
///|
/// White-box: reach build()-time validation directly (the public setters
/// already reject these states, and the fields are private outside the
/// package — this pins the authoritative-revalidation layer itself).
test "build revalidates relationship metadata from raw state" {
let raw_relationships : Array[PackageRelationship] = [
{
id: "rId1",
relationship_type: "types/test",
target: "word/document.xml",
external: false,
},
{
id: "rId1",
relationship_type: "urn:test",
target: "word/document.xml",
external: false,
},
]
let builder = PackageBuilder::{
parts: [("word/document.xml", b"")],
defaults: SortedMap([
("rels", "application/vnd.openxmlformats-package.relationships+xml"),
("xml", "application/xml"),
]),
overrides: SortedMap([]),
relationships: SortedMap([("", raw_relationships)]),
part_names: PartNameRegistry::new(),
zip_name_chars: 0,
}
try builder.build() catch {
PackageBuildError(message) =>
inspect(
message.has_prefix(
"relationship Type must be an absolute IRI without a fragment",
),
content="true",
)
PackageLimitExceeded(_) => fail("unexpected package limit error")
} noraise {
_ => fail("expected build-time relationship-Type rejection")
}
raw_relationships[0] = {
id: "rId1",
relationship_type: "urn:test",
target: "https://example.test/bad path",
external: true,
}
try builder.build() catch {
PackageBuildError(message) =>
inspect(
message.has_prefix(
"external relationship Target is not a valid IRI reference",
),
content="true",
)
PackageLimitExceeded(_) => fail("unexpected package limit error")
} noraise {
_ => fail("expected build-time external-Target rejection")
}
raw_relationships[0] = {
id: "rId1",
relationship_type: "urn:test",
target: "word/document.xml",
external: false,
}
try builder.build() catch {
PackageBuildError(message) =>
inspect(
message.has_prefix("duplicate relationship Id 'rId1' for source ''"),
content="true",
)
PackageLimitExceeded(_) => fail("unexpected package limit error")
} noraise {
_ => fail("expected build-time duplicate-id rejection")
}
}
///|
test "build revalidates part names from raw state" {
let builder = PackageBuilder::{
parts: [("word/document.xml", b""), ("word/document.xml", b"")],
defaults: SortedMap([("xml", "application/xml")]),
overrides: SortedMap([]),
relationships: SortedMap([]),
part_names: PartNameRegistry::new(),
zip_name_chars: 0,
}
try builder.build() catch {
PackageBuildError(message) =>
inspect(
message.has_prefix("duplicate part name: word/document.xml"),
content="true",
)
PackageLimitExceeded(_) => fail("unexpected package limit error")
} noraise {
_ => fail("expected build-time duplicate-part rejection")
}
}
///|
test "build revalidates content-type defaults from raw state" {
let builder = PackageBuilder::{
parts: [],
defaults: SortedMap([("bad.ext", "application/xml")]),
overrides: SortedMap([]),
relationships: SortedMap([]),
part_names: PartNameRegistry::new(),
zip_name_chars: 0,
}
try builder.build() catch {
PackageBuildError(message) =>
inspect(
message.has_prefix(
"Default extension is not a valid OPC ST_Extension: bad.ext",
),
content="true",
)
PackageLimitExceeded(_) => fail("unexpected package limit error")
} noraise {
_ => fail("expected build-time ST_Extension rejection")
}
}
///|
/// A minimal valid single-part package builder for limit tests.
fn limit_test_builder() -> PackageBuilder raise PackageBuildError {
let builder = PackageBuilder::new()
builder.add_default("xml", "application/xml")
builder.add_override("/word/document.xml", "application/xml")
builder.add_part("word/document.xml", b"")
builder
}
///|
test "build_limited accepts a package within every ceiling" {
let bytes = limit_test_builder().build_limited({
max_output_bytes: 1 << 20,
max_entries: 16,
max_entry_uncompressed_bytes: 1 << 20,
max_total_uncompressed_bytes: 1 << 20,
})
// content-types + document.xml round-trips as a real zip
assert_true(bytes.length() > 0)
}
///|
test "build_limited refuses over each ceiling with a typed breach" {
fn breach(limits : PackageLimits, kind : String) -> Unit raise {
try limit_test_builder().build_limited(limits) |> ignore catch {
PackageLimitExceeded(kind=k, ..) => assert_eq(k, kind)
PackageBuildError(m) => fail("expected a typed limit, got: \{m}")
} noraise {
_ => fail("expected \{kind} ceiling to be exceeded")
}
}
// entry count: content-types + the document part = 2 entries > 1
breach(
{
max_output_bytes: 1 << 20,
max_entries: 1,
max_entry_uncompressed_bytes: 1 << 20,
max_total_uncompressed_bytes: 1 << 20,
},
"entries",
)
// a single entry larger than the per-entry ceiling
breach(
{
max_output_bytes: 1 << 20,
max_entries: 16,
max_entry_uncompressed_bytes: 4,
max_total_uncompressed_bytes: 1 << 20,
},
"entry_uncompressed_bytes",
)
// aggregate uncompressed over the total ceiling
breach(
{
max_output_bytes: 1 << 20,
max_entries: 16,
max_entry_uncompressed_bytes: 1 << 20,
max_total_uncompressed_bytes: 8,
},
"total_uncompressed_bytes",
)
// output bytes: a 1-byte package cannot hold a real zip
breach(
{
max_output_bytes: 1,
max_entries: 16,
max_entry_uncompressed_bytes: 1 << 20,
max_total_uncompressed_bytes: 1 << 20,
},
"output_bytes",
)
}