// Portable structural validation of a docx OPC package — the tier behind
// `docx validate` (native AND wasm; the .NET OpenXML SDK schema tier is a
// separate native-only development gate). Checks are package-structural,
// not schema-deep:
//
// 1. The archive opens, entry names are unique and well-formed, and each
// entry's data matches its stored CRC-32 (the inflate path tolerates
// corruption the CRC exposes — raw central-directory entries are used
// because the map-backed reader wrapper silently drops duplicates).
// 2. `[Content_Types].xml` exists, parses as strict DTD-free XML, and covers
// every part via an extension default or an exact override; overrides
// point at parts that exist.
// 3. `_rels/.rels` exists, parses, and names an officeDocument
// relationship whose target exists (the main document part is found by
// FOLLOWING that relationship — never a hardcoded `word/document.xml`).
// 4. The selected part has the DOCX main content type and parses as strict
// XML with a WordprocessingML `document` root whose namespace matches the
// transitional or Strict officeDocument relationship dialect.
// 5. Every part's own relationships (`dir/_rels/name.rels`), when
// present, parse and their internal targets resolve to existing parts.
//
// Returns findings as agent-readable strings; empty means structurally
// valid. Deliberately does NOT validate WordprocessingML content — that is
// the SDK tier's job.
///|
const CONTENT_TYPES_PART : String = "[Content_Types].xml"
///|
const ROOT_RELS_PART : String = "_rels/.rels"
///|
const PACKAGE_RELATIONSHIPS_NAMESPACE : String = "http://schemas.openxmlformats.org/package/2006/relationships"
///|
const CONTENT_TYPES_ROOT : String = "{http://schemas.openxmlformats.org/package/2006/content-types}Types"
///|
const RELATIONSHIPS_CONTENT_TYPE : String = "application/vnd.openxmlformats-package.relationships+xml"
///|
const CONTENT_TYPES_DEFAULT : String = "{http://schemas.openxmlformats.org/package/2006/content-types}Default"
///|
const CONTENT_TYPES_OVERRIDE : String = "{http://schemas.openxmlformats.org/package/2006/content-types}Override"
///|
const TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP : String = "http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument"
///|
const STRICT_OFFICE_DOCUMENT_RELATIONSHIP : String = "http://purl.oclc.org/ooxml/officeDocument/relationships/officeDocument"
///|
const TRANSITIONAL_DOCUMENT_ROOT : String = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}document"
///|
const STRICT_DOCUMENT_ROOT : String = "{http://purl.oclc.org/ooxml/wordprocessingml/main}document"
///|
const DOCX_MAIN_CONTENT_TYPE : String = "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"
///|
const DEFAULT_MAX_FINDINGS : Int = 1024
///|
const DEFAULT_MAX_FINDING_CHARS : Int = 2048
///|
const DEFAULT_MAX_XML_SOURCE_UNITS : Int = 16 * 1024 * 1024
///|
const DEFAULT_MAX_XML_TOKENS : Int = 262_144
///|
const DEFAULT_MAX_XML_MATERIALIZED_CHARS : Int = 8 * 1024 * 1024
///|
const DEFAULT_MAX_XML_TOKEN_CHARS : Int = 4 * 1024 * 1024
///|
const DEFAULT_MAX_ZIP_ENTRIES : Int = 4096
///|
const DEFAULT_MAX_ZIP_PACKAGE_BYTES : Int = 64 * 1024 * 1024
///|
const DEFAULT_MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES : Int = 32 * 1024 * 1024
///|
const DEFAULT_MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES : Int = 128 * 1024 * 1024
///|
const DEFAULT_MAX_ZIP_TOTAL_PRESERVED_SOURCE_BYTES : Int = 64 * 1024 * 1024 +
65_535
///|
const DEFAULT_MAX_ZIP_ENTRY_NAME_CHARS : Int = 65_535
///|
const DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS : Int = 4 * 1024 * 1024
///|
/// ECMA-376 Part 2 §7.3.6 caps the Central Directory File Header—not merely
/// its File Name field. The four-byte ZIP signature is outside that size; the
/// remaining fixed fields consume 42 bytes before File Name, Extra, and File
/// Comment data.
const MAX_OPC_CENTRAL_DIRECTORY_FILE_HEADER_BYTES : Int = 65_535
///|
const MAX_OPC_ZIP_ENTRY_NAME_BYTES_WITHOUT_METADATA : Int = 65_493
///|
/// The resource guard that stopped structural validation. This status is kept
/// separate from rendered findings so attacker-controlled part names cannot
/// forge or hide resource exhaustion by changing diagnostic text.
pub(all) enum DocxValidationResourceLimit {
ZipPackageBytes
ZipEntries
ZipEntryUncompressedBytes
ZipTotalUncompressedBytes
ZipTotalPreservedSourceBytes
ZipEntryNameCharacters
ZipTotalEntryNameCharacters
XmlSourceUnits
XmlTokens
XmlTokenLength
XmlMaterializedCharacters
XmlNestingDepth
}
///|
/// Returns the stable machine name for this resource guard.
pub fn DocxValidationResourceLimit::name(
self : DocxValidationResourceLimit,
) -> String {
match self {
ZipPackageBytes => "zip-package-bytes"
ZipEntries => "zip-entry-count"
ZipEntryUncompressedBytes => "zip-entry-uncompressed-bytes"
ZipTotalUncompressedBytes => "zip-total-uncompressed-bytes"
ZipTotalPreservedSourceBytes => "zip-total-preserved-source-bytes"
ZipEntryNameCharacters => "zip-entry-name-characters"
ZipTotalEntryNameCharacters => "zip-total-entry-name-characters"
XmlSourceUnits => "xml-source-units"
XmlTokens => "xml-tokens"
XmlTokenLength => "xml-token-length"
XmlMaterializedCharacters => "xml-materialized-characters"
XmlNestingDepth => "xml-nesting-depth"
}
}
///|
/// A bounded structural-validation result. Findings remain suitable for
/// humans; `resource_limit` is the authoritative machine classification.
pub struct DocxValidationReport {
priv findings : Array[String]
priv findings_truncated : Bool
priv resource_limit : DocxValidationResourceLimit?
priv main_document_part : String?
}
///|
/// Returns a defensive copy of the bounded human-readable findings.
pub fn DocxValidationReport::findings(
self : DocxValidationReport,
) -> Array[String] {
self.findings.copy()
}
///|
/// Reports whether validation stopped at the caller's finding-count ceiling.
pub fn DocxValidationReport::findings_truncated(
self : DocxValidationReport,
) -> Bool {
self.findings_truncated
}
///|
/// Returns the ZIP or XML resource guard that stopped validation, when present.
pub fn DocxValidationReport::resource_limit(
self : DocxValidationReport,
) -> DocxValidationResourceLimit? {
self.resource_limit
}
///|
/// Returns the exact physical archive entry selected by the validated
/// package-root officeDocument relationship. Callers that subsequently parse
/// the package should pin their reader to this identity so validation and
/// interpretation cannot diverge.
pub fn DocxValidationReport::main_document_part(
self : DocxValidationReport,
) -> String? {
self.main_document_part
}
///|
priv struct FindingCollector {
findings : Array[String]
max_findings : Int
max_message_chars : Int
max_zip_entry_name_chars : Int
max_zip_total_entry_name_chars : Int
mut zip_entry_name_chars : Int
xml_budget : @xml.XmlReadBudget
cancelled : () -> Bool
mut truncated : Bool
mut resource_limit : DocxValidationResourceLimit?
mut main_document_part : String?
}
///|
fn FindingCollector::new_with_xml_limits(
max_findings : Int,
max_message_chars : Int,
max_zip_entry_name_chars : Int,
max_zip_total_entry_name_chars : Int,
max_xml_source_units : Int,
max_xml_tokens : Int,
max_xml_materialized_chars : Int,
max_xml_token_chars : Int,
cancelled : () -> Bool,
) -> FindingCollector {
{
findings: [],
max_findings: if max_findings > 0 {
max_findings
} else {
0
},
max_message_chars: if max_message_chars > 0 {
max_message_chars
} else {
0
},
max_zip_entry_name_chars: if max_zip_entry_name_chars > 0 {
max_zip_entry_name_chars
} else {
0
},
max_zip_total_entry_name_chars: if max_zip_total_entry_name_chars > 0 {
max_zip_total_entry_name_chars
} else {
0
},
zip_entry_name_chars: 0,
xml_budget: @xml.xml_read_budget(
max_source_units=if max_xml_source_units > 0 {
max_xml_source_units
} else {
0
},
max_tokens=if max_xml_tokens > 0 { max_xml_tokens } else { 0 },
max_materialized_chars=if max_xml_materialized_chars > 0 {
max_xml_materialized_chars
} else {
0
},
max_token_chars=if max_xml_token_chars > 0 {
max_xml_token_chars
} else {
0
},
cancelled~,
),
cancelled,
truncated: false,
resource_limit: None,
main_document_part: None,
}
}
///|
fn FindingCollector::consume_zip_entry_name(
self : FindingCollector,
name : String,
) -> Bool {
let length = name.length()
if length > self.max_zip_entry_name_chars {
if self.resource_limit is None {
self.resource_limit = Some(ZipEntryNameCharacters)
}
self.add("ZIP entry name exceeds the character budget: \{self.text(name)}")
self.stop()
return false
}
if length > self.max_zip_total_entry_name_chars - self.zip_entry_name_chars {
if self.resource_limit is None {
self.resource_limit = Some(ZipTotalEntryNameCharacters)
}
self.add("ZIP entry names exceed the aggregate character budget")
self.stop()
return false
}
self.zip_entry_name_chars = self.zip_entry_name_chars + length
true
}
///|
fn xml_validation_resource_limit(
error : @core.DocxError,
) -> DocxValidationResourceLimit? {
match error {
ResourceLimit(limit~, ..) =>
match limit {
DocxXmlSourceUnits => Some(XmlSourceUnits)
DocxXmlTokens => Some(XmlTokens)
DocxXmlTokenLength => Some(XmlTokenLength)
DocxXmlMaterializedCharacters => Some(XmlMaterializedCharacters)
DocxXmlNestingDepth => Some(XmlNestingDepth)
}
_ => None
}
}
///|
fn zip_validation_resource_limit(kind : String) -> DocxValidationResourceLimit? {
match kind {
"package_bytes" => Some(ZipPackageBytes)
"entry_count" => Some(ZipEntries)
"entry_uncompressed_bytes" => Some(ZipEntryUncompressedBytes)
"total_uncompressed_bytes" => Some(ZipTotalUncompressedBytes)
"total_preserved_source_bytes" => Some(ZipTotalPreservedSourceBytes)
_ => None
}
}
///|
fn FindingCollector::mark_zip_resource_limit(
self : FindingCollector,
kind : String,
) -> Unit {
if self.resource_limit is None {
self.resource_limit = zip_validation_resource_limit(kind)
}
}
///|
fn FindingCollector::mark_resource_limit(
self : FindingCollector,
error : @core.DocxError,
) -> Unit {
if self.resource_limit is None {
self.resource_limit = xml_validation_resource_limit(error)
}
}
///|
fn FindingCollector::report(self : FindingCollector) -> DocxValidationReport {
{
findings: self.findings,
findings_truncated: self.truncated,
resource_limit: self.resource_limit,
main_document_part: self.main_document_part,
}
}
///|
fn valid_finding_text(text : String, limit : Int) -> Bool {
let mut offset = 0
let mut scalars = 0
while offset < text.length() {
let unit = text[offset]
if unit.is_leading_surrogate() {
if offset + 1 >= text.length() ||
!text[offset + 1].is_trailing_surrogate() {
return false
}
offset = offset + 2
} else if unit.is_trailing_surrogate() {
return false
} else {
offset = offset + 1
}
scalars = scalars + 1
if scalars > limit {
return false
}
}
true
}
///|
fn bounded_finding_text(text : String, limit : Int) -> String {
if limit <= 0 {
return ""
}
if valid_finding_text(text, limit) {
return text
}
if !valid_finding_text(text, 0x7fffffff) {
return bounded_finding_text("", limit)
}
let output = StringBuilder::new()
let mut count = 0
let content_limit = if limit > 1 { limit - 1 } else { 0 }
for character in text {
if count == content_limit {
output.write_string("…") |> ignore
break
}
output.write_char(character) |> ignore
count = count + 1
}
output.to_string()
}
///|
fn FindingCollector::text(self : FindingCollector, text : String) -> String {
bounded_finding_text(text, self.max_message_chars)
}
///|
fn FindingCollector::add(self : FindingCollector, message : String) -> Unit {
if self.findings.length() >= self.max_findings {
self.truncated = true
return
}
self.findings.push(bounded_finding_text(message, self.max_message_chars))
}
///|
fn FindingCollector::full(self : FindingCollector) -> Bool {
self.findings.length() >= self.max_findings
}
///|
fn FindingCollector::stop(self : FindingCollector) -> Unit {
self.truncated = true
}
///|
/// Polls the caller's cooperative cancellation hook. Cancellation is recorded
/// as a stopped/truncated validation rather than a finding so command callers
/// can translate their own cancellation state without attacker-controlled
/// diagnostics obscuring it.
fn FindingCollector::checkpoint(self : FindingCollector) -> Bool {
if (self.cancelled)() {
self.stop()
false
} else {
true
}
}
///|
fn default_findings(result : (Array[String], Bool)) -> Array[String] {
let (findings, truncated) = result
if truncated {
if findings.length() == DEFAULT_MAX_FINDINGS {
let _ = findings.pop()
}
findings.push("additional validation findings were omitted")
}
findings
}
///|
/// Validates docx package structure. Returns bounded findings; empty = valid.
pub fn validate_docx_package(data : BytesView) -> Array[String] {
default_findings(
validate_docx_package_limited(
data,
max_findings=DEFAULT_MAX_FINDINGS,
max_message_chars=DEFAULT_MAX_FINDING_CHARS,
),
)
}
///|
/// Validates package bytes while bounding findings as they are produced.
/// `truncated=true` means validation stopped after filling the caller's cap.
pub fn validate_docx_package_limited(
data : BytesView,
max_findings~ : Int,
max_message_chars~ : Int,
max_zip_package_bytes? : Int = DEFAULT_MAX_ZIP_PACKAGE_BYTES,
max_zip_entries? : Int = DEFAULT_MAX_ZIP_ENTRIES,
max_zip_entry_uncompressed_bytes? : Int = DEFAULT_MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES,
max_zip_total_uncompressed_bytes? : Int = DEFAULT_MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES,
max_zip_total_preserved_source_bytes? : Int = DEFAULT_MAX_ZIP_TOTAL_PRESERVED_SOURCE_BYTES,
max_zip_entry_name_chars? : Int = DEFAULT_MAX_ZIP_ENTRY_NAME_CHARS,
max_zip_total_entry_name_chars? : Int = DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS,
max_xml_source_units? : Int = DEFAULT_MAX_XML_SOURCE_UNITS,
max_xml_tokens? : Int = DEFAULT_MAX_XML_TOKENS,
max_xml_materialized_chars? : Int = DEFAULT_MAX_XML_MATERIALIZED_CHARS,
max_xml_token_chars? : Int = DEFAULT_MAX_XML_TOKEN_CHARS,
) -> (Array[String], Bool) {
let report = validate_docx_package_report_limited(
data,
max_findings~,
max_message_chars~,
max_zip_package_bytes~,
max_zip_entries~,
max_zip_entry_uncompressed_bytes~,
max_zip_total_uncompressed_bytes~,
max_zip_total_preserved_source_bytes~,
max_zip_entry_name_chars~,
max_zip_total_entry_name_chars~,
max_xml_source_units~,
max_xml_tokens~,
max_xml_materialized_chars~,
max_xml_token_chars~,
)
(report.findings, report.findings_truncated)
}
///|
/// The typed counterpart to `validate_docx_package_limited`. Resource status
/// is captured from the ZIP/XML error before any attacker-controlled text is
/// interpolated or truncated into a finding.
pub fn validate_docx_package_report_limited(
data : BytesView,
max_findings~ : Int,
max_message_chars~ : Int,
max_zip_package_bytes? : Int = DEFAULT_MAX_ZIP_PACKAGE_BYTES,
max_zip_entries? : Int = DEFAULT_MAX_ZIP_ENTRIES,
max_zip_entry_uncompressed_bytes? : Int = DEFAULT_MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES,
max_zip_total_uncompressed_bytes? : Int = DEFAULT_MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES,
max_zip_total_preserved_source_bytes? : Int = DEFAULT_MAX_ZIP_TOTAL_PRESERVED_SOURCE_BYTES,
max_zip_entry_name_chars? : Int = DEFAULT_MAX_ZIP_ENTRY_NAME_CHARS,
max_zip_total_entry_name_chars? : Int = DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS,
max_xml_source_units? : Int = DEFAULT_MAX_XML_SOURCE_UNITS,
max_xml_tokens? : Int = DEFAULT_MAX_XML_TOKENS,
max_xml_materialized_chars? : Int = DEFAULT_MAX_XML_MATERIALIZED_CHARS,
max_xml_token_chars? : Int = DEFAULT_MAX_XML_TOKEN_CHARS,
) -> DocxValidationReport {
let findings = FindingCollector::new_with_xml_limits(
max_findings,
max_message_chars,
max_zip_entry_name_chars,
max_zip_total_entry_name_chars,
max_xml_source_units,
max_xml_tokens,
max_xml_materialized_chars,
max_xml_token_chars,
() => false,
)
let archive = @mbtzip.read_limited(
data,
max_package_bytes=if max_zip_package_bytes > 0 {
max_zip_package_bytes
} else {
0
},
max_entries=if max_zip_entries > 0 { max_zip_entries } else { 0 },
max_entry_uncompressed_bytes=if max_zip_entry_uncompressed_bytes > 0 {
max_zip_entry_uncompressed_bytes
} else {
0
},
max_total_uncompressed_bytes=if max_zip_total_uncompressed_bytes > 0 {
max_zip_total_uncompressed_bytes
} else {
0
},
max_total_preserved_source_bytes=if max_zip_total_preserved_source_bytes > 0 {
max_zip_total_preserved_source_bytes
} else {
0
},
) catch {
ResourceLimitExceeded(kind~, limit=_, actual=_) as err => {
findings.mark_zip_resource_limit(kind)
findings.add("invalid ZIP archive: \{findings.text(repr(err))}")
return findings.report()
}
err => {
findings.add("invalid ZIP archive: \{findings.text(repr(err))}")
return findings.report()
}
}
validate_docx_archive_into(archive, findings)
findings.report()
}
///|
/// Validates an already materialized DOCX archive without reading or inflating
/// the package again. The caller retains ownership; validation is read-only.
pub fn validate_docx_archive(archive : @mbtzip.Archive) -> Array[String] {
default_findings(
validate_docx_archive_limited(
archive,
max_findings=DEFAULT_MAX_FINDINGS,
max_message_chars=DEFAULT_MAX_FINDING_CHARS,
),
)
}
///|
/// Archive-backed validation with a hard finding count and per-message scalar
/// ceiling applied during production rather than after an unbounded array is
/// built.
pub fn validate_docx_archive_limited(
archive : @mbtzip.Archive,
max_findings~ : Int,
max_message_chars~ : Int,
max_zip_entry_name_chars? : Int = DEFAULT_MAX_ZIP_ENTRY_NAME_CHARS,
max_zip_total_entry_name_chars? : Int = DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS,
max_xml_source_units? : Int = DEFAULT_MAX_XML_SOURCE_UNITS,
max_xml_tokens? : Int = DEFAULT_MAX_XML_TOKENS,
max_xml_materialized_chars? : Int = DEFAULT_MAX_XML_MATERIALIZED_CHARS,
max_xml_token_chars? : Int = DEFAULT_MAX_XML_TOKEN_CHARS,
cancelled? : () -> Bool = () => false,
) -> (Array[String], Bool) {
let report = validate_docx_archive_report_limited(
archive,
max_findings~,
max_message_chars~,
max_zip_entry_name_chars~,
max_zip_total_entry_name_chars~,
max_xml_source_units~,
max_xml_tokens~,
max_xml_materialized_chars~,
max_xml_token_chars~,
cancelled~,
)
(report.findings, report.findings_truncated)
}
///|
/// Archive-backed validation with typed resource-exhaustion status preserved
/// independently of bounded human findings.
pub fn validate_docx_archive_report_limited(
archive : @mbtzip.Archive,
max_findings~ : Int,
max_message_chars~ : Int,
max_zip_entry_name_chars? : Int = DEFAULT_MAX_ZIP_ENTRY_NAME_CHARS,
max_zip_total_entry_name_chars? : Int = DEFAULT_MAX_ZIP_TOTAL_ENTRY_NAME_CHARS,
max_xml_source_units? : Int = DEFAULT_MAX_XML_SOURCE_UNITS,
max_xml_tokens? : Int = DEFAULT_MAX_XML_TOKENS,
max_xml_materialized_chars? : Int = DEFAULT_MAX_XML_MATERIALIZED_CHARS,
max_xml_token_chars? : Int = DEFAULT_MAX_XML_TOKEN_CHARS,
cancelled? : () -> Bool = () => false,
) -> DocxValidationReport {
let findings = FindingCollector::new_with_xml_limits(
max_findings, max_message_chars, max_zip_entry_name_chars, max_zip_total_entry_name_chars,
max_xml_source_units, max_xml_tokens, max_xml_materialized_chars, max_xml_token_chars,
cancelled,
)
validate_docx_archive_into(archive, findings)
findings.report()
}
///|
fn validate_docx_archive_into(
archive : @mbtzip.Archive,
findings : FindingCollector,
) -> Unit {
// Raw entry sweep: names + CRC integrity, before any part-level logic.
let parts : StableStringMap[BytesView] = SortedMap([])
let canonical_names : StableStringMap[String] = SortedMap([])
let logical_names : StableStringMap[String] = SortedMap([])
let part_names = PartNameRegistry::new()
let seen : StableStringSet = SortedSet([])
for entry in archive.entries() {
if findings.full() {
findings.stop()
return
}
let name = entry.name()
if !findings.consume_zip_entry_name(name) {
return
}
if entry.central_directory_file_header_size() >
MAX_OPC_CENTRAL_DIRECTORY_FILE_HEADER_BYTES {
findings.add(
"central-directory file header exceeds the OPC \{MAX_OPC_CENTRAL_DIRECTORY_FILE_HEADER_BYTES}-byte limit: \{findings.text(name)}",
)
}
if seen.contains(name) {
findings.add("duplicate entry name: \{findings.text(name)}")
} else {
seen.add(name)
}
let logical_name = check_entry_name(name, findings)
let actual_crc = @mbtzip.crc32_cancellable(
entry.data(),
cancelled=findings.cancelled,
) catch {
ReadCancelled => {
findings.stop()
return
}
_ => {
findings.add("entry CRC-32 verification failed: \{findings.text(name)}")
findings.stop()
return
}
}
if entry.crc32() != actual_crc {
findings.add(
"entry data does not match its stored CRC-32: \{findings.text(name)}",
)
}
if !name.has_suffix("/") {
match logical_name {
Some(logical) => {
let key = part_name_key(logical)
if logical != CONTENT_TYPES_PART {
match part_names.register(logical, name) {
Some(Equivalent(existing)) if existing != name =>
findings.add(
"duplicate entry name: \{findings.text(name)} (logically equivalent to \{findings.text(existing)})",
)
Some(Derivable(existing)) =>
findings.add(
"part name is derivable from another part name: \{findings.text(existing)} and \{findings.text(name)}",
)
_ => ()
}
}
match canonical_names.get(key) {
Some(_) => ()
None => canonical_names[key] = name
}
logical_names[name] = logical
parts[name] = entry.data()
}
None => ()
}
}
}
let content_types = check_content_types(parts, canonical_names, findings)
check_relationships(
parts, canonical_names, logical_names, content_types, findings,
)
match content_types {
Some(types) =>
check_content_type_coverage(parts, logical_names, types, findings)
None => ()
}
}
///|
fn actual_part_name(
canonical_names : StableStringMap[String],
requested : String,
) -> String? {
canonical_names.get(part_name_key(requested))
}
///|
fn find_part(
parts : StableStringMap[BytesView],
canonical_names : StableStringMap[String],
requested : String,
) -> (String, BytesView)? {
guard actual_part_name(canonical_names, requested) is Some(actual) else {
return None
}
match parts.get(actual) {
Some(bytes) => Some((actual, bytes))
None => None
}
}
///|
fn contains_part(
canonical_names : StableStringMap[String],
requested : String,
) -> Bool {
actual_part_name(canonical_names, requested) is Some(_)
}
///|
fn check_entry_name(name : String, findings : FindingCollector) -> String? {
if name == "" {
findings.add("empty entry name")
return None
}
if name.has_prefix("/") {
findings.add("entry name must not start with '/': \{findings.text(name)}")
}
if name.find("\\") is Some(_) {
findings.add("entry name contains a backslash: \{findings.text(name)}")
return None
}
// The package content-types stream is reserved package metadata rather than
// an OPC part name, so its bracketed spelling is the one deliberate Pack-URI
// exception.
if name == CONTENT_TYPES_PART {
return Some(CONTENT_TYPES_PART)
}
if part_name_key(name) == part_name_key(CONTENT_TYPES_PART) {
findings.add(
"reserved content-types entry must use exact spelling '[Content_Types].xml': \{findings.text(name)}",
)
return None
}
// Explicit ZIP directory records are not OPC parts, but they are valid and
// preservation-safe package metadata. Validate their path without treating
// the terminal directory slash as an empty interior segment.
let item_path = if name.has_suffix("/") && name.length() > 1 {
name[:name.length() - 1]
} else {
name
}
match logical_part_name_from_zip_item_name(item_path) {
Some(logical) => if name.has_suffix("/") { None } else { Some(logical) }
None => {
for segment in item_path.split("/") {
match segment {
"" => {
findings.add(
"entry name contains an empty segment: \{findings.text(name)}",
)
return None
}
"." | ".." => {
findings.add(
"entry name contains a '\{segment}' segment: \{findings.text(name)}",
)
return None
}
_ => ()
}
}
findings.add(
"entry name is not a valid OPC URI path: \{findings.text(name)}",
)
None
}
}
}
///|
priv struct ContentTypes {
defaults : StableStringMap[String]
overrides : StableStringMap[String]
}
///|
fn is_media_type_token_char(character : Char) -> Bool {
character.is_ascii_alphabetic() ||
character.is_ascii_digit() ||
character
is ('!'
| '#'
| '$'
| '%'
| '&'
| '*'
| '+'
| '-'
| '.'
| '^'
| '_'
| '`'
| '|'
| '~') ||
character.to_int() == 0x27
}
///|
fn is_media_type_quoted_char(character : Char) -> Bool {
let code = character.to_int()
character == '\t' ||
character == ' ' ||
code == 0x21 ||
(code >= 0x23 && code <= 0x5b) ||
(code >= 0x5d && code <= 0x7e) ||
(code >= 0x80 && code <= 0xff)
}
///|
fn is_media_type_quoted_pair_char(character : Char) -> Bool {
let code = character.to_int()
character == '\t' ||
character == ' ' ||
(code >= 0x21 && code <= 0x7e) ||
(code >= 0x80 && code <= 0xff)
}
///|
/// RFC 7230 token and quoted-string rules, composed as the RFC 7231 media-type
/// grammar required by OPC. Optional whitespace surrounds semicolons only;
/// parameter names, `=`, and values remain contiguous.
fn is_valid_media_type(value : String) -> Bool {
let characters = value.to_array()
let mut index = 0
while index < characters.length() &&
is_media_type_token_char(characters[index]) {
index += 1
}
if index == 0 || index >= characters.length() || characters[index] != '/' {
return false
}
index += 1
let subtype_start = index
while index < characters.length() &&
is_media_type_token_char(characters[index]) {
index += 1
}
if index == subtype_start {
return false
}
while index < characters.length() {
while index < characters.length() &&
(characters[index] == ' ' || characters[index] == '\t') {
index += 1
}
if index >= characters.length() || characters[index] != ';' {
return false
}
index += 1
while index < characters.length() &&
(characters[index] == ' ' || characters[index] == '\t') {
index += 1
}
let parameter_start = index
while index < characters.length() &&
is_media_type_token_char(characters[index]) {
index += 1
}
if index == parameter_start ||
index >= characters.length() ||
characters[index] != '=' {
return false
}
index += 1
if index >= characters.length() {
return false
}
if characters[index] == '"' {
index += 1
let mut closed = false
while index < characters.length() {
let character = characters[index]
if character == '"' {
closed = true
index += 1
break
}
if character == '\\' {
index += 1
if index >= characters.length() ||
!is_media_type_quoted_pair_char(characters[index]) {
return false
}
} else if !is_media_type_quoted_char(character) {
return false
}
index += 1
}
if !closed {
return false
}
} else {
let value_start = index
while index < characters.length() &&
is_media_type_token_char(characters[index]) {
index += 1
}
if index == value_start {
return false
}
}
}
true
}
///|
/// ECMA-376 Part 2 `ST_Extension`. A percent sign is legal only as a complete
/// percent-encoded byte; dots and non-ASCII characters are not extension data.
pub fn is_valid_content_type_extension(value : String) -> Bool {
if value == "" {
return false
}
let characters = value.to_array()
let mut index = 0
while index < characters.length() {
let character = characters[index]
if character.is_ascii_alphabetic() ||
character.is_ascii_digit() ||
character
is ('!'
| '$'
| '&'
| '('
| ')'
| '*'
| '+'
| ','
| ':'
| '='
| '@'
| '-'
| '_'
| '~') ||
character.to_int() == 0x27 {
index += 1
} else if character == '%' &&
index + 2 < characters.length() &&
opc_ascii_hex_digit(characters[index + 1]) &&
opc_ascii_hex_digit(characters[index + 2]) {
index += 3
} else {
return false
}
}
true
}
///|
fn unexpected_xml_attribute(
element : @xml.XmlElement,
allowed : ArrayView[String],
) -> String? {
for name, _ in element.attributes {
if !allowed.any(expected => expected == name) {
return Some(name)
}
}
None
}
///|
fn check_content_types(
parts : StableStringMap[BytesView],
canonical_names : StableStringMap[String],
findings : FindingCollector,
) -> ContentTypes? {
guard find_part(parts, canonical_names, CONTENT_TYPES_PART)
is Some((content_types_name, bytes)) else {
findings.add("missing required part: \{CONTENT_TYPES_PART}")
return None
}
guard parse_xml_part(content_types_name, bytes, findings) is Some(root) else {
return None
}
if root.name != CONTENT_TYPES_ROOT {
findings.add(
"\{CONTENT_TYPES_PART}: root element is not in the OPC content-types namespace",
)
return None
}
let mut valid = true
match unexpected_xml_attribute(root, []) {
Some(attribute) => {
findings.add(
"\{CONTENT_TYPES_PART}: Types has undeclared attribute: \{findings.text(attribute)}",
)
valid = false
}
None => ()
}
let defaults : StableStringMap[String] = SortedMap([])
let overrides : StableStringMap[String] = SortedMap([])
let seen_defaults : StableStringSet = SortedSet([])
let seen_overrides : StableStringSet = SortedSet([])
for node in root.children {
if findings.full() {
findings.stop()
valid = false
break
}
match node {
XmlText(text) =>
if text.trim() != "" {
findings.add("\{CONTENT_TYPES_PART}: Types contains unexpected text")
valid = false
}
XmlElement(element) =>
match element.name {
CONTENT_TYPES_DEFAULT => {
let mut element_valid = true
match
unexpected_xml_attribute(element, ["Extension", "ContentType"]) {
Some(attribute) => {
findings.add(
"\{CONTENT_TYPES_PART}: Default has undeclared attribute: \{findings.text(attribute)}",
)
element_valid = false
valid = false
}
None => ()
}
if !element.children.is_empty() {
findings.add(
"\{CONTENT_TYPES_PART}: Default element is not empty",
)
element_valid = false
valid = false
}
match
(
element.attributes.get("Extension"),
element.attributes.get("ContentType"),
) {
(Some(extension), Some(content_type)) => {
let extension_valid = is_valid_content_type_extension(extension)
if !extension_valid {
findings.add(
"\{CONTENT_TYPES_PART}: Default has invalid Extension: \{findings.text(extension)}",
)
valid = false
}
let content_type_valid = is_valid_media_type(content_type)
if !content_type_valid {
findings.add(
"\{CONTENT_TYPES_PART}: Default has invalid ContentType: \{findings.text(content_type)}",
)
valid = false
}
if extension_valid {
let key = extension.to_lower()
let duplicate = seen_defaults.contains(key)
if duplicate {
findings.add(
"\{CONTENT_TYPES_PART}: duplicate Default Extension after case normalization: \{findings.text(key)}",
)
valid = false
} else {
seen_defaults.add(key)
if element_valid && content_type_valid {
defaults[key] = content_type
}
}
}
}
_ => {
findings.add(
"\{CONTENT_TYPES_PART}: Default missing Extension or ContentType",
)
valid = false
}
}
}
CONTENT_TYPES_OVERRIDE => {
let mut element_valid = true
match
unexpected_xml_attribute(element, ["PartName", "ContentType"]) {
Some(attribute) => {
findings.add(
"\{CONTENT_TYPES_PART}: Override has undeclared attribute: \{findings.text(attribute)}",
)
element_valid = false
valid = false
}
None => ()
}
if !element.children.is_empty() {
findings.add(
"\{CONTENT_TYPES_PART}: Override element is not empty",
)
element_valid = false
valid = false
}
match
(
element.attributes.get("PartName"),
element.attributes.get("ContentType"),
) {
(Some(part_name), Some(content_type)) => {
let content_type_valid = is_valid_media_type(content_type)
if !content_type_valid {
findings.add(
"\{CONTENT_TYPES_PART}: Override has invalid ContentType: \{findings.text(content_type)}",
)
valid = false
}
if part_name.has_prefix("/") {
match normalize_override_part_name(part_name) {
Some(key) => {
let collision_key = part_name_key(key)
let duplicate = seen_overrides.contains(collision_key)
if duplicate {
findings.add(
"\{CONTENT_TYPES_PART}: duplicate Override PartName after path normalization: \{findings.text(key)}",
)
valid = false
} else {
seen_overrides.add(collision_key)
}
if key != part_name {
findings.add(
"\{CONTENT_TYPES_PART}: Override PartName is not canonical: \{findings.text(part_name)}",
)
element_valid = false
valid = false
}
let target = key[1:].to_owned()
if part_name_key(target) ==
part_name_key(CONTENT_TYPES_PART) {
findings.add(
"\{CONTENT_TYPES_PART}: Override names the reserved content-types stream",
)
element_valid = false
valid = false
} else if !contains_part(canonical_names, target) {
findings.add(
"\{CONTENT_TYPES_PART}: Override names a missing part: \{findings.text(key)}",
)
element_valid = false
valid = false
}
if element_valid && content_type_valid && !duplicate {
overrides[collision_key] = content_type
}
}
None => {
findings.add(
"\{CONTENT_TYPES_PART}: Override PartName is invalid: \{findings.text(part_name)}",
)
valid = false
}
}
} else {
findings.add(
"\{CONTENT_TYPES_PART}: Override PartName must start with '/': \{findings.text(part_name)}",
)
valid = false
}
}
_ => {
findings.add(
"\{CONTENT_TYPES_PART}: Override missing PartName or ContentType",
)
valid = false
}
}
}
_ => {
let child_name = local_name(element.name)
if child_name == "Default" || child_name == "Override" {
findings.add(
"\{CONTENT_TYPES_PART}: \{findings.text(child_name)} element is outside the OPC content-types namespace",
)
} else {
findings.add(
"\{CONTENT_TYPES_PART}: Types contains unexpected element: \{findings.text(element.name)}",
)
}
valid = false
}
}
}
}
if valid {
Some({ defaults, overrides })
} else {
None
}
}
///|
fn normalize_override_part_name(part_name : String) -> String? {
// Callers establish the leading slash. `resolve_target` removes redundant
// dot segments, giving declarations one collision key while the separate
// canonicality finding rejects noncanonical spellings.
match resolve_part_target("", part_name) {
Some(target) => Some("/" + target)
None => None
}
}
///|
fn check_content_type_coverage(
parts : StableStringMap[BytesView],
logical_names : StableStringMap[String],
types : ContentTypes,
findings : FindingCollector,
) -> Unit {
for name, _ in parts {
if findings.full() {
findings.stop()
return
}
guard logical_names.get(name) is Some(logical_name) else { continue }
if part_name_key(logical_name) == part_name_key(CONTENT_TYPES_PART) {
continue
}
if types.overrides.contains(part_name_key("/" + logical_name)) {
continue
}
let extension = part_extension(logical_name)
if extension is Some(ext) && types.defaults.contains(ext) {
continue
}
findings.add("no content type for part: \{findings.text(name)}")
}
}
///|
fn part_extension(name : String) -> String? {
match name.rev_find(".") {
Some(dot) => {
// The extension must belong to the final path segment.
let after_slash = match name.rev_find("/") {
Some(slash) => dot > slash
None => true
}
if after_slash && dot + 1 < name.length() {
Some(name[dot + 1:].to_owned().to_lower())
} else {
None
}
}
None => None
}
}
///|
fn ContentTypes::for_part(self : ContentTypes, name : String) -> String? {
match self.overrides.get(part_name_key("/" + name)) {
Some(value) => Some(value)
None =>
match part_extension(name) {
Some(extension) => self.defaults.get(extension)
None => None
}
}
}
///|
priv enum DocumentDialect {
Transitional
Strict
}
///|
fn xml_ncname_start(character : Char) -> Bool {
let value = character.to_int()
character.is_ascii_alphabetic() ||
character == '_' ||
(value >= 0x00c0 && value <= 0x00d6) ||
(value >= 0x00d8 && value <= 0x00f6) ||
(value >= 0x00f8 && value <= 0x02ff) ||
(value >= 0x0370 && value <= 0x037d) ||
(value >= 0x037f && value <= 0x1fff) ||
(value >= 0x200c && value <= 0x200d) ||
(value >= 0x2070 && value <= 0x218f) ||
(value >= 0x2c00 && value <= 0x2fef) ||
(value >= 0x3001 && value <= 0xd7ff) ||
(value >= 0xf900 && value <= 0xfdcf) ||
(value >= 0xfdf0 && value <= 0xfffd) ||
(value >= 0x10000 && value <= 0xeffff)
}
///|
fn xml_ncname_character(character : Char) -> Bool {
let value = character.to_int()
xml_ncname_start(character) ||
character.is_ascii_digit() ||
character == '-' ||
character == '.' ||
value == 0x00b7 ||
(value >= 0x0300 && value <= 0x036f) ||
(value >= 0x203f && value <= 0x2040)
}
///|
fn is_valid_relationship_id(id : String) -> Bool {
let characters = id.to_array()
if characters.length() == 0 || !xml_ncname_start(characters[0]) {
return false
}
for index in 1.. DocumentDialect? {
match rel_type {
TRANSITIONAL_OFFICE_DOCUMENT_RELATIONSHIP => Some(Transitional)
STRICT_OFFICE_DOCUMENT_RELATIONSHIP => Some(Strict)
_ => None
}
}
///|
fn check_relationships(
parts : StableStringMap[BytesView],
canonical_names : StableStringMap[String],
logical_names : StableStringMap[String],
content_types : ContentTypes?,
findings : FindingCollector,
) -> Unit {
if findings.full() {
findings.stop()
return
}
// Root rels: required, every internal target must resolve, and exactly one
// internal officeDocument relationship must select the main part.
match find_part(parts, canonical_names, ROOT_RELS_PART) {
Some((root_rels_name, bytes)) => {
check_relationship_content_type(
root_rels_name,
ROOT_RELS_PART,
content_types,
findings,
)
match parse_relationships_part(root_rels_name, bytes, findings) {
Some(root) => {
let main_parts : Array[(String, DocumentDialect)] = []
if !for_each_relationship(ROOT_RELS_PART, root, findings, (
_id,
rel_type,
target,
external,
) => {
let dialect = office_document_dialect(rel_type)
if external {
match dialect {
Some(_) =>
findings.add(
"\{ROOT_RELS_PART}: officeDocument relationship is not internal",
)
None => ()
}
return
}
match resolve_part_target("", target) {
Some(resolved) =>
if is_relationship_part_name(resolved) {
findings.add(
"relationship target must not be a Relationships part: \{ROOT_RELS_PART} -> \{findings.text(target)}",
)
} else {
if !contains_part(canonical_names, resolved) {
findings.add(
"relationship target missing: \{ROOT_RELS_PART} -> \{findings.text(target)} (resolved \{findings.text(resolved)})",
)
}
match dialect {
Some(value) => main_parts.push((resolved, value))
None => ()
}
}
None => {
findings.add(
"relationship target invalid: \{ROOT_RELS_PART} -> \{findings.text(target)}",
)
match dialect {
Some(_) =>
findings.add(
"\{ROOT_RELS_PART}: officeDocument relationship has an invalid target: \{findings.text(target)}",
)
None => ()
}
}
}
}) {
return
}
match main_parts {
[(part, dialect)] =>
match find_part(parts, canonical_names, part) {
Some((actual, bytes)) =>
if check_main_part(
actual, part, bytes, dialect, content_types, findings,
) {
findings.main_document_part = Some(actual)
}
None =>
findings.add(
"officeDocument relationship targets a missing part: \{findings.text(part)}",
)
}
[] =>
findings.add("\{ROOT_RELS_PART}: no officeDocument relationship")
_ =>
findings.add(
"\{ROOT_RELS_PART}: multiple officeDocument relationships",
)
}
}
None => ()
}
}
None => findings.add("missing required part: \{ROOT_RELS_PART}")
}
// Every part's own rels, when present: parse + internal targets resolve.
for name, bytes in parts {
if findings.full() {
findings.stop()
return
}
guard logical_names.get(name) is Some(logical_name) else { continue }
let canonical_name = part_name_key(logical_name)
if canonical_name == ROOT_RELS_PART {
continue
}
if !is_relationship_part_name(logical_name) {
continue
}
guard relationship_source_part_name(logical_name) is Some(source) else {
findings.add("invalid Relationships part name: \{findings.text(name)}")
continue
}
if is_relationship_part_name(source) {
findings.add(
"relationship part \{findings.text(name)} must not describe another Relationships part: \{findings.text(source)}",
)
continue
}
if !contains_part(canonical_names, source) {
findings.add(
"relationship part \{findings.text(name)} describes missing source part: \{findings.text(source)}",
)
}
check_relationship_content_type(name, logical_name, content_types, findings)
match parse_relationships_part(name, bytes, findings) {
Some(root) => {
let base = rels_base_dir(logical_name)
if !for_each_relationship(name, root, findings, (
_id,
_rel_type,
target,
external,
) => {
if !external {
match resolve_part_target(base, target) {
Some(resolved) =>
if is_relationship_part_name(resolved) {
findings.add(
"relationship target must not be a Relationships part: \{findings.text(name)} -> \{findings.text(target)}",
)
} else if !contains_part(canonical_names, resolved) {
findings.add(
"relationship target missing: \{findings.text(name)} -> \{findings.text(target)} (resolved \{findings.text(resolved)})",
)
}
None =>
findings.add(
"relationship target invalid: \{findings.text(name)} -> \{findings.text(target)}",
)
}
}
}) {
return
}
}
None => ()
}
}
}
///|
fn check_relationship_content_type(
physical_name : String,
logical_name : String,
content_types : ContentTypes?,
findings : FindingCollector,
) -> Unit {
match content_types {
Some(types) =>
match types.for_part(logical_name) {
Some(value) if value.trim().to_lower() == RELATIONSHIPS_CONTENT_TYPE =>
()
Some(value) =>
findings.add(
"relationship part \{findings.text(physical_name)} has content type '\{findings.text(value)}', expected '\{RELATIONSHIPS_CONTENT_TYPE}'",
)
None =>
findings.add(
"relationship part \{findings.text(physical_name)} has no content type",
)
}
None => ()
}
}
///|
fn check_main_part(
physical_name : String,
logical_name : String,
bytes : BytesView,
dialect : DocumentDialect,
content_types : ContentTypes?,
findings : FindingCollector,
) -> Bool {
let mut valid = true
match content_types {
Some(types) =>
match types.for_part(logical_name) {
Some(value) if value.trim().to_lower() == DOCX_MAIN_CONTENT_TYPE => ()
Some(value) => {
findings.add(
"main part \{findings.text(physical_name)} has content type '\{findings.text(value)}', expected '\{DOCX_MAIN_CONTENT_TYPE}'",
)
valid = false
}
None => {
findings.add(
"main part \{findings.text(physical_name)} has no content type",
)
valid = false
}
}
None => valid = false
}
match parse_xml_part(physical_name, bytes, findings) {
Some(root) => {
let expected = match dialect {
Transitional => TRANSITIONAL_DOCUMENT_ROOT
Strict => STRICT_DOCUMENT_ROOT
}
if root.name != expected {
findings.add(
"main part \{findings.text(physical_name)} root element does not match the officeDocument relationship dialect",
)
valid = false
}
}
None => valid = false
}
valid
}
///|
fn for_each_relationship(
part_name : String,
root : @xml.XmlElement,
findings : FindingCollector,
visit : (String, String, String, Bool) -> Unit,
) -> Bool {
if !findings.checkpoint() {
return false
}
let relationships_name = "{\{PACKAGE_RELATIONSHIPS_NAMESPACE}}Relationships"
let relationship_name = "{\{PACKAGE_RELATIONSHIPS_NAMESPACE}}Relationship"
if root.name != relationships_name {
findings.add(
"\{findings.text(part_name)}: root element is not in the OPC package relationships namespace",
)
return true
}
let mut valid_part = true
for attribute, _ in root.attributes {
if !findings.checkpoint() {
return false
}
if findings.full() {
findings.stop()
return false
}
findings.add(
"\{findings.text(part_name)}: Relationships has undeclared attribute: \{findings.text(attribute)}",
)
valid_part = false
}
let ids : StableStringSet = SortedSet([])
let relationships : Array[(String, String, String, Bool)] = []
for node in root.children {
if !findings.checkpoint() {
return false
}
if findings.full() {
findings.stop()
return false
}
guard node is XmlElement(element) else {
guard node is XmlText(text) else { continue }
if text.trim() != "" {
findings.add(
"\{findings.text(part_name)}: Relationships contains unexpected text",
)
valid_part = false
}
continue
}
if element.name != relationship_name {
if local_name(element.name) == "Relationship" {
findings.add(
"\{findings.text(part_name)}: Relationship element is outside the OPC package relationships namespace",
)
} else {
findings.add(
"\{findings.text(part_name)}: Relationships contains unexpected element: \{findings.text(element.name)}",
)
}
valid_part = false
continue
}
let mut valid = true
for attribute, _ in element.attributes {
if !findings.checkpoint() {
return false
}
if attribute != "Id" &&
attribute != "Type" &&
attribute != "Target" &&
attribute != "TargetMode" {
findings.add(
"\{findings.text(part_name)}: Relationship has undeclared attribute: \{findings.text(attribute)}",
)
valid = false
}
}
for child in element.children {
if !findings.checkpoint() {
return false
}
match child {
XmlElement(_) => {
findings.add(
"\{findings.text(part_name)}: Relationship contains a child element",
)
valid = false
}
// CT_Relationship is simpleContent extending xsd:string. Preserve and
// accept its schema-valid character content; child elements remain
// forbidden by the complex type.
XmlText(_) => ()
}
}
if !findings.checkpoint() {
return false
}
let id = @xml.collapse_xml_schema_whitespace(
element.attributes.get("Id").unwrap_or(""),
)
if id == "" {
findings.add(
"\{findings.text(part_name)}: Relationship missing a non-empty Id",
)
valid = false
} else if !is_valid_relationship_id(id) {
findings.add(
"\{findings.text(part_name)}: Relationship Id is not an XML NCName: \{findings.text(id)}",
)
valid = false
} else if ids.contains(id) {
findings.add(
"\{findings.text(part_name)}: duplicate Relationship Id: \{findings.text(id)}",
)
valid = false
} else {
ids.add(id)
}
if !findings.checkpoint() {
return false
}
let rel_type = @xml.collapse_xml_schema_whitespace(
element.attributes.get("Type").unwrap_or(""),
)
if rel_type == "" {
findings.add(
"\{findings.text(part_name)}: Relationship missing a non-empty Type",
)
valid = false
} else if !is_absolute_relationship_type(rel_type) {
findings.add(
"\{findings.text(part_name)}: Relationship Type is not an absolute IRI without a fragment: \{findings.text(rel_type)}",
)
valid = false
}
if !findings.checkpoint() {
return false
}
let target = @xml.collapse_xml_schema_whitespace(
element.attributes.get("Target").unwrap_or(""),
)
if target == "" {
findings.add(
"\{findings.text(part_name)}: Relationship missing a non-empty Target",
)
valid = false
}
let external = match element.attributes.get("TargetMode") {
None | Some("Internal") => Some(false)
Some("External") => Some(true)
Some(mode) => {
findings.add(
"\{findings.text(part_name)}: Relationship has invalid TargetMode: \{findings.text(mode)}",
)
valid = false
None
}
}
match external {
Some(true) if target != "" &&
!is_valid_external_relationship_target(target) => {
findings.add(
"\{findings.text(part_name)}: external Relationship Target is not a valid IRI reference: \{findings.text(target)}",
)
valid = false
}
_ => ()
}
match external {
Some(value) =>
if valid {
relationships.push((id, rel_type, target, value))
} else {
valid_part = false
}
None => ()
}
if !valid {
valid_part = false
}
}
if valid_part {
for relationship in relationships {
if !findings.checkpoint() {
return false
}
let (id, rel_type, target, external) = relationship
visit(id, rel_type, target, external)
}
}
findings.checkpoint()
}
///|
fn rels_base_dir(rels_name : String) -> String {
match relationship_source_part_name(rels_name) {
Some(source) =>
match source.rev_find("/") {
Some(index) => source[:index].to_owned()
None => ""
}
None => ""
}
}
///|
/// Returns whether an internal relationship target starts with a URI scheme.
fn target_has_uri_scheme(target : String) -> Bool {
let mut index = 0
for character in target {
if character == ':' {
return index > 0
}
if character == '/' || character == '?' || character == '#' {
return false
}
let valid = if index == 0 {
character.is_ascii_alphabetic()
} else {
character.is_ascii_alphabetic() ||
character.is_ascii_digit() ||
character == '+' ||
character == '-' ||
character == '.'
}
if !valid {
return false
}
index += 1
}
false
}
///|
/// RFC 3986 `path-noscheme` forbids a colon in the first relative segment.
/// Prefixing that segment with `./` is the unambiguous spelling and remains
/// valid (`./1:document.xml`).
fn target_has_ambiguous_first_segment_colon(target : String) -> Bool {
if target.has_prefix("/") {
return false
}
for character in target {
if character == '/' {
return false
}
if character == ':' {
return true
}
}
false
}
///|
/// Resolves one internal relationship target against an archive-directory
/// base. Package-rooted targets (`/word/media/x.png`) and relative targets use
/// the same dot-segment normalization. Empty segments, URI schemes,
/// query/fragment text, backslashes, empty results, and any traversal above
/// the package root fail closed instead of being silently clamped.
pub fn resolve_target(base : String, target : String) -> String? {
if target == "" ||
target.has_prefix("//") ||
target.contains("\\") ||
target.contains("?") ||
target.contains("#") ||
target_has_ambiguous_first_segment_colon(target) ||
target_has_uri_scheme(target) {
return None
}
let segments : Array[String] = []
if !target.has_prefix("/") && base != "" {
if base.has_prefix("/") {
return None
}
let normalized_base = if base.has_suffix("/") {
base[:base.length() - 1]
} else {
base
}
if normalized_base != "" {
for segment in normalized_base.split("/") {
if segment == "" || segment == "." || segment == ".." {
return None
}
segments.push(segment.to_owned())
}
}
}
let path = if target.has_prefix("/") { target[1:] } else { target }
for segment in path.split("/") {
match segment {
"" => return None
"." => ()
".." =>
if segments.length() > 0 {
let _ = segments.pop()
} else {
return None
}
other => segments.push(other.to_owned())
}
}
if segments.length() == 0 {
None
} else {
Some(segments.join("/"))
}
}
///|
/// The parser emits `prefix:Local` for mapped namespaces and Clark notation
/// `{uri}Local` for unmapped ones. Generic structural checks use local names;
/// security-sensitive relationship parsing above requires the exact expanded
/// package-relationships name.
fn local_name(name : String) -> String {
let after_ns = match name.rev_find("}") {
Some(index) => name[index + 1:].to_owned()
None => name
}
match after_ns.rev_find(":") {
Some(index) => after_ns[index + 1:].to_owned()
None => after_ns
}
}
///|
fn parse_xml_part(
name : String,
bytes : BytesView,
findings : FindingCollector,
) -> @xml.XmlElement? {
let root = @xml.read_xml_bytes_strict_limited(bytes, findings.xml_budget) catch {
InvalidXml(message="XML source is not valid UTF-8") => {
findings.add("part is not valid UTF-8: \{findings.text(name)}")
return None
}
err => {
findings.mark_resource_limit(err)
findings.add(
"malformed XML in \{findings.text(name)}: \{findings.text(err.description())}",
)
return None
}
}
Some(root)
}
///|
/// Relationships parts have a distinct OPC contract: before their schema is
/// evaluated, ECMA-376 Part 3 processing runs with an empty markup
/// configuration and only the package Relationships namespace understood.
fn parse_relationships_part(
name : String,
bytes : BytesView,
findings : FindingCollector,
) -> @xml.XmlElement? {
let root = read_opc_relationships_xml_limited(bytes, findings.xml_budget) catch {
InvalidXml(message="XML source is not valid UTF-8") => {
findings.add("part is not valid UTF-8: \{findings.text(name)}")
return None
}
err => {
findings.mark_resource_limit(err)
findings.add(
"malformed XML in \{findings.text(name)}: \{findings.text(err.description())}",
)
return None
}
}
Some(root)
}
///|
/// Strictly parses one OPC Relationships part and applies Markup
/// Compatibility with only the package Relationships namespace understood.
/// Schema validation remains the caller's responsibility.
pub fn read_opc_relationships_xml_limited(
source : BytesView,
budget : @xml.XmlReadBudget,
) -> @xml.XmlElement raise @core.DocxError {
@xml.read_xml_bytes_strict_mce_limited(source, budget, [
PACKAGE_RELATIONSHIPS_NAMESPACE,
])
}
///|
/// Parses one OPC Relationships part and returns both its raw XML tree and its
/// MCE-effective tree from one bounded parse. Byte-preserving mutation uses the
/// raw projection to reject compatibility-dependent edits before splicing.
pub fn read_opc_relationships_xml_with_roots_limited(
source : BytesView,
budget : @xml.XmlReadBudget,
) -> (@xml.XmlElement, @xml.XmlElement) raise @core.DocxError {
@xml.read_xml_bytes_strict_mce_with_roots_limited(source, budget, [
PACKAGE_RELATIONSHIPS_NAMESPACE,
])
}
///|
/// Strictly parses one already-decoded OPC Relationships part, verifies its
/// XML encoding declaration, and applies Markup Compatibility with only the
/// package Relationships namespace understood. Schema validation remains the
/// caller's responsibility.
pub fn read_opc_relationships_string_encoded_limited(
source : String,
source_encoding : @xml.XmlSourceEncoding,
budget : @xml.XmlReadBudget,
) -> @xml.XmlElement raise @core.DocxError {
@xml.read_xml_string_strict_mce_encoded_limited(
source,
source_encoding,
budget,
[PACKAGE_RELATIONSHIPS_NAMESPACE],
)
}