211 lines
6.5 KiB
Python
211 lines
6.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Shared, dependency-light OPC package relationship validation."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import posixpath
|
|
import re
|
|
from pathlib import Path
|
|
from urllib.parse import urlsplit
|
|
from xml.etree import ElementTree as ET
|
|
|
|
|
|
PACKAGE_REL_NS = (
|
|
"http://schemas.openxmlformats.org/package/2006/relationships"
|
|
)
|
|
_RELATIONSHIPS_TAG = f"{{{PACKAGE_REL_NS}}}Relationships"
|
|
_RELATIONSHIP_TAG = f"{{{PACKAGE_REL_NS}}}Relationship"
|
|
_OPC_UNRESERVED = frozenset(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
|
)
|
|
_ASCII_LOWER_TRANSLATION = str.maketrans(
|
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ",
|
|
"abcdefghijklmnopqrstuvwxyz",
|
|
)
|
|
|
|
|
|
def canonical_opc_part_path(path: str) -> str | None:
|
|
"""Return an OPC-equivalent package path key, or None when invalid."""
|
|
if (
|
|
not path
|
|
or "\\" in path
|
|
or "?" in path
|
|
or "#" in path
|
|
or path.endswith("/")
|
|
or "//" in path
|
|
or any(ord(char) <= 0x20 for char in path)
|
|
):
|
|
return None
|
|
output: list[str] = []
|
|
index = 0
|
|
while index < len(path):
|
|
char = path[index]
|
|
if char != "%":
|
|
output.append(char)
|
|
index += 1
|
|
continue
|
|
if (
|
|
index + 2 >= len(path)
|
|
or re.fullmatch(
|
|
r"[0-9A-Fa-f]{2}",
|
|
path[index + 1:index + 3],
|
|
)
|
|
is None
|
|
):
|
|
return None
|
|
value = int(path[index + 1:index + 3], 16)
|
|
decoded = chr(value)
|
|
if value in {0, ord("/"), ord("\\")}:
|
|
return None
|
|
output.append(
|
|
decoded
|
|
if decoded in _OPC_UNRESERVED
|
|
else f"%{value:02X}"
|
|
)
|
|
index += 3
|
|
|
|
decoded_path = "".join(output)
|
|
if decoded_path.rsplit("/", 1)[-1] in {".", ".."}:
|
|
return None
|
|
normalized = posixpath.normpath(decoded_path)
|
|
if (
|
|
not normalized
|
|
or normalized in {".", ".."}
|
|
or normalized.startswith("/")
|
|
or normalized.startswith("../")
|
|
):
|
|
return None
|
|
return normalized.translate(_ASCII_LOWER_TRANSLATION)
|
|
|
|
|
|
def _source_part_for_rels(rels_path: str) -> str | None:
|
|
filename = posixpath.basename(rels_path)
|
|
if filename == ".rels" or not filename.endswith(".rels"):
|
|
return None
|
|
source_dir = posixpath.dirname(posixpath.dirname(rels_path))
|
|
source_name = filename.removesuffix(".rels")
|
|
return (
|
|
posixpath.join(source_dir, source_name)
|
|
if source_dir
|
|
else source_name
|
|
)
|
|
|
|
|
|
def resolve_internal_opc_target(
|
|
rels_path: str,
|
|
target: str,
|
|
) -> str | None:
|
|
"""Resolve one valid internal OPC Target to its canonical package key."""
|
|
target_path_query = target.split("#", 1)[0]
|
|
if (
|
|
"\\" in target
|
|
or "?" in target_path_query
|
|
or any(ord(char) <= 0x20 for char in target)
|
|
):
|
|
return None
|
|
try:
|
|
parsed = urlsplit(target)
|
|
except ValueError:
|
|
return None
|
|
if parsed.scheme or parsed.netloc or parsed.query:
|
|
return None
|
|
|
|
source_part = _source_part_for_rels(rels_path)
|
|
if parsed.path.startswith("/"):
|
|
resolved = parsed.path[1:]
|
|
elif parsed.path:
|
|
base_dir = posixpath.dirname(source_part) if source_part else ""
|
|
resolved = (
|
|
posixpath.join(base_dir, parsed.path)
|
|
if base_dir
|
|
else parsed.path
|
|
)
|
|
elif source_part and "#" in target:
|
|
resolved = source_part
|
|
else:
|
|
return None
|
|
return canonical_opc_part_path(resolved)
|
|
|
|
|
|
def verify_internal_relationships(extract_dir: Path) -> list[str]:
|
|
"""Return invalid or dangling internal relationships in an OPC package."""
|
|
package_parts: set[str] = set()
|
|
for path in extract_dir.rglob("*"):
|
|
if not path.is_file():
|
|
continue
|
|
key = canonical_opc_part_path(
|
|
path.relative_to(extract_dir).as_posix()
|
|
)
|
|
if key is not None:
|
|
package_parts.add(key)
|
|
|
|
problems: list[str] = []
|
|
for rels_path in sorted(extract_dir.rglob("*.rels")):
|
|
rels_rel = rels_path.relative_to(extract_dir).as_posix()
|
|
try:
|
|
root = ET.parse(rels_path).getroot()
|
|
except ET.ParseError as exc:
|
|
problems.append(
|
|
f"{rels_rel} -> <invalid relationships XML: {exc}>"
|
|
)
|
|
continue
|
|
if root.tag != _RELATIONSHIPS_TAG:
|
|
problems.append(
|
|
f"{rels_rel} -> <invalid Relationships namespace>"
|
|
)
|
|
continue
|
|
|
|
seen_ids: set[str] = set()
|
|
for element in root:
|
|
if element.tag != _RELATIONSHIP_TAG:
|
|
problems.append(
|
|
f"{rels_rel} -> <invalid relationships child "
|
|
f"{element.tag!r}>"
|
|
)
|
|
continue
|
|
relationship_id = (element.attrib.get("Id") or "").strip()
|
|
relationship_type = (
|
|
element.attrib.get("Type") or ""
|
|
).strip()
|
|
target = (element.attrib.get("Target") or "").strip()
|
|
target_mode = (
|
|
element.attrib.get("TargetMode") or ""
|
|
).strip()
|
|
|
|
if not relationship_id:
|
|
problems.append(f"{rels_rel} -> <missing relationship Id>")
|
|
elif relationship_id in seen_ids:
|
|
problems.append(
|
|
f"{rels_rel} -> <duplicate relationship Id "
|
|
f"{relationship_id!r}>"
|
|
)
|
|
else:
|
|
seen_ids.add(relationship_id)
|
|
if not relationship_type:
|
|
problems.append(
|
|
f"{rels_rel} -> <missing relationship Type>"
|
|
)
|
|
if not target:
|
|
problems.append(f"{rels_rel} -> <missing Target>")
|
|
continue
|
|
if target_mode and target_mode.lower() not in {
|
|
"internal",
|
|
"external",
|
|
}:
|
|
problems.append(
|
|
f"{rels_rel} -> <invalid TargetMode "
|
|
f"{target_mode!r}>"
|
|
)
|
|
continue
|
|
if target_mode.lower() == "external":
|
|
continue
|
|
|
|
resolved = resolve_internal_opc_target(rels_rel, target)
|
|
if resolved is None:
|
|
problems.append(
|
|
f"{rels_rel} -> <invalid Target {target!r}>"
|
|
)
|
|
elif resolved not in package_parts:
|
|
problems.append(f"{rels_rel} -> {resolved}")
|
|
return problems
|