From d14dc49025886a00251a3f579f4e9d53ed55f0ba Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 22:17:04 +0900 Subject: [PATCH 1/9] fix: harden ontology site identifiers and output safety --- scripts/build_ontology_site.py | 20 ++++++++++++++------ scripts/ontology_site_contract.py | 8 ++++++++ scripts/publish_ontology_site.py | 10 +++++++++- tests/test_ontology_site.py | 28 +++++++++++++++++++++++++--- tests/test_publish_ontology_site.py | 3 +++ 5 files changed, 59 insertions(+), 10 deletions(-) create mode 100644 scripts/ontology_site_contract.py diff --git a/scripts/build_ontology_site.py b/scripts/build_ontology_site.py index 423aff2c6..16077c117 100644 --- a/scripts/build_ontology_site.py +++ b/scripts/build_ontology_site.py @@ -16,7 +16,11 @@ from collections.abc import Iterable from pathlib import Path from typing import Any -from urllib.parse import quote + +try: + from scripts.ontology_site_contract import public_fragment +except ModuleNotFoundError: # direct execution with ``scripts`` as sys.path[0] + from ontology_site_contract import public_fragment from rdflib import Graph, Literal, URIRef from rdflib.compare import to_canonical_graph @@ -113,7 +117,7 @@ def _write_serializations(graph: Graph, ontology_dir: Path) -> None: def _term_href(value: URIRef, ontology_subjects: set[URIRef]) -> str: """Return a local fragment for local terms and an absolute IRI otherwise.""" if value in ontology_subjects: - return f"#{quote(_fragment(value), safe='-._~')}" + return f"#{public_fragment(_fragment(value))}" return str(value) @@ -146,11 +150,12 @@ def _render_relation_rows( def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) -> str: """Render one fragment-addressable ontology term section.""" - fragment = _fragment(subject) + raw_fragment = _fragment(subject) + fragment = public_fragment(raw_fragment) label = ( _preferred_literal(graph, subject, RDFS.label) or _preferred_literal(graph, subject, SKOS.prefLabel) - or fragment + or raw_fragment ) comment = _preferred_literal(graph, subject, RDFS.comment) lookup_predicate = URIRef( @@ -171,7 +176,7 @@ def _render_term(graph: Graph, subject: URIRef, ontology_subjects: set[URIRef]) ) return ( f'
' - f'

# ' f"{html.escape(label)}

" f'

{html.escape(str(subject))}

' @@ -411,7 +416,10 @@ def build_site(repository_root: Path, output_dir: Path) -> None: raise FileNotFoundError(f"PROV-O support profile is missing: {prov_profile}") if output.exists(): - shutil.rmtree(output) + raise FileExistsError( + "refusing to replace an existing output directory; " + "use publish_ontology_site for marked replacement" + ) ontology_dir = output / "ontology" ontology_dir.mkdir(parents=True) diff --git a/scripts/ontology_site_contract.py b/scripts/ontology_site_contract.py new file mode 100644 index 000000000..b82c4c5bc --- /dev/null +++ b/scripts/ontology_site_contract.py @@ -0,0 +1,8 @@ +"""Shared contracts for safe public ontology-site identifiers.""" + +from urllib.parse import quote + + +def public_fragment(fragment: str) -> str: + """Encode one local fragment identically for HTML IDs and hrefs.""" + return quote(fragment, safe="-._~") diff --git a/scripts/publish_ontology_site.py b/scripts/publish_ontology_site.py index fee47d3d3..bccdaa9f4 100644 --- a/scripts/publish_ontology_site.py +++ b/scripts/publish_ontology_site.py @@ -10,6 +10,7 @@ import argparse import importlib.util +import shutil from collections.abc import Iterable from pathlib import Path from types import ModuleType @@ -18,6 +19,11 @@ from rdflib import Graph, URIRef from rdflib.namespace import OWL, RDF, RDFS, SKOS +try: + from scripts.ontology_site_contract import public_fragment +except ModuleNotFoundError: # direct execution with ``scripts`` as sys.path[0] + from ontology_site_contract import public_fragment + OUTPUT_MARKER = ".lineageweave-ontology-site" SOURCE_RELATIVE_PATH = Path("docs/ontology/lineageweave-kg.ttl") PROV_PROFILE_RELATIVE_PATH = Path("docs/ontology/prov-o-support-profile.ttl") @@ -75,7 +81,7 @@ def validate_public_graph(graph: Graph) -> None: subjects = _public_subjects(graph) fragment_owner: dict[str, URIRef] = {} for subject in sorted(subjects, key=str): - fragment = _fragment(subject) + fragment = public_fragment(_fragment(subject)) owner = fragment_owner.setdefault(fragment, subject) if owner != subject: raise ValueError( @@ -122,6 +128,8 @@ def publish_site(repository_root: Path, output_dir: Path) -> None: validate_public_graph(graph) renderer = _load_renderer(root) + if output.exists(): + shutil.rmtree(output) renderer.build_site(root, output) (output / OUTPUT_MARKER).write_text("", encoding="utf-8") diff --git a/tests/test_ontology_site.py b/tests/test_ontology_site.py index e3638cbe5..662d28fc1 100644 --- a/tests/test_ontology_site.py +++ b/tests/test_ontology_site.py @@ -5,6 +5,7 @@ import hashlib import importlib.util import json +import shutil from pathlib import Path from rdflib import Graph @@ -95,10 +96,22 @@ def test_render_term_uses_skos_preferred_label_without_rdfs_label() -> None: rendered = builder._render_term(graph, term, {term}) - assert ">Human label" in rendered + assert "Human label" in rendered assert 'aria-label="Link to Human label"' in rendered +def test_render_term_uses_one_encoded_fragment_for_id_and_href() -> None: + builder = _load_builder() + graph = Graph() + term = builder.URIRef("https://example.test/ontology#Safety/한국어 term") + graph.add((term, builder.RDF.type, builder.OWL.Class)) + + rendered = builder._render_term(graph, term, {term}) + + assert 'id="Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"' in rendered + assert 'href="#Safety%2F%ED%95%9C%EA%B5%AD%EC%96%B4%20term"' in rendered + + def test_serializations_round_trip_to_the_source_graph(tmp_path: Path) -> None: builder = _load_builder() output = tmp_path / "site" @@ -173,7 +186,7 @@ def test_render_term_sections_keeps_one_anchor_for_multi_typed_terms() -> None: assert term_count == 1 -def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: Path) -> None: +def test_builder_fails_closed_for_missing_sources_and_rejects_existing_output(tmp_path: Path) -> None: builder = _load_builder() repository = tmp_path / "repository" output = tmp_path / "site" @@ -201,8 +214,17 @@ def test_builder_fails_closed_for_missing_sources_and_replaces_output(tmp_path: raise AssertionError("missing PROV-O profile was accepted") (ontology_dir / "prov-o-support-profile.ttl").write_text("", encoding="utf-8") + + try: + builder.build_site(repository, output) + except FileExistsError as exc: + assert "publish_ontology_site" in str(exc) + else: + raise AssertionError("direct builder replaced an existing output") + assert (output / "stale.txt").is_file() + shutil.rmtree(output) builder.build_site(repository, output) - assert not (output / "stale.txt").exists() + assert (output / "ontology" / "index.html").is_file() def test_cli_main_and_module_entrypoint(tmp_path: Path, monkeypatch) -> None: diff --git a/tests/test_publish_ontology_site.py b/tests/test_publish_ontology_site.py index bec404ac2..166ee3411 100644 --- a/tests/test_publish_ontology_site.py +++ b/tests/test_publish_ontology_site.py @@ -33,6 +33,9 @@ def _repository_fixture(tmp_path: Path) -> Path: (scripts_dir / "build_ontology_site.py").write_bytes( (ROOT / "scripts" / "build_ontology_site.py").read_bytes() ) + (scripts_dir / "ontology_site_contract.py").write_bytes( + (ROOT / "scripts" / "ontology_site_contract.py").read_bytes() + ) return repository From aab1e60c2c2ec4b57ca34585e5539d5ce1a7f9dd Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Fri, 21 Aug 2026 23:24:24 +0900 Subject: [PATCH 2/9] fix: keep unauthenticated login build type-safe --- frontend/src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6fba0dd41..666888a4d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -101,7 +101,6 @@ import { tf, useLocale, } from "./i18n"; -import { rememberOidcReturnUrl, returnUrlFromLocation } from "./oidcReturnUrl"; import "./App.css"; function orchestratorUnavailableMessage(err: unknown, action: string): string { @@ -4620,7 +4619,6 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean Enterprise SSO Authentication - {destination === "admin" ? : null}