From 6d15f1f247c41af22b8f7fc99631a3a18950ff37 Mon Sep 17 00:00:00 2001 From: Yiorgis Gozadinos Date: Fri, 22 May 2026 13:45:55 +0300 Subject: [PATCH] fix URL-decode file:// paths before stat/exists checks --- haiku_rag_slim/haiku/rag/client/documents.py | 21 ++++---- tests/ingester/test_serve_integration.py | 56 ++++++++++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/haiku_rag_slim/haiku/rag/client/documents.py b/haiku_rag_slim/haiku/rag/client/documents.py index f8b17ea3..973e123f 100644 --- a/haiku_rag_slim/haiku/rag/client/documents.py +++ b/haiku_rag_slim/haiku/rag/client/documents.py @@ -1,7 +1,7 @@ import tempfile from pathlib import Path from typing import TYPE_CHECKING -from urllib.parse import urlparse +from urllib.parse import unquote, urlparse from haiku.rag.client.processing import ( ensure_chunks_embedded, @@ -315,8 +315,10 @@ async def create_document_from_source( # Directory case: recurse with the existing FS filter and produce one # document per file. Remote schemes (http/s3) never hit this branch. if parsed_url.scheme in ("", "file"): + # file:// URIs URL-encode special characters ([, ], spaces, etc.); + # unquote to get the real filesystem path before any stat/rglob. local_path = ( - Path(parsed_url.path) + Path(unquote(parsed_url.path)) if parsed_url.scheme == "file" else (Path(source) if isinstance(source, str) else source) ) @@ -355,16 +357,15 @@ async def create_document_from_source( fetcher = resolve_fetcher(source_str, storage_options=storage_options) # The stored URI is what we look up + persist by. For an explicit uri - # override, use it as-is. Otherwise canonicalize local paths to file:// - # and leave remote URIs alone. + # override, use it as-is. For a file:// input the source string is + # already canonical (URL-encoded); round-tripping via Path.as_uri() + # would double-encode any escapes like %5B. For bare paths, canonicalize. if uri is not None: stored_uri = uri - elif parsed_url.scheme in ("", "file"): - stored_uri = ( - (Path(parsed_url.path) if parsed_url.scheme == "file" else Path(source_str)) - .absolute() - .as_uri() - ) + elif parsed_url.scheme == "file": + stored_uri = source_str + elif parsed_url.scheme == "": + stored_uri = Path(source_str).absolute().as_uri() else: stored_uri = source_str diff --git a/tests/ingester/test_serve_integration.py b/tests/ingester/test_serve_integration.py index 87d5f439..7a2c5737 100644 --- a/tests/ingester/test_serve_integration.py +++ b/tests/ingester/test_serve_integration.py @@ -135,6 +135,62 @@ async def test_e2e_initial_sweep_lands_succeeded_jobs(tmp_path, jobs, sync): assert row_b is not None and row_b.content_hash and row_b.last_ingested_at +@pytest.mark.asyncio +async def test_e2e_handles_url_encoded_special_chars_in_path(tmp_path, jobs, sync): + """File names containing characters that path.as_uri() URL-encodes (e.g. + Next.js dynamic-route brackets like `[chunk_id]`) must survive the + round-trip through the job queue without tripping the existence check + inside create_document_from_source.""" + bracketed_dir = tmp_path / "[chunk_id]" + bracketed_dir.mkdir() + target = bracketed_dir / "route.ts" + target.write_text("export default {};") + + client = _mock_client(tmp_path) + cfg = FSSourceConfig( + type="fs", + id="local", + root=tmp_path, + poll_interval_s=60.0, + ) + + manager = PollerManager( + configs=[cfg], + job_repo=jobs, + sync_repo=sync, + supported_extensions=[".ts"], + ) + pool = WorkerPool( + client=client, + job_repo=jobs, + sync_repo=sync, + worker_count=1, + max_concurrent=1, + poll_idle_interval_s=0.05, + ) + + await pool.start() + await manager.start() + try: + + async def _one_succeeded() -> bool: + counts = await jobs.counts_by_status() + return counts.get("succeeded", 0) == 1 + + await _wait_for(_one_succeeded, timeout=5.0) + finally: + await manager.stop() + await pool.stop() + + counts = await jobs.counts_by_status() + assert counts.get("succeeded", 0) == 1 + assert counts.get("dead", 0) == 0 # no PermanentError("File does not exist") + + # The URI in the queue is URL-encoded; the worker still finds the file. + [call] = client.create_document_from_source.await_args_list + assert "%5Bchunk_id%5D" in call.args[0] + + @pytest.mark.asyncio async def test_e2e_watchfiles_push_event_lands_as_job(tmp_path, jobs, sync): """FSPoller's watchfiles loop: a file *added* after startup should land