fix(export): escape RDF literals and use URI-aware id fallback

Address Qodo review on #1113:
- Escape entity text for Turtle, RDF/XML and N-Triples so names containing
  quotes, XML markup, backslashes or control chars cannot break out of the
  literal or inject RDF/XML (High/Security).
- Replace colon-only id split with URI-aware local-name extraction so an id
  like https://example.org/acme yields 'acme', not '//example.org/acme'
  (Medium/Correctness).
- Add regression tests: escaping (quotes/XML/backslash/CR/LF), parseability
  via rdflib, and exact id local-name assertions.
This commit is contained in:
江俊杰
2026-08-20 10:19:52 +08:00
committed by cxzg007
parent 9e2f349221
commit 1c27a0ae7e
2 changed files with 149 additions and 6 deletions
+67 -5
View File
@@ -277,6 +277,61 @@ class RDFSerializer:
self.logger.debug("RDF serializer initialized")
@staticmethod
def _escape_turtle_literal(value: str) -> str:
"""Escape a string for use inside a Turtle/N-Triples double-quoted literal.
Follows the RDF 1.1 Turtle grammar for STRING_LITERAL_QUOTE: backslash
must be escaped first, then the double quote and the recognized control
characters. This prevents literal breakout and injection.
"""
return (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\r", "\\r")
.replace("\n", "\\n")
.replace("\t", "\\t")
)
@staticmethod
def _escape_xml_text(value: str) -> str:
"""Escape a string for safe inclusion in RDF/XML character data."""
from xml.sax.saxutils import escape
# escape() handles &, <, > by default; add quotes for attribute safety.
return escape(value, {'"': "&quot;", "'": "&apos;"})
@staticmethod
def _local_name_from_id(identifier: str) -> str:
"""Derive a human-readable local name from an entity identifier.
Handles HTTP(S)/IRI identifiers (path segments and fragments, tolerating
trailing slashes) as well as compact/CURIE and URN-style identifiers.
"""
raw = str(identifier).strip()
if not raw:
return ""
# Prefer a fragment if present (e.g. http://ex.org/onto#acme -> acme).
if "#" in raw:
candidate = raw.rsplit("#", 1)[-1]
if candidate:
return candidate
# For IRIs/paths, take the last non-empty path segment.
if "/" in raw:
segment = raw.rstrip("/").rsplit("/", 1)[-1]
if segment:
return segment
# Fall back to the tail of a CURIE/URN (e.g. urn:x:acme, semantica:acme).
if ":" in raw:
candidate = raw.rsplit(":", 1)[-1]
if candidate:
return candidate
return raw
def convert_kg_to_rdf(self, knowledge_graph: Dict[str, Any]) -> Dict[str, Any]:
"""
Convert knowledge graph to RDF data structure.
@@ -316,8 +371,11 @@ class RDFSerializer:
if "name" in norm_entity:
norm_entity["label"] = norm_entity["name"]
elif "id" in norm_entity:
# Use ID part as label if no name/text
norm_entity["label"] = str(norm_entity["id"]).split(":")[-1]
# Derive a readable label from the identifier's local name
# (fragment/last path segment/CURIE tail). See #1097.
local_name = self._local_name_from_id(norm_entity["id"])
if local_name:
norm_entity["label"] = local_name
rdf_data["entities"].append(norm_entity)
@@ -398,7 +456,9 @@ class RDFSerializer:
confidence = entity.get("confidence", 1.0)
lines.append(f"<{entity_id}> a <{entity_type}> ;")
lines.append(f' semantica:text "{text}" ;')
lines.append(
f' semantica:text "{self._escape_turtle_literal(text)}" ;'
)
lines.append(f" semantica:confidence {confidence} .")
lines.append("")
@@ -532,7 +592,9 @@ class RDFSerializer:
# RDF/XML syntax: rdf:Description with rdf:about
lines.append(f' <rdf:Description rdf:about="{entity_id}">')
lines.append(f' <rdf:type rdf:resource="{entity_type}"/>')
lines.append(f" <semantica:text>{text}</semantica:text>")
lines.append(
f" <semantica:text>{self._escape_xml_text(text)}</semantica:text>"
)
lines.append(
f" <semantica:confidence>{confidence}</semantica:confidence>"
)
@@ -692,7 +754,7 @@ class RDFSerializer:
# Text property
text = entity.get("text") or entity.get("label", "")
if text:
safe_text = text.replace('"', '\\"').replace("\n", "\\n")
safe_text = self._escape_turtle_literal(text)
lines.append(
f'{subject} {expand_uri("semantica:text")} "{safe_text}" .'
)
+82 -1
View File
@@ -1,6 +1,7 @@
"""Tests for RDFExporter format alias resolution (issue #355)."""
import pytest
from semantica.export import RDFExporter
RDF_DATA = {
@@ -150,7 +151,7 @@ def test_existing_text_not_overwritten_by_name(exporter):
def test_id_fallback_label_when_no_name(exporter):
"""With neither 'name' nor 'text', the id tail is used as the label (#1097)."""
"""With neither 'name' nor 'text', the id local-name is used as label (#1097)."""
data = {
"entities": [
{"id": "https://example.org/acme", "type": "https://example.org/Org"}
@@ -159,3 +160,83 @@ def test_id_fallback_label_when_no_name(exporter):
}
result = exporter.export_to_rdf(data, format="turtle")
assert 'semantica:text ""' not in result
# The label must be the URI local name 'acme', not '//example.org/acme'.
assert 'semantica:text "acme"' in result
@pytest.mark.parametrize(
"identifier,expected",
[
("https://example.org/acme", "acme"),
("https://example.org/path/acme", "acme"),
("https://example.org/onto#acme", "acme"),
("https://example.org/acme/", "acme"),
("urn:example:acme", "acme"),
("semantica:acme", "acme"),
("acme", "acme"),
],
)
def test_id_fallback_local_name_extraction(exporter, identifier, expected):
"""The id fallback must extract a URI-aware local name, not a colon split (#1113)."""
data = {
"entities": [{"id": identifier, "type": "https://example.org/Org"}],
"relationships": [],
}
result = exporter.export_to_rdf(data, format="turtle")
assert f'semantica:text "{expected}"' in result
def _export(exporter, name, fmt):
data = {
"entities": [
{"id": "https://example.org/e1", "name": name, "type": "ORG"}
],
"relationships": [],
}
return exporter.export_to_rdf(data, format=fmt)
def test_turtle_escapes_quotes_and_control_chars(exporter):
"""Turtle literals must escape quotes/backslashes/newlines (#1113 security)."""
result = _export(exporter, 'Acme "Best" \\ Corp\nLine2\tTab\rCR', "turtle")
# The raw closing-quote breakout must not appear inside the literal.
assert '"Acme "Best"' not in result
assert '\\"Best\\"' in result
assert "\\\\ Corp" in result
assert "\\n" in result and "\\t" in result and "\\r" in result
# No unescaped newline leaked into the literal value.
assert "Line2" in result and "\nLine2" not in result.split("semantica:text")[1]
def test_ntriples_escapes_quotes_and_control_chars(exporter):
"""N-Triples literals must escape backslash first, then quotes/controls (#1113)."""
result = _export(exporter, 'Quote " Back \\ New\nTab\t', "ntriples")
assert '\\"' in result
assert "\\\\" in result
assert "\\n" in result and "\\t" in result
# Each triple must be a single physical line: the literal value carrying the
# escaped text must not have leaked a bare newline that splits it in two.
text_lines = [ln for ln in result.splitlines() if "Quote" in ln]
assert len(text_lines) == 1
assert text_lines[0].rstrip().endswith(" .")
def test_rdfxml_escapes_markup(exporter):
"""RDF/XML character data must escape &, <, > so names cannot inject markup (#1113)."""
result = _export(exporter, 'Acme <script>&"x"', "rdfxml")
assert "<script>" not in result
assert "&lt;script&gt;" in result
assert "&amp;" in result
# The document must still parse as well-formed XML.
import xml.dom.minidom
xml.dom.minidom.parseString(result)
def test_turtle_output_is_parseable_with_special_name(exporter):
"""A name full of metacharacters must still yield parseable Turtle (#1113)."""
rdflib = pytest.importorskip("rdflib")
result = _export(exporter, 'Tricky "quote" \\ and <angle> & amp', "turtle")
graph = rdflib.Graph()
# Should not raise a parser error.
graph.parse(data=result, format="turtle")