From 551b94c524f4c5876828dac0fda6f9dd0437ab46 Mon Sep 17 00:00:00 2001 From: Kevin Zhang Date: Tue, 25 Aug 2026 23:57:57 +0800 Subject: [PATCH] fix(export): escape Turtle/N-Triples string literals (closes #1098) (#1148) * fix(export): escape Turtle/N-Triples string literals (fixes #1098) Add RDFSerializer._escape_turtle_literal and apply it to the semantica:text literal in serialize_to_turtle and the N-Triples text triple. Backslash, double quote, newline, CR, and tab are escaped per the RDF 1.1 Turtle STRING_LITERAL_QUOTE grammar, so entity text containing quotes or control characters no longer emits invalid Turtle/N-Triples. N-Triples previously escaped only quotes and newlines; now it also handles backslashes and tabs via the shared escaper. * fix(export): escape OWL-Time timestamp literals in Turtle output Addresses Qodo finding on #1148: the OWL-Time branch of serialize_to_turtle interpolated from_val/until_val directly into quoted literals. Apply _escape_turtle_literal there too so timestamps containing quotes, backslashes, or control characters cannot produce invalid Turtle. * chore: remove stray local files (AGENTS.md, evals superpowers docs) from PR branch --------- --- semantica/export/rdf_exporter.py | 24 ++++- tests/export/test_rdf_literal_escaping.py | 107 ++++++++++++++++++++++ 2 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 tests/export/test_rdf_literal_escaping.py diff --git a/semantica/export/rdf_exporter.py b/semantica/export/rdf_exporter.py index dffd4951..68e28bd3 100644 --- a/semantica/export/rdf_exporter.py +++ b/semantica/export/rdf_exporter.py @@ -738,6 +738,22 @@ class RDFSerializer: # node to signal that valid_until is OPEN/unbounded. This keeps the # interval well-formed while remaining human- and machine-readable. + @staticmethod + def _escape_turtle_literal(value: str) -> str: + """Escape a string value for safe embedding in a Turtle string literal. + + Backslash must be escaped first, then the double quote and the + recognized control characters (newline, carriage return, tab), per the + RDF 1.1 Turtle grammar for STRING_LITERAL_QUOTE. + """ + return ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + def serialize_to_turtle(self, rdf_data: Dict[str, Any], **options) -> str: """ Serialize RDF to Turtle format. @@ -807,7 +823,7 @@ class RDFSerializer: clauses = [ f"a <{self._as_turtle_iri(entity_type, merged_namespaces)}>", - f'semantica:text "{text}"', + f'semantica:text "{self._escape_turtle_literal(text)}"', ] if confidence is None: self.logger.warning( @@ -999,7 +1015,7 @@ class RDFSerializer: lines.append(f" time:hasEnd <{end_id}> .") lines.append(f"<{end_id}> a time:Instant ;") lines.append( - f' time:inXSDDateTimeStamp "{until_val}"^^xsd:dateTimeStamp .' + f' time:inXSDDateTimeStamp "{self._escape_turtle_literal(until_val)}"^^xsd:dateTimeStamp .' ) else: lines[-1] = ( @@ -1008,7 +1024,7 @@ class RDFSerializer: lines.append(f"<{begin_id}> a time:Instant ;") lines.append( - f' time:inXSDDateTimeStamp "{from_val}"^^xsd:dateTimeStamp .' + f' time:inXSDDateTimeStamp "{self._escape_turtle_literal(from_val)}"^^xsd:dateTimeStamp .' ) lines.append("") @@ -1305,7 +1321,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}" .' ) diff --git a/tests/export/test_rdf_literal_escaping.py b/tests/export/test_rdf_literal_escaping.py new file mode 100644 index 00000000..8a3c2354 --- /dev/null +++ b/tests/export/test_rdf_literal_escaping.py @@ -0,0 +1,107 @@ +"""Regression tests for #1098: Turtle/N-Triples literal escaping.""" +import pytest + +from semantica.export.rdf_exporter import RDFExporter, RDFSerializer + + +@pytest.fixture +def serializer(): + return RDFSerializer() + + +class TestTurtleLiteralEscaping: + def test_quote_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": 'He said "hello"', "type": "ORG"}], + "relationships": [], + } + turtle = serializer.serialize_to_turtle(kg) + assert '"He said \\"hello\\""' in turtle + + def test_backslash_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": r"path\to\file", "type": "ORG"}], + "relationships": [], + } + turtle = serializer.serialize_to_turtle(kg) + assert r"path\\to\\file" in turtle + + def test_newline_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": "line1\nline2", "type": "ORG"}], + "relationships": [], + } + turtle = serializer.serialize_to_turtle(kg) + assert "line1\\nline2" in turtle + + def test_tab_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": "a\tb", "type": "ORG"}], + "relationships": [], + } + turtle = serializer.serialize_to_turtle(kg) + assert "a\\tb" in turtle + + def test_plain_text_unchanged(self, serializer): + kg = { + "entities": [{"id": "e1", "text": "Apple Inc.", "type": "ORG"}], + "relationships": [], + } + turtle = serializer.serialize_to_turtle(kg) + assert 'semantica:text "Apple Inc."' in turtle + + +class TestNTriplesLiteralEscaping: + def test_quote_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": 'He said "hello"', "type": "ORG"}], + "relationships": [], + } + ntriples = serializer.serialize_to_ntriples(kg) + assert '\\"hello\\"' in ntriples + + def test_backslash_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": r"path\to\file", "type": "ORG"}], + "relationships": [], + } + ntriples = serializer.serialize_to_ntriples(kg) + assert r"path\\to\\file" in ntriples + + def test_newline_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": "line1\nline2", "type": "ORG"}], + "relationships": [], + } + ntriples = serializer.serialize_to_ntriples(kg) + assert "line1\\nline2" in ntriples + + def test_tab_in_text_is_escaped(self, serializer): + kg = { + "entities": [{"id": "e1", "text": "a\tb", "type": "ORG"}], + "relationships": [], + } + ntriples = serializer.serialize_to_ntriples(kg) + assert "a\\tb" in ntriples + + +class TestOWLTimeLiteralEscaping: + """Timestamp literals in OWL-Time turtle output must also be escaped.""" + + def test_owl_time_timestamps_are_escaped(self): + exporter = RDFExporter() + kg = { + "entities": [], + "relationships": [ + { + "id": "r1", + "source_id": "a", + "target_id": "b", + "type": "works_for", + "valid_from": "2020-01-01T00:00:00Z", + "valid_until": None, + } + ], + } + turtle = exporter.export_to_rdf(kg, format="turtle", include_temporal=True) + assert 'time:inXSDDateTimeStamp "2020-01-01T00:00:00Z"' in turtle \ No newline at end of file