///|
fn workbook_content_type_candidates() -> Array[String] {
[
ct_workbook, ct_workbook_macro, ct_workbook_addin_macro, ct_workbook_template,
ct_workbook_template_macro,
]
}
///|
fn workbook_part_from_root_relationships(
archive : @zip.Archive,
part_names : Map[String, String],
content_types : @ooxml.PackageContentTypes,
decode : (BytesView) -> String raise XlsxError,
budget? : ReadBudget,
cancelled? : () -> Bool = () => false,
) -> String raise XlsxError {
let root_rels_path = actual_archive_part_path(part_names, root_rels_part_path)
let root_rels_xml = decode(
load_relationship_part(
archive,
content_types,
root_rels_path,
"root relationships",
cancelled~,
),
)
let office_document_type = transitional_office_relationship_prefix +
"officeDocument"
let targets = parse_internal_relationship_targets(
root_rels_xml,
office_document_type,
budget?,
cancelled~,
)
if targets.length() > 1 {
raise InvalidXml(msg="root relationships contain multiple workbook targets")
}
match first_relationship_target(targets) {
Some(target) => {
validate_relationship_target_uri(target, cancelled~)
let part_name = archive_path_from_part_name(target)
normalize_rel_part_path(part_name, cancelled~)
}
None => raise InvalidXml(msg="root workbook relationship missing")
}
}
///|
fn workbook_content_type_is_supported(value : StringView) -> Bool {
let normalized = value.to_owned().to_lower()
for candidate in workbook_content_type_candidates() {
if normalized == candidate.to_lower() {
return true
}
}
false
}
///|
fn require_part_content_type(
content_types : @ooxml.PackageContentTypes,
part_name : StringView,
expected : ArrayView[String],
role : StringView,
cancelled? : () -> Bool = () => false,
) -> Unit raise XlsxError {
let declared = declared_part_content_type(
content_types,
part_name,
role,
cancelled~,
)
let normalized = declared.to_lower()
for candidate in expected {
if normalized == candidate.to_lower() {
return
}
}
raise InvalidXml(msg="\{role.to_owned()} content type is invalid")
}
///|
/// Returns the manifest identity for a package part. This is the single place
/// where missing content declarations become XLSX package errors.
fn declared_part_content_type(
content_types : @ooxml.PackageContentTypes,
part_name : StringView,
role : StringView,
cancelled? : () -> Bool = () => false,
) -> String raise XlsxError {
let declared = content_types.content_type_for(part_name, cancelled~) catch {
InvalidXml(msg~) => raise InvalidXml(msg~)
ReadCancelled => raise ReadCancelled
}
match declared {
Some(value) => value
None => raise InvalidXml(msg="\{role.to_owned()} content type is missing")
}
}
///|
/// Loads a relationship-selected package part before validating its semantic
/// media type. Missing targets remain `MissingPart`; an existing part whose
/// manifest identity does not match `expected` is rejected as invalid XML.
fn load_typed_part(
archive : @zip.Archive,
content_types : @ooxml.PackageContentTypes,
path : StringView,
expected : ArrayView[String],
role : StringView,
cancelled? : () -> Bool = () => false,
) -> BytesView raise XlsxError {
let bytes = match archive.get(path) {
Some(value) => value
None => raise MissingPart(path=path.to_owned())
}
require_part_content_type(
content_types,
logical_archive_part_path(path),
expected,
role,
cancelled~,
)
bytes
}
///|
/// Loads and authenticates an OPC relationship part. Relationship XML has one
/// fixed media type regardless of the source part that owns it.
fn load_relationship_part(
archive : @zip.Archive,
content_types : @ooxml.PackageContentTypes,
path : StringView,
role : StringView,
cancelled? : () -> Bool = () => false,
) -> BytesView raise XlsxError {
load_typed_part(
archive,
content_types,
path,
[ct_relationships],
role,
cancelled~,
)
}
///|
/// Optional relationship parts remain optional, but an existing part is never
/// consumed unless its manifest identity is the fixed OPC relationships type.
fn load_optional_relationship_part(
archive : @zip.Archive,
content_types : @ooxml.PackageContentTypes,
path : StringView,
role : StringView,
cancelled? : () -> Bool = () => false,
) -> BytesView? raise XlsxError {
match archive.get(path) {
Some(_) =>
Some(
load_relationship_part(archive, content_types, path, role, cancelled~),
)
None => None
}
}
///|
test "read package parts: relationship parts require their fixed manifest identity" {
let archive = @zip.Archive::new()
archive.add("custom/_rels/source.xml.rels", b"")
let missing = try! @ooxml.parse_package_content_types(
"",
)
let wrong = try! @ooxml.parse_package_content_types(
"",
)
let valid = try! @ooxml.parse_package_content_types(
"",
)
for
entry in [
(missing, "test relationships content type is missing"),
(wrong, "test relationships content type is invalid"),
] {
let (content_types, expected) = entry
try
load_relationship_part(
archive, content_types, "custom/_rels/source.xml.rels", "test relationships",
)
catch {
InvalidXml(msg~) => assert_eq(msg, expected)
_ => fail("unexpected relationship content-type error")
} noraise {
_ => fail("expected relationship content-type rejection")
}
}
assert_eq(
load_relationship_part(
archive, valid, "custom/_rels/source.xml.rels", "test relationships",
),
b"",
)
}
///|
test "read package parts: workbook media types are case-insensitive" {
assert_true(
workbook_content_type_is_supported(
"Application/Vnd.Openxmlformats-Officedocument.Spreadsheetml.Sheet.Main+Xml",
),
)
assert_true(
workbook_content_type_is_supported(
"APPLICATION/VND.MS-EXCEL.SHEET.MACROENABLED.MAIN+XML",
),
)
}
///|
fn resolve_workbook_xml_part_path(
archive : @zip.Archive,
part_names : Map[String, String],
decode : (BytesView) -> String raise XlsxError,
budget? : ReadBudget,
cancelled? : () -> Bool = () => false,
) -> (String, @ooxml.PackageContentTypes) raise XlsxError {
// The content-type manifest authenticates every consumed package part,
// including the root relationship part, so it must be parsed first.
let content_types_path = actual_archive_part_path(
part_names, content_types_part_path,
)
let content_types_xml = match archive.get(content_types_path) {
Some(content_types_bytes) => decode(content_types_bytes)
None => raise MissingPart(path=content_types_path)
}
match budget {
Some(value) => {
value.checkpoint()
value.charge_work(content_types_xml.length())
}
None => check_read_cancelled(cancelled)
}
let content_types = @ooxml.parse_package_content_types(
content_types_xml,
cancelled~,
) catch {
InvalidXml(msg~) => raise InvalidXml(msg~)
ReadCancelled => raise ReadCancelled
}
let workbook_part = workbook_part_from_root_relationships(
archive,
part_names,
content_types,
decode,
budget?,
cancelled~,
)
require_part_content_type(
content_types,
workbook_part,
workbook_content_type_candidates(),
"root workbook",
cancelled~,
)
(workbook_part, content_types)
}
///|
fn decode_utf8_or_invalid_xml(bytes : BytesView) -> String raise XlsxError {
@encoding/utf8.decode(bytes) catch {
_ => raise InvalidXml(msg="invalid utf8")
}
}
///|
fn resolve_workbook_xml_part_path_for_test(
archive : @zip.Archive,
) -> String raise XlsxError {
let (path, _) = resolve_workbook_xml_part_path(
archive,
archive_part_name_index(archive),
decode_utf8_or_invalid_xml,
)
path
}
///|
test "read package parts: root relationship is required" {
let archive = @zip.Archive::new()
archive.add(
content_types_part_path,
@encoding/utf8.encode(
"",
),
)
try resolve_workbook_xml_part_path_for_test(archive) catch {
MissingPart(path~) => inspect(path, content="_rels/.rels")
_ => fail("unexpected missing-root error")
} noraise {
_ => fail("expected missing root relationships")
}
}
///|
test "read package parts: workbook path follows the root relationship" {
let archive = @zip.Archive::new()
let content_types =
#|
#|
#|
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(content_types))
let root_relationships =
#|
#|
#|
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
inspect(
resolve_workbook_xml_part_path_for_test(archive),
content="xl/workbook2.xml",
)
}
///|
fn workbook_part_from_root_target_for_test(
target : StringView,
) -> String raise XlsxError {
let archive = @zip.Archive::new()
let root_relationships = "" +
""
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
let content_types = @ooxml.parse_package_content_types(
"",
) catch {
InvalidXml(msg~) => raise InvalidXml(msg~)
ReadCancelled => raise ReadCancelled
}
workbook_part_from_root_relationships(
archive,
archive_part_name_index(archive),
content_types,
decode_utf8_or_invalid_xml,
)
}
///|
test "read package parts: root target rejects absolute URI syntax" {
for target in ["urn:book.xml", "a:b/c"] {
try workbook_part_from_root_target_for_test(target) catch {
InvalidXml(msg~) => inspect(msg, content="relationship target invalid")
_ => fail("unexpected absolute root-target error")
} noraise {
_ => fail("expected absolute root-target rejection")
}
}
}
///|
test "read package parts: explicit relative and package-absolute root targets remain valid" {
for target in ["./urn:book.xml", "/urn:book.xml"] {
inspect(
workbook_part_from_root_target_for_test(target),
content="urn:book.xml",
)
}
}
///|
test "read package parts: external workbook relationship is never resolved as a part" {
let archive = @zip.Archive::new()
let content_types =
#|
#|
#|
#|
let root_relationships =
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(content_types))
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
try resolve_workbook_xml_part_path_for_test(archive) catch {
InvalidXml(msg~) =>
inspect(msg, content="root workbook relationship missing")
_ => fail("unexpected external-root error")
} noraise {
_ => fail("expected external root relationship rejection")
}
}
///|
test "read package parts: root target must have a workbook content type" {
let archive = @zip.Archive::new()
let content_types =
#|
#|
#|
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(content_types))
let root_relationships =
#|
#|
#|
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
try resolve_workbook_xml_part_path_for_test(archive) catch {
InvalidXml(msg~) =>
inspect(msg, content="root workbook content type is missing")
_ => fail("unexpected workbook content-type error")
} noraise {
_ => fail("expected missing workbook content type")
}
}
///|
test "read package parts: malformed [Content_Types] raises InvalidXml" {
let archive = @zip.Archive::new()
let malformed =
#|
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(malformed))
let root_relationships =
#|
#|
#|
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
let result = Ok(resolve_workbook_xml_part_path_for_test(archive)) catch {
e => Err(e)
}
match result {
Err(InvalidXml(msg~)) => inspect(msg, content="XML start tag is not closed")
_ => fail("expected content type override tag not closed")
}
}
///|
test "read package parts: invalid root workbook target raises InvalidXml" {
let archive = @zip.Archive::new()
let invalid_part_name =
#|
#|
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(invalid_part_name))
let root_relationships =
#|
#|
#|
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
let result = Ok(resolve_workbook_xml_part_path_for_test(archive)) catch {
e => Err(e)
}
match result {
Err(InvalidXml(msg~)) =>
inspect(msg, content="content type override part name is invalid")
_ => fail("expected content type part name empty")
}
}
///|
test "read package parts: stale workbook override cannot redirect the root relationship" {
let archive = @zip.Archive::new()
let content_types =
#|
#|
#|
#|
#|
let root_relationships =
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(content_types))
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
inspect(
resolve_workbook_xml_part_path_for_test(archive),
content="actual/book.xml",
)
}
///|
test "read package parts: default-declared workbook follows the root relationship" {
let archive = @zip.Archive::new()
let content_types =
#|
#|
#|
#|
let root_relationships =
#|
#|
#|
archive.add(content_types_part_path, @encoding/utf8.encode(content_types))
archive.add(root_rels_part_path, @encoding/utf8.encode(root_relationships))
inspect(
resolve_workbook_xml_part_path_for_test(archive),
content="custom/book.xml",
)
}