Merge pull request #1670 from MODSetter/fix/kb-path-byte-limit-born-git-native
Some checks failed
Build and Push Docker Images / compute_version (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda126, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-24.04-arm, linux/arm64, arm64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cpu, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, , production, false, cpu) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu126, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-latest, linux/amd64, amd64, -cuda126, production, true, cuda126) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_backend, cu128, ./surfsense_backend/Dockerfile, backend, surfsense-backend, ubuntu-24.04-arm, linux/arm64, arm64, -cuda, production, true, cuda) (push) Has been cancelled
Build and Push Docker Images / build (./surfsense_web, cpu, ./surfsense_web/Dockerfile, web, surfsense-web, ubuntu-latest, linux/amd64, amd64, , runner, false, cpu) (push) Has been cancelled
Build and Push Docker Images / verify_digests (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, , cpu) (push) Has been cancelled
Build and Push Docker Images / create_manifest (backend, surfsense-backend, -cuda, cuda) (push) Has been cancelled
Build and Push Docker Images / create_manifest (web, surfsense-web, , cpu) (push) Has been cancelled
Build and Push Docker Images / finalize_release (push) Has been cancelled

[Fix] Git-native path byte-limit + born-git-native workspaces
This commit is contained in:
Thierry CH. 2026-08-07 17:49:05 +02:00 committed by GitHub
commit d96e29d4d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 124 additions and 3 deletions

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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"