From ea85308f0010bd2f4b37e46157a35cf83e1b1e45 Mon Sep 17 00:00:00 2001 From: Sebastian Braun Date: Wed, 9 Sep 2026 10:59:24 +0200 Subject: [PATCH] fix(converter): cap doc_name length to avoid Windows path-length errors --- openkb/converter.py | 15 ++++++++++++++- tests/test_converter.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/openkb/converter.py b/openkb/converter.py index 08929e015..acb26623d 100644 --- a/openkb/converter.py +++ b/openkb/converter.py @@ -48,11 +48,24 @@ def _registry_path(path: Path, kb_dir: Path) -> str: _SAFE_STEM_RE = re.compile(r"[^\w\-]+") _SUFFIX_LEN = 8 +_MAX_STEM_LEN = 40 def _sanitize_stem(stem: str) -> str: + """Sanitize and cap ``stem``, appending a hash suffix if truncated. + + ``doc_name`` (this function's return value) can appear twice in the + per-add staging path (staging dir name + images subdir), so an + unbounded stem risks Windows' ~260-char path limit. The suffix is a + hash of the FULL cleaned stem (not just the truncated prefix) so two + different overlong stems sharing the same prefix don't collide. + """ normalized = unicodedata.normalize("NFKC", stem) - return _SAFE_STEM_RE.sub("-", normalized).strip("-") or "document" + cleaned = _SAFE_STEM_RE.sub("-", normalized).strip("-") or "document" + if len(cleaned) > _MAX_STEM_LEN: + digest = hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:_SUFFIX_LEN] + cleaned = f"{cleaned[:_MAX_STEM_LEN].rstrip('-')}-{digest}" + return cleaned def _name_taken(candidate: str, registry: HashRegistry) -> bool: diff --git a/tests/test_converter.py b/tests/test_converter.py index 64adf1b53..949add401 100644 --- a/tests/test_converter.py +++ b/tests/test_converter.py @@ -217,6 +217,40 @@ def test_outside_kb_is_absolute_posix(self, kb_dir, tmp_path_factory): assert result.startswith("/") +# --------------------------------------------------------------------------- +# _sanitize_stem +# --------------------------------------------------------------------------- + + +class TestSanitizeStem: + def test_short_stem_is_unchanged(self): + from openkb.converter import _sanitize_stem + + assert _sanitize_stem("report") == "report" + + def test_long_stem_is_capped_with_hash_suffix(self): + from openkb.converter import _MAX_STEM_LEN, _sanitize_stem + + stem = "bulkQuery_result_" + "a" * 60 + result = _sanitize_stem(stem) + assert len(result) == _MAX_STEM_LEN + 1 + 8 # prefix + "-" + 8-hex digest + assert result.startswith(stem[:_MAX_STEM_LEN]) + + def test_long_stem_truncation_is_deterministic(self): + from openkb.converter import _sanitize_stem + + stem = "x" * 100 + assert _sanitize_stem(stem) == _sanitize_stem(stem) + + def test_different_long_stems_with_same_prefix_do_not_collide(self): + from openkb.converter import _MAX_STEM_LEN, _sanitize_stem + + prefix = "a" * _MAX_STEM_LEN + first = _sanitize_stem(prefix + "-one") + second = _sanitize_stem(prefix + "-two") + assert first != second + + # --------------------------------------------------------------------------- # resolve_doc_name # ---------------------------------------------------------------------------