fix: strip NUL bytes from content before PostgreSQL search indexing (#592)

Signed-off-by: phernandez <paul@basicmachines.co>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Paul Hernandez
2026-02-20 18:11:04 -06:00
committed by GitHub
parent d7faeb754e
commit edb7991ccf
10 changed files with 221 additions and 18 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
"""basic-memory - Local-first knowledge management combining Zettelkasten with knowledge graphs"""
# Package version - updated by release automation
__version__ = "0.18.3"
__version__ = "0.18.5"
# API version for FastAPI - independent of package version
__api_version__ = "v0"
@@ -223,6 +223,9 @@ def project_sync(
*TIGRIS_CONSISTENCY_HEADERS,
"--filter-from",
str(filter_path),
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
# See: rclone/rclone#6801
"--local-no-preallocate",
]
if verbose:
@@ -299,6 +302,9 @@ def project_bisync(
str(filter_path),
"--workdir",
str(state_path),
# Prevent NUL byte padding on virtual filesystems (e.g. Google Drive File Stream)
# See: rclone/rclone#6801
"--local-no-preallocate",
]
# Add --create-empty-src-dirs if rclone version supports it (v1.64+)
@@ -24,6 +24,15 @@ from basic_memory.repository.semantic_errors import SemanticDependenciesMissingE
from basic_memory.schemas.search import SearchItemType, SearchRetrievalMode
def _strip_nul_from_row(row_data: dict) -> dict:
"""Strip NUL bytes from all string values in a row dict.
Secondary defense: PostgreSQL text columns cannot store \\x00.
Primary sanitization happens in SearchService.index_entity_markdown().
"""
return {k: v.replace("\x00", "") if isinstance(v, str) else v for k, v in row_data.items()}
class PostgresSearchRepository(SearchRepositoryBase):
"""PostgreSQL tsvector implementation of search repository.
@@ -92,6 +101,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
# Serialize JSON for raw SQL
insert_data = search_index_row.to_insert(serialize_json=True)
insert_data["project_id"] = self.project_id
insert_data = _strip_nul_from_row(insert_data)
# Use upsert to handle race conditions during parallel indexing
# ON CONFLICT (permalink, project_id) matches the partial unique index
@@ -533,7 +543,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
for row in search_index_rows:
insert_data = row.to_insert(serialize_json=True)
insert_data["project_id"] = self.project_id
insert_data_list.append(insert_data)
insert_data_list.append(_strip_nul_from_row(insert_data))
# Use upsert to handle race conditions during parallel indexing
# ON CONFLICT (permalink, project_id) matches the partial unique index
+20 -11
View File
@@ -58,6 +58,15 @@ FTS_RELAXED_STOPWORDS = {
}
def _strip_nul(value: str) -> str:
"""Strip NUL bytes that PostgreSQL text columns cannot store.
rclone preallocation on virtual filesystems (e.g. Google Drive File Stream)
can pad files with \\x00 bytes. See: rclone/rclone#6801
"""
return value.replace("\x00", "")
def _mtime_to_datetime(entity: Entity) -> datetime:
"""Convert entity mtime (file modification time) to datetime.
@@ -402,7 +411,7 @@ class SearchService:
id=entity.id,
entity_id=entity.id,
type=SearchItemType.ENTITY.value,
title=entity.title,
title=_strip_nul(entity.title),
permalink=entity.permalink, # Required for Postgres NOT NULL constraint
file_path=entity.file_path,
metadata={
@@ -461,7 +470,7 @@ class SearchService:
# Store full content for vector embedding quality.
# The chunker in the vector pipeline splits this into
# appropriately-sized pieces for embedding.
content_snippet = content
content_snippet = _strip_nul(content)
if entity.permalink:
content_stems.extend(self._generate_variants(entity.permalink))
@@ -473,7 +482,7 @@ class SearchService:
if entity_tags:
content_stems.extend(entity_tags)
entity_content_stems = "\n".join(p for p in content_stems if p and p.strip())
entity_content_stems = _strip_nul("\n".join(p for p in content_stems if p and p.strip()))
# Truncate to stay under Postgres's 8KB index row limit
if len(entity_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
@@ -484,7 +493,7 @@ class SearchService:
SearchIndexRow(
id=entity.id,
type=SearchItemType.ENTITY.value,
title=entity.title,
title=_strip_nul(entity.title),
content_stems=entity_content_stems,
content_snippet=content_snippet,
permalink=entity.permalink,
@@ -510,8 +519,8 @@ class SearchService:
seen_permalinks.add(obs_permalink)
# Index with parent entity's file path since that's where it's defined
obs_content_stems = "\n".join(
p for p in self._generate_variants(obs.content) if p and p.strip()
obs_content_stems = _strip_nul(
"\n".join(p for p in self._generate_variants(obs.content) if p and p.strip())
)
# Truncate to stay under Postgres's 8KB index row limit
if len(obs_content_stems) > MAX_CONTENT_STEMS_SIZE: # pragma: no cover
@@ -520,9 +529,9 @@ class SearchService:
SearchIndexRow(
id=obs.id,
type=SearchItemType.OBSERVATION.value,
title=f"{obs.category}: {obs.content[:100]}...",
title=_strip_nul(f"{obs.category}: {obs.content[:100]}..."),
content_stems=obs_content_stems,
content_snippet=obs.content,
content_snippet=_strip_nul(obs.content),
permalink=obs_permalink,
file_path=entity.file_path,
category=obs.category,
@@ -539,14 +548,14 @@ class SearchService:
# Add relation rows (only outgoing relations defined in this file)
for rel in entity.outgoing_relations:
# Create descriptive title showing the relationship
relation_title = (
relation_title = _strip_nul(
f"{rel.from_entity.title}{rel.to_entity.title}"
if rel.to_entity
else f"{rel.from_entity.title}"
)
rel_content_stems = "\n".join(
p for p in self._generate_variants(relation_title) if p and p.strip()
rel_content_stems = _strip_nul(
"\n".join(p for p in self._generate_variants(relation_title) if p and p.strip())
)
rows_to_index.append(
SearchIndexRow(