mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-08-19 13:53:39 +00:00
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
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:
commit
d96e29d4d2
4 changed files with 124 additions and 3 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
Loading…
Add table
Add a link
Reference in a new issue