fix(vector): address PR review round 12 — bool guard + strict doc_id validation

- _group_int_doc_ids: use type(value) is not int instead of isinstance,
  since bool is an int subclass and would otherwise stringify to
  "True"/"False" and corrupt legacy payloads on backfill.
- Replace doc_id.isdigit() guards in 5 boundary sites
  (api/visualization, auth/viz_routes, search/context note/news_item/
  deck_card branches) with a shared is_valid_nextcloud_doc_id helper
  that rejects "0", leading zeros, and Unicode digit classes
  (superscripts, Arabic-Indic, Devanagari) which pass isdigit() but
  cannot be valid MySQL AUTO_INCREMENT IDs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-05-09 20:28:47 +02:00
co-authored by Claude Opus 4.7
parent f3ce46da0f
commit f9ad7dc52e
8 changed files with 133 additions and 24 deletions
+4 -2
View File
@@ -34,6 +34,7 @@ from nextcloud_mcp_server.search.context import (
get_chunk_bbox_and_page_from_qdrant,
get_chunk_with_context,
)
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client_basic_auth,
@@ -502,8 +503,9 @@ async def get_chunk_context(request: Request) -> JSONResponse:
# otherwise pass through to get_chunk_with_context and bottom out as a
# 404 from deep inside, not a clear 400. Nextcloud IDs are unsigned
# ints from MySQL auto_increment; doc_id stays a str downstream
# (Qdrant payload index is keyword-typed).
if not doc_id.isdigit():
# (Qdrant payload index is keyword-typed). is_valid_nextcloud_doc_id
# rejects "0", leading zeros, and Unicode digits that pass isdigit().
if not is_valid_nextcloud_doc_id(doc_id):
return JSONResponse(
{
"success": False,
+4 -2
View File
@@ -37,6 +37,7 @@ from nextcloud_mcp_server.search.context import (
get_chunk_bbox_and_page_from_qdrant,
get_chunk_with_context,
)
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.vector.oauth_sync import (
NotProvisionedError,
get_user_client_basic_auth,
@@ -564,8 +565,9 @@ async def chunk_context_endpoint(request: Request) -> JSONResponse:
# otherwise pass through to get_chunk_with_context and bottom out as a
# 404 from deep inside, not a clear 400. Nextcloud IDs are unsigned
# ints from MySQL auto_increment; doc_id stays a str downstream
# (Qdrant payload index is keyword-typed).
if not doc_id.isdigit():
# (Qdrant payload index is keyword-typed). is_valid_nextcloud_doc_id
# rejects "0", leading zeros, and Unicode digits that pass isdigit().
if not is_valid_nextcloud_doc_id(doc_id):
return JSONResponse(
{
"success": False,
+18 -14
View File
@@ -11,6 +11,7 @@ from qdrant_client.models import FieldCondition, Filter, MatchValue
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import get_settings
from nextcloud_mcp_server.utils.validation import is_valid_nextcloud_doc_id
from nextcloud_mcp_server.vector.html_processor import html_to_markdown
from nextcloud_mcp_server.vector.placeholder import get_placeholder_filter
from nextcloud_mcp_server.vector.qdrant_client import get_qdrant_client
@@ -577,10 +578,11 @@ async def _fetch_document_text(
"""
try:
if doc_type == "note":
# Note IDs are integers in the Nextcloud API; reject non-numeric
# doc_ids explicitly so a malformed payload surfaces in logs
# rather than getting silently swallowed by `except Exception`.
if not doc_id.isdigit():
# Note IDs are positive ASCII integers (MySQL AUTO_INCREMENT).
# is_valid_nextcloud_doc_id rejects "0", leading zeros, and Unicode
# digits that pass str.isdigit(); a malformed payload surfaces in
# logs rather than getting silently swallowed by `except Exception`.
if not is_valid_nextcloud_doc_id(doc_id):
logger.warning(
"Expected numeric note doc_id, got %r — skipping document fetch",
doc_id,
@@ -594,10 +596,11 @@ async def _fetch_document_text(
content = note.get("content", "")
return f"{title}\n\n{content}"
elif doc_type == "news_item":
# News item IDs are integers in the Nextcloud News API; reject
# non-numeric doc_ids explicitly so malformed payloads surface
# rather than getting swallowed by the broad except below.
if not doc_id.isdigit():
# News item IDs are positive ASCII integers (MySQL AUTO_INCREMENT).
# is_valid_nextcloud_doc_id rejects "0", leading zeros, and Unicode
# digits that pass str.isdigit(); malformed payloads surface in
# logs rather than getting swallowed by the broad except below.
if not is_valid_nextcloud_doc_id(doc_id):
logger.warning(
"Expected numeric news_item doc_id, got %r — skipping document fetch",
doc_id,
@@ -621,12 +624,13 @@ async def _fetch_document_text(
content_parts.append(body_markdown)
return "\n".join(content_parts)
elif doc_type == "deck_card":
# Deck card IDs are integers in the Nextcloud Deck API; reject
# non-numeric doc_ids explicitly so malformed payloads surface
# rather than getting swallowed by the broad except below. The
# numeric check covers both the metadata-fast-path (line ~600)
# and the iteration fallback (line ~635).
if not doc_id.isdigit():
# Deck card IDs are positive ASCII integers (MySQL AUTO_INCREMENT).
# is_valid_nextcloud_doc_id rejects "0", leading zeros, and Unicode
# digits that pass str.isdigit(); malformed payloads surface in
# logs rather than getting swallowed by the broad except below.
# The numeric check covers both the metadata-fast-path and the
# iteration fallback below.
if not is_valid_nextcloud_doc_id(doc_id):
logger.warning(
"Expected numeric deck_card doc_id, got %r — skipping document fetch",
doc_id,
+15
View File
@@ -0,0 +1,15 @@
"""Shared validators for primitive types crossing system boundaries."""
import re
# Nextcloud object IDs are unsigned ints from MySQL AUTO_INCREMENT, which
# starts at 1. Restrict to ASCII positive integers to exclude Unicode digit
# classes (e.g. superscripts, Arabic-Indic numerals) that pass str.isdigit()
# / str.isdecimal() but would never be valid Nextcloud IDs, and to reject "0"
# and leading zeros.
_NEXTCLOUD_DOC_ID_RE = re.compile(r"^[1-9][0-9]*$")
def is_valid_nextcloud_doc_id(value: str) -> bool:
"""True iff `value` is the str form of a positive ASCII integer (>= 1)."""
return bool(_NEXTCLOUD_DOC_ID_RE.fullmatch(value))
+8 -6
View File
@@ -204,12 +204,14 @@ def _group_int_doc_ids(points: list[Any]) -> tuple[dict[str, list[Any]], int]:
value = payload.get("doc_id")
if value is None or isinstance(value, str):
continue
if not isinstance(value, int):
# Producers only ever write int or str; anything else is a
# producer bug. Stringifying e.g. a float would write "3.0",
# which producers (str(int)) and the keyword index would
# never match, and which int() on the verification side
# would later reject. Skip and log loudly instead.
# Strict type check: bool is a subclass of int in Python, so an
# `isinstance(value, int)` guard would let `True`/`False` slip
# through and be stringified to `"True"`/`"False"` — which the
# keyword index would never match and the verification side
# would later reject. Producers only ever write int or str;
# anything else (bool, float, etc.) is a producer bug. Skip
# and log loudly instead.
if type(value) is not int:
logger.warning(
"Unexpected doc_id type %s on point %s; skipping rewrite",
type(value).__name__,