diff --git a/surfsense_backend/app/knowledge_store/paths/naming.py b/surfsense_backend/app/knowledge_store/paths/naming.py index 2b94183e6..5e9258b2f 100644 --- a/surfsense_backend/app/knowledge_store/paths/naming.py +++ b/surfsense_backend/app/knowledge_store/paths/naming.py @@ -10,6 +10,28 @@ from app.knowledge_store.paths.store_path import StorePath, validate_segments _INVALID_FILENAME_CHARS = re.compile(r"[\\/:*?\"<>|]+") _WHITESPACE_RUN = re.compile(r"\s+") _MAX_SEGMENT_LEN = 180 +# Per-component filesystem limit is bytes, not characters (255 on ext4). +_MAX_SEGMENT_BYTES = 255 + + +def _truncate_to_bytes(text: str, max_bytes: int) -> str: + """Longest prefix whose UTF-8 encoding fits ``max_bytes``, cut on a char boundary.""" + encoded = text.encode("utf-8") + if len(encoded) <= max_bytes: + return text + return encoded[: max(max_bytes, 0)].decode("utf-8", "ignore") + + +def _clamp_segment_bytes(name: str, *, max_bytes: int = _MAX_SEGMENT_BYTES) -> str: + """Clamp a filename to ``max_bytes``, keeping its extension.""" + if len(name.encode("utf-8")) <= max_bytes: + return name + stem, dot, ext = name.rpartition(".") + if dot and stem: + suffix = f".{ext}" + budget = max_bytes - len(suffix.encode("utf-8")) + return _truncate_to_bytes(stem, budget).rstrip() + suffix + return _truncate_to_bytes(name, max_bytes).rstrip() def safe_folder_segment(value: str, *, fallback: str = "folder") -> str: @@ -20,7 +42,7 @@ def safe_folder_segment(value: str, *, fallback: str = "folder") -> str: return fallback if len(name) > _MAX_SEGMENT_LEN: name = name[:_MAX_SEGMENT_LEN].rstrip() - return name + return _truncate_to_bytes(name, _MAX_SEGMENT_BYTES).rstrip() def normalize_filename(value: str, *, fallback: str = "untitled.md") -> str: @@ -34,7 +56,7 @@ def normalize_filename(value: str, *, fallback: str = "untitled.md") -> str: stem, dot, ext = name.rpartition(".") if not dot or not stem or not ext or len(ext) > 12 or " " in ext: name = f"{name}.md" - return name + return _clamp_segment_bytes(name) def markdown_name_for_source(source_filename: str) -> str: @@ -66,7 +88,9 @@ def allocate_path( base, extension = (stem, f".{ext}") if dot else (filename, "") counter = 2 while True: - disambiguated = f"{base} ({counter}){extension}" + suffix = f" ({counter}){extension}" + budget = _MAX_SEGMENT_BYTES - len(suffix.encode("utf-8")) + disambiguated = _truncate_to_bytes(base, budget).rstrip() + suffix candidate = StorePath(validate_segments((*folders, disambiguated))) if candidate.virtual_path not in taken: taken.add(candidate.virtual_path) diff --git a/surfsense_backend/app/routes/workspaces_routes.py b/surfsense_backend/app/routes/workspaces_routes.py index de7cc5be6..a2c28dc74 100644 --- a/surfsense_backend/app/routes/workspaces_routes.py +++ b/surfsense_backend/app/routes/workspaces_routes.py @@ -104,6 +104,7 @@ async def create_workspace( # qna_custom_instructions defaults to None/empty (handled by DB) db_workspace = Workspace(**workspace_data, user_id=user.id) + db_workspace.knowledge_store_enabled = config.KNOWLEDGE_STORE_ENABLED session.add(db_workspace) await session.flush() # Get the workspace ID diff --git a/surfsense_backend/tests/integration/test_workspace_born_git_native.py b/surfsense_backend/tests/integration/test_workspace_born_git_native.py new file mode 100644 index 000000000..845fb42c9 --- /dev/null +++ b/surfsense_backend/tests/integration/test_workspace_born_git_native.py @@ -0,0 +1,53 @@ +"""A new workspace inherits the global git-native switch at creation. + +The flag must survive a real INSERT round-trip, not merely live on the transient +ORM object — so this exercises the route against a real database. +""" + +from __future__ import annotations + +import pytest +from sqlalchemy import select + +from app.auth.context import AuthContext +from app.config import config as app_config +from app.db import Workspace +from app.routes import workspaces_routes +from app.schemas import WorkspaceCreate + +pytestmark = pytest.mark.integration + + +async def _persisted_flag(db_session, workspace_id: int) -> bool: + db_session.expire_all() + return await db_session.scalar( + select(Workspace.knowledge_store_enabled).where(Workspace.id == workspace_id) + ) + + +async def test_new_workspace_persists_git_native_when_global_enabled( + db_session, db_user, monkeypatch +): + monkeypatch.setattr(app_config, "KNOWLEDGE_STORE_ENABLED", True) + + response = await workspaces_routes.create_workspace( + WorkspaceCreate(name="Born git-native", description=""), + session=db_session, + auth=AuthContext.session(db_user), + ) + + assert await _persisted_flag(db_session, response.id) is True + + +async def test_new_workspace_persists_legacy_when_global_disabled( + db_session, db_user, monkeypatch +): + monkeypatch.setattr(app_config, "KNOWLEDGE_STORE_ENABLED", False) + + response = await workspaces_routes.create_workspace( + WorkspaceCreate(name="Stays legacy", description=""), + session=db_session, + auth=AuthContext.session(db_user), + ) + + assert await _persisted_flag(db_session, response.id) is False diff --git a/surfsense_backend/tests/unit/knowledge_store/test_naming_byte_length.py b/surfsense_backend/tests/unit/knowledge_store/test_naming_byte_length.py new file mode 100644 index 000000000..554f48f59 --- /dev/null +++ b/surfsense_backend/tests/unit/knowledge_store/test_naming_byte_length.py @@ -0,0 +1,43 @@ +"""Derived path components must fit the filesystem's 255-byte per-component limit.""" + +import pytest + +from app.knowledge_store.paths import ( + allocate_path, + normalize_filename, + safe_folder_segment, +) + +pytestmark = pytest.mark.unit + +_MAX_COMPONENT_BYTES = 255 + + +def test_normalize_filename_fits_byte_limit_for_multibyte_title(): + title = "低空经济政策对比" * 25 # 200 chars, 600 UTF-8 bytes + name = normalize_filename(title) + assert len(name.encode("utf-8")) <= _MAX_COMPONENT_BYTES + + +def test_safe_folder_segment_fits_byte_limit_for_multibyte_name(): + name = "低空经济政策对比" * 25 + seg = safe_folder_segment(name) + assert len(seg.encode("utf-8")) <= _MAX_COMPONENT_BYTES + + +def test_allocate_path_disambiguation_stays_within_byte_limit(): + name = "低空经济政策对比" * 25 + taken: set[str] = set() + first = allocate_path(name=name, folder_parts=(), taken=taken) + second = allocate_path(name=name, folder_parts=(), taken=taken) + assert first.virtual_path != second.virtual_path + assert len(second.name.encode("utf-8")) <= _MAX_COMPONENT_BYTES + + +def test_derived_name_is_writable_where_raw_title_is_not(tmp_path): + raw = "低空经济政策对比" * 25 + ".xlsx" + with pytest.raises(OSError): + (tmp_path / raw).write_bytes(b"x") + safe = normalize_filename(raw) + (tmp_path / safe).write_bytes(b"x") + assert (tmp_path / safe).read_bytes() == b"x"