spatial-memory-mcp 1.0.3__py3-none-any.whl → 1.5.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of spatial-memory-mcp might be problematic. Click here for more details.
- spatial_memory/__init__.py +97 -97
- spatial_memory/config.py +105 -0
- spatial_memory/core/__init__.py +26 -0
- spatial_memory/core/cache.py +317 -0
- spatial_memory/core/circuit_breaker.py +297 -0
- spatial_memory/core/database.py +167 -1
- spatial_memory/core/embeddings.py +92 -2
- spatial_memory/core/logging.py +194 -103
- spatial_memory/core/rate_limiter.py +309 -105
- spatial_memory/core/tracing.py +300 -0
- spatial_memory/core/validation.py +319 -319
- spatial_memory/server.py +229 -30
- spatial_memory/services/memory.py +79 -2
- spatial_memory/tools/definitions.py +695 -671
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/METADATA +1 -1
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/RECORD +19 -16
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/WHEEL +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/entry_points.txt +0 -0
- {spatial_memory_mcp-1.0.3.dist-info → spatial_memory_mcp-1.5.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,319 +1,319 @@
|
|
|
1
|
-
"""Centralized input validation for Spatial Memory MCP.
|
|
2
|
-
|
|
3
|
-
This module consolidates all validation logic from database.py and memory.py
|
|
4
|
-
to provide a single source of truth for input validation.
|
|
5
|
-
|
|
6
|
-
Security features:
|
|
7
|
-
- SQL injection prevention through pattern matching and escaping
|
|
8
|
-
- UUID format validation
|
|
9
|
-
- Content length validation
|
|
10
|
-
- Tag format and count validation
|
|
11
|
-
- Metadata size and serializability validation
|
|
12
|
-
"""
|
|
13
|
-
|
|
14
|
-
from __future__ import annotations
|
|
15
|
-
|
|
16
|
-
import json
|
|
17
|
-
import re
|
|
18
|
-
import uuid
|
|
19
|
-
from typing import Any
|
|
20
|
-
|
|
21
|
-
from spatial_memory.core.errors import ValidationError
|
|
22
|
-
|
|
23
|
-
# Content validation constants
|
|
24
|
-
MAX_CONTENT_LENGTH = 100_000 # 100KB of text
|
|
25
|
-
|
|
26
|
-
# Tag validation constants
|
|
27
|
-
MAX_TAGS = 100 # Maximum number of tags per memory
|
|
28
|
-
MAX_TAG_LENGTH = 50 # Maximum length of a single tag
|
|
29
|
-
|
|
30
|
-
# Metadata validation constants
|
|
31
|
-
MAX_METADATA_SIZE = 65536 # 64KB serialized JSON
|
|
32
|
-
|
|
33
|
-
# Namespace validation pattern
|
|
34
|
-
# Must start with letter, followed by letters/numbers/dash/underscore, max 63 chars
|
|
35
|
-
NAMESPACE_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,62}$")
|
|
36
|
-
|
|
37
|
-
# Tag validation pattern
|
|
38
|
-
# Must start with letter or number, followed by letters/numbers/dash/underscore, max 50 chars
|
|
39
|
-
TAG_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_
|
|
40
|
-
|
|
41
|
-
# Dangerous SQL patterns for injection prevention
|
|
42
|
-
DANGEROUS_PATTERNS = [
|
|
43
|
-
r";\s*(?:DROP|DELETE|UPDATE|INSERT|ALTER|CREATE|TRUNCATE)",
|
|
44
|
-
r"--\s*$",
|
|
45
|
-
r"/\*.*\*/",
|
|
46
|
-
r"'\s*OR\s*'",
|
|
47
|
-
r"'\s*AND\s*'",
|
|
48
|
-
r"'\s*UNION\s+(?:ALL\s+)?SELECT",
|
|
49
|
-
# Additional patterns for stored procedures and timing attacks
|
|
50
|
-
r";\s*EXEC(?:UTE)?\s", # EXEC/EXECUTE stored procedures
|
|
51
|
-
r"WAITFOR\s+DELAY", # Time-based SQL injection
|
|
52
|
-
r"(?:xp_|sp_)\w+", # SQL Server stored procedures
|
|
53
|
-
r"0x[0-9a-fA-F]+", # Hex-encoded strings
|
|
54
|
-
r"BENCHMARK\s*\(", # MySQL timing attack
|
|
55
|
-
r"SLEEP\s*\(", # MySQL/PostgreSQL sleep
|
|
56
|
-
r"PG_SLEEP\s*\(", # PostgreSQL specific
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def validate_uuid(value: str) -> str:
|
|
61
|
-
"""Validate and return a UUID string.
|
|
62
|
-
|
|
63
|
-
Args:
|
|
64
|
-
value: The value to validate as a UUID.
|
|
65
|
-
|
|
66
|
-
Returns:
|
|
67
|
-
The validated UUID string.
|
|
68
|
-
|
|
69
|
-
Raises:
|
|
70
|
-
ValidationError: If the value is not a valid UUID format.
|
|
71
|
-
|
|
72
|
-
Examples:
|
|
73
|
-
>>> validate_uuid("550e8400-e29b-41d4-a716-446655440000")
|
|
74
|
-
'550e8400-e29b-41d4-a716-446655440000'
|
|
75
|
-
>>> validate_uuid("not-a-uuid")
|
|
76
|
-
Traceback (most recent call last):
|
|
77
|
-
...
|
|
78
|
-
ValidationError: Invalid UUID format: not-a-uuid
|
|
79
|
-
"""
|
|
80
|
-
try:
|
|
81
|
-
# Attempt to parse as UUID to validate format
|
|
82
|
-
uuid.UUID(value)
|
|
83
|
-
return value
|
|
84
|
-
except (ValueError, AttributeError) as e:
|
|
85
|
-
raise ValidationError(f"Invalid UUID format: {value}") from e
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def validate_namespace(namespace: str) -> str:
|
|
89
|
-
"""Validate namespace format.
|
|
90
|
-
|
|
91
|
-
Namespaces must:
|
|
92
|
-
- Start with a letter
|
|
93
|
-
- Contain only letters, numbers, dash, underscore, or dot
|
|
94
|
-
- Be between 1-256 characters
|
|
95
|
-
- Not be empty
|
|
96
|
-
|
|
97
|
-
Args:
|
|
98
|
-
namespace: The namespace to validate.
|
|
99
|
-
|
|
100
|
-
Returns:
|
|
101
|
-
The validated namespace string.
|
|
102
|
-
|
|
103
|
-
Raises:
|
|
104
|
-
ValidationError: If the namespace is invalid.
|
|
105
|
-
|
|
106
|
-
Examples:
|
|
107
|
-
>>> validate_namespace("default")
|
|
108
|
-
'default'
|
|
109
|
-
>>> validate_namespace("my-namespace_v1.0")
|
|
110
|
-
'my-namespace_v1.0'
|
|
111
|
-
>>> validate_namespace("")
|
|
112
|
-
Traceback (most recent call last):
|
|
113
|
-
...
|
|
114
|
-
ValidationError: Namespace cannot be empty
|
|
115
|
-
"""
|
|
116
|
-
if not namespace:
|
|
117
|
-
raise ValidationError("Namespace cannot be empty")
|
|
118
|
-
|
|
119
|
-
if len(namespace) > 256:
|
|
120
|
-
raise ValidationError("Namespace too long (max 256 characters)")
|
|
121
|
-
|
|
122
|
-
# Allow alphanumeric, dash, underscore, dot
|
|
123
|
-
if not re.match(r"^[\w\-\.]+$", namespace):
|
|
124
|
-
raise ValidationError(f"Invalid namespace format: {namespace}")
|
|
125
|
-
|
|
126
|
-
return namespace
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def validate_content(content: str) -> None:
|
|
130
|
-
"""Validate memory content.
|
|
131
|
-
|
|
132
|
-
Content must:
|
|
133
|
-
- Not be empty or whitespace-only
|
|
134
|
-
- Not exceed MAX_CONTENT_LENGTH characters
|
|
135
|
-
|
|
136
|
-
Args:
|
|
137
|
-
content: Content to validate.
|
|
138
|
-
|
|
139
|
-
Raises:
|
|
140
|
-
ValidationError: If content is empty, whitespace-only, or too long.
|
|
141
|
-
|
|
142
|
-
Examples:
|
|
143
|
-
>>> validate_content("This is valid content")
|
|
144
|
-
>>> validate_content("")
|
|
145
|
-
Traceback (most recent call last):
|
|
146
|
-
...
|
|
147
|
-
ValidationError: Content cannot be empty
|
|
148
|
-
>>> validate_content("x" * 100001)
|
|
149
|
-
Traceback (most recent call last):
|
|
150
|
-
...
|
|
151
|
-
ValidationError: Content exceeds maximum length...
|
|
152
|
-
"""
|
|
153
|
-
if not content or not content.strip():
|
|
154
|
-
raise ValidationError("Content cannot be empty")
|
|
155
|
-
|
|
156
|
-
if len(content) > MAX_CONTENT_LENGTH:
|
|
157
|
-
raise ValidationError(
|
|
158
|
-
f"Content exceeds maximum length of {MAX_CONTENT_LENGTH} characters "
|
|
159
|
-
f"(got {len(content)} characters)"
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def validate_importance(importance: float) -> None:
|
|
164
|
-
"""Validate importance value (0.0-1.0).
|
|
165
|
-
|
|
166
|
-
Args:
|
|
167
|
-
importance: Importance to validate.
|
|
168
|
-
|
|
169
|
-
Raises:
|
|
170
|
-
ValidationError: If importance is out of range.
|
|
171
|
-
|
|
172
|
-
Examples:
|
|
173
|
-
>>> validate_importance(0.5)
|
|
174
|
-
>>> validate_importance(1.5)
|
|
175
|
-
Traceback (most recent call last):
|
|
176
|
-
...
|
|
177
|
-
ValidationError: Importance must be between 0.0 and 1.0
|
|
178
|
-
"""
|
|
179
|
-
if not 0.0 <= importance <= 1.0:
|
|
180
|
-
raise ValidationError("Importance must be between 0.0 and 1.0")
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def validate_tags(tags: list[str] | None) -> list[str]:
|
|
184
|
-
"""Validate and return tags list.
|
|
185
|
-
|
|
186
|
-
Tags must:
|
|
187
|
-
- Start with a letter or number
|
|
188
|
-
- Contain only letters, numbers, dash, or underscore
|
|
189
|
-
- Be between 1-50 characters each
|
|
190
|
-
- Have at most MAX_TAGS total tags
|
|
191
|
-
|
|
192
|
-
Args:
|
|
193
|
-
tags: List of tags to validate (None is treated as empty list).
|
|
194
|
-
|
|
195
|
-
Returns:
|
|
196
|
-
Validated tags list (empty list if None was provided).
|
|
197
|
-
|
|
198
|
-
Raises:
|
|
199
|
-
ValidationError: If tags are invalid.
|
|
200
|
-
|
|
201
|
-
Examples:
|
|
202
|
-
>>> validate_tags(["tag1", "tag2"])
|
|
203
|
-
['tag1', 'tag2']
|
|
204
|
-
>>> validate_tags(None)
|
|
205
|
-
[]
|
|
206
|
-
>>> validate_tags(["invalid tag"])
|
|
207
|
-
Traceback (most recent call last):
|
|
208
|
-
...
|
|
209
|
-
ValidationError: Invalid tag format...
|
|
210
|
-
"""
|
|
211
|
-
if tags is None:
|
|
212
|
-
return []
|
|
213
|
-
|
|
214
|
-
if len(tags) > MAX_TAGS:
|
|
215
|
-
raise ValidationError(f"Maximum {MAX_TAGS} tags allowed, got {len(tags)}")
|
|
216
|
-
|
|
217
|
-
validated = []
|
|
218
|
-
for tag in tags:
|
|
219
|
-
# Must be a string
|
|
220
|
-
if not isinstance(tag, str):
|
|
221
|
-
raise ValidationError(f"Tag must be a string, got {type(tag).__name__}")
|
|
222
|
-
|
|
223
|
-
# Must match pattern: start with letter/number, alphanumeric with dash/underscore
|
|
224
|
-
if not TAG_PATTERN.match(tag):
|
|
225
|
-
raise ValidationError(
|
|
226
|
-
f"Invalid tag format: '{tag}'. Tags must be 1-{MAX_TAG_LENGTH} characters, "
|
|
227
|
-
"start with letter or number, and contain only letters, numbers, dash, "
|
|
228
|
-
"or
|
|
229
|
-
)
|
|
230
|
-
|
|
231
|
-
validated.append(tag)
|
|
232
|
-
|
|
233
|
-
return validated
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
def validate_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
237
|
-
"""Validate and return metadata dict.
|
|
238
|
-
|
|
239
|
-
Metadata must:
|
|
240
|
-
- Be a dictionary
|
|
241
|
-
- Be JSON-serializable
|
|
242
|
-
- Not exceed MAX_METADATA_SIZE bytes when serialized
|
|
243
|
-
|
|
244
|
-
Args:
|
|
245
|
-
metadata: Metadata dictionary to validate (None is treated as empty dict).
|
|
246
|
-
|
|
247
|
-
Returns:
|
|
248
|
-
Validated metadata dictionary (empty dict if None was provided).
|
|
249
|
-
|
|
250
|
-
Raises:
|
|
251
|
-
ValidationError: If metadata is invalid.
|
|
252
|
-
|
|
253
|
-
Examples:
|
|
254
|
-
>>> validate_metadata({"key": "value"})
|
|
255
|
-
{'key': 'value'}
|
|
256
|
-
>>> validate_metadata(None)
|
|
257
|
-
{}
|
|
258
|
-
>>> validate_metadata("not a dict")
|
|
259
|
-
Traceback (most recent call last):
|
|
260
|
-
...
|
|
261
|
-
ValidationError: Metadata must be a dictionary...
|
|
262
|
-
"""
|
|
263
|
-
if metadata is None:
|
|
264
|
-
return {}
|
|
265
|
-
|
|
266
|
-
if not isinstance(metadata, dict):
|
|
267
|
-
raise ValidationError(f"Metadata must be a dictionary, got {type(metadata).__name__}")
|
|
268
|
-
|
|
269
|
-
# Check serialized size (max 64KB)
|
|
270
|
-
try:
|
|
271
|
-
serialized = json.dumps(metadata)
|
|
272
|
-
if len(serialized) > MAX_METADATA_SIZE:
|
|
273
|
-
raise ValidationError(
|
|
274
|
-
f"Metadata exceeds 64KB limit ({len(serialized)} bytes)"
|
|
275
|
-
)
|
|
276
|
-
except (TypeError, ValueError) as e:
|
|
277
|
-
raise ValidationError(f"Metadata must be JSON-serializable: {e}") from e
|
|
278
|
-
|
|
279
|
-
return metadata
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
def sanitize_string(value: str) -> str:
|
|
283
|
-
"""Sanitize string for safe SQL usage.
|
|
284
|
-
|
|
285
|
-
Prevents SQL injection by:
|
|
286
|
-
1. Validating input type
|
|
287
|
-
2. Detecting dangerous SQL patterns
|
|
288
|
-
3. Escaping single quotes
|
|
289
|
-
|
|
290
|
-
Args:
|
|
291
|
-
value: The string value to sanitize.
|
|
292
|
-
|
|
293
|
-
Returns:
|
|
294
|
-
Sanitized string safe for use in filter expressions.
|
|
295
|
-
|
|
296
|
-
Raises:
|
|
297
|
-
ValidationError: If the value contains invalid characters or SQL injection patterns.
|
|
298
|
-
|
|
299
|
-
Examples:
|
|
300
|
-
>>> sanitize_string("hello")
|
|
301
|
-
'hello'
|
|
302
|
-
>>> sanitize_string("it's")
|
|
303
|
-
"it''s"
|
|
304
|
-
>>> sanitize_string("'; DROP TABLE users--")
|
|
305
|
-
Traceback (most recent call last):
|
|
306
|
-
...
|
|
307
|
-
ValidationError: Invalid characters in value...
|
|
308
|
-
"""
|
|
309
|
-
if not isinstance(value, str):
|
|
310
|
-
raise ValidationError(f"Expected string, got {type(value).__name__}")
|
|
311
|
-
|
|
312
|
-
# Check for dangerous SQL injection patterns
|
|
313
|
-
for pattern in DANGEROUS_PATTERNS:
|
|
314
|
-
if re.search(pattern, value, re.IGNORECASE):
|
|
315
|
-
# Only show first 50 chars in error to prevent log flooding
|
|
316
|
-
raise ValidationError(f"Invalid characters in value: {value[:50]}")
|
|
317
|
-
|
|
318
|
-
# Escape single quotes by doubling them (standard SQL escaping)
|
|
319
|
-
return value.replace("'", "''")
|
|
1
|
+
"""Centralized input validation for Spatial Memory MCP.
|
|
2
|
+
|
|
3
|
+
This module consolidates all validation logic from database.py and memory.py
|
|
4
|
+
to provide a single source of truth for input validation.
|
|
5
|
+
|
|
6
|
+
Security features:
|
|
7
|
+
- SQL injection prevention through pattern matching and escaping
|
|
8
|
+
- UUID format validation
|
|
9
|
+
- Content length validation
|
|
10
|
+
- Tag format and count validation
|
|
11
|
+
- Metadata size and serializability validation
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import re
|
|
18
|
+
import uuid
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from spatial_memory.core.errors import ValidationError
|
|
22
|
+
|
|
23
|
+
# Content validation constants
|
|
24
|
+
MAX_CONTENT_LENGTH = 100_000 # 100KB of text
|
|
25
|
+
|
|
26
|
+
# Tag validation constants
|
|
27
|
+
MAX_TAGS = 100 # Maximum number of tags per memory
|
|
28
|
+
MAX_TAG_LENGTH = 50 # Maximum length of a single tag
|
|
29
|
+
|
|
30
|
+
# Metadata validation constants
|
|
31
|
+
MAX_METADATA_SIZE = 65536 # 64KB serialized JSON
|
|
32
|
+
|
|
33
|
+
# Namespace validation pattern
|
|
34
|
+
# Must start with letter, followed by letters/numbers/dash/underscore, max 63 chars
|
|
35
|
+
NAMESPACE_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_-]{0,62}$")
|
|
36
|
+
|
|
37
|
+
# Tag validation pattern
|
|
38
|
+
# Must start with letter or number, followed by letters/numbers/dash/underscore/dot, max 50 chars
|
|
39
|
+
TAG_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-.]{0,49}$")
|
|
40
|
+
|
|
41
|
+
# Dangerous SQL patterns for injection prevention
|
|
42
|
+
DANGEROUS_PATTERNS = [
|
|
43
|
+
r";\s*(?:DROP|DELETE|UPDATE|INSERT|ALTER|CREATE|TRUNCATE)",
|
|
44
|
+
r"--\s*$",
|
|
45
|
+
r"/\*.*\*/",
|
|
46
|
+
r"'\s*OR\s*'",
|
|
47
|
+
r"'\s*AND\s*'",
|
|
48
|
+
r"'\s*UNION\s+(?:ALL\s+)?SELECT",
|
|
49
|
+
# Additional patterns for stored procedures and timing attacks
|
|
50
|
+
r";\s*EXEC(?:UTE)?\s", # EXEC/EXECUTE stored procedures
|
|
51
|
+
r"WAITFOR\s+DELAY", # Time-based SQL injection
|
|
52
|
+
r"(?:xp_|sp_)\w+", # SQL Server stored procedures
|
|
53
|
+
r"0x[0-9a-fA-F]+", # Hex-encoded strings
|
|
54
|
+
r"BENCHMARK\s*\(", # MySQL timing attack
|
|
55
|
+
r"SLEEP\s*\(", # MySQL/PostgreSQL sleep
|
|
56
|
+
r"PG_SLEEP\s*\(", # PostgreSQL specific
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def validate_uuid(value: str) -> str:
|
|
61
|
+
"""Validate and return a UUID string.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
value: The value to validate as a UUID.
|
|
65
|
+
|
|
66
|
+
Returns:
|
|
67
|
+
The validated UUID string.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValidationError: If the value is not a valid UUID format.
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
>>> validate_uuid("550e8400-e29b-41d4-a716-446655440000")
|
|
74
|
+
'550e8400-e29b-41d4-a716-446655440000'
|
|
75
|
+
>>> validate_uuid("not-a-uuid")
|
|
76
|
+
Traceback (most recent call last):
|
|
77
|
+
...
|
|
78
|
+
ValidationError: Invalid UUID format: not-a-uuid
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
# Attempt to parse as UUID to validate format
|
|
82
|
+
uuid.UUID(value)
|
|
83
|
+
return value
|
|
84
|
+
except (ValueError, AttributeError) as e:
|
|
85
|
+
raise ValidationError(f"Invalid UUID format: {value}") from e
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def validate_namespace(namespace: str) -> str:
|
|
89
|
+
"""Validate namespace format.
|
|
90
|
+
|
|
91
|
+
Namespaces must:
|
|
92
|
+
- Start with a letter
|
|
93
|
+
- Contain only letters, numbers, dash, underscore, or dot
|
|
94
|
+
- Be between 1-256 characters
|
|
95
|
+
- Not be empty
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
namespace: The namespace to validate.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The validated namespace string.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValidationError: If the namespace is invalid.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> validate_namespace("default")
|
|
108
|
+
'default'
|
|
109
|
+
>>> validate_namespace("my-namespace_v1.0")
|
|
110
|
+
'my-namespace_v1.0'
|
|
111
|
+
>>> validate_namespace("")
|
|
112
|
+
Traceback (most recent call last):
|
|
113
|
+
...
|
|
114
|
+
ValidationError: Namespace cannot be empty
|
|
115
|
+
"""
|
|
116
|
+
if not namespace:
|
|
117
|
+
raise ValidationError("Namespace cannot be empty")
|
|
118
|
+
|
|
119
|
+
if len(namespace) > 256:
|
|
120
|
+
raise ValidationError("Namespace too long (max 256 characters)")
|
|
121
|
+
|
|
122
|
+
# Allow alphanumeric, dash, underscore, dot
|
|
123
|
+
if not re.match(r"^[\w\-\.]+$", namespace):
|
|
124
|
+
raise ValidationError(f"Invalid namespace format: {namespace}")
|
|
125
|
+
|
|
126
|
+
return namespace
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def validate_content(content: str) -> None:
|
|
130
|
+
"""Validate memory content.
|
|
131
|
+
|
|
132
|
+
Content must:
|
|
133
|
+
- Not be empty or whitespace-only
|
|
134
|
+
- Not exceed MAX_CONTENT_LENGTH characters
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
content: Content to validate.
|
|
138
|
+
|
|
139
|
+
Raises:
|
|
140
|
+
ValidationError: If content is empty, whitespace-only, or too long.
|
|
141
|
+
|
|
142
|
+
Examples:
|
|
143
|
+
>>> validate_content("This is valid content")
|
|
144
|
+
>>> validate_content("")
|
|
145
|
+
Traceback (most recent call last):
|
|
146
|
+
...
|
|
147
|
+
ValidationError: Content cannot be empty
|
|
148
|
+
>>> validate_content("x" * 100001)
|
|
149
|
+
Traceback (most recent call last):
|
|
150
|
+
...
|
|
151
|
+
ValidationError: Content exceeds maximum length...
|
|
152
|
+
"""
|
|
153
|
+
if not content or not content.strip():
|
|
154
|
+
raise ValidationError("Content cannot be empty")
|
|
155
|
+
|
|
156
|
+
if len(content) > MAX_CONTENT_LENGTH:
|
|
157
|
+
raise ValidationError(
|
|
158
|
+
f"Content exceeds maximum length of {MAX_CONTENT_LENGTH} characters "
|
|
159
|
+
f"(got {len(content)} characters)"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def validate_importance(importance: float) -> None:
|
|
164
|
+
"""Validate importance value (0.0-1.0).
|
|
165
|
+
|
|
166
|
+
Args:
|
|
167
|
+
importance: Importance to validate.
|
|
168
|
+
|
|
169
|
+
Raises:
|
|
170
|
+
ValidationError: If importance is out of range.
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
>>> validate_importance(0.5)
|
|
174
|
+
>>> validate_importance(1.5)
|
|
175
|
+
Traceback (most recent call last):
|
|
176
|
+
...
|
|
177
|
+
ValidationError: Importance must be between 0.0 and 1.0
|
|
178
|
+
"""
|
|
179
|
+
if not 0.0 <= importance <= 1.0:
|
|
180
|
+
raise ValidationError("Importance must be between 0.0 and 1.0")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def validate_tags(tags: list[str] | None) -> list[str]:
|
|
184
|
+
"""Validate and return tags list.
|
|
185
|
+
|
|
186
|
+
Tags must:
|
|
187
|
+
- Start with a letter or number
|
|
188
|
+
- Contain only letters, numbers, dash, or underscore
|
|
189
|
+
- Be between 1-50 characters each
|
|
190
|
+
- Have at most MAX_TAGS total tags
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
tags: List of tags to validate (None is treated as empty list).
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Validated tags list (empty list if None was provided).
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ValidationError: If tags are invalid.
|
|
200
|
+
|
|
201
|
+
Examples:
|
|
202
|
+
>>> validate_tags(["tag1", "tag2"])
|
|
203
|
+
['tag1', 'tag2']
|
|
204
|
+
>>> validate_tags(None)
|
|
205
|
+
[]
|
|
206
|
+
>>> validate_tags(["invalid tag"])
|
|
207
|
+
Traceback (most recent call last):
|
|
208
|
+
...
|
|
209
|
+
ValidationError: Invalid tag format...
|
|
210
|
+
"""
|
|
211
|
+
if tags is None:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
if len(tags) > MAX_TAGS:
|
|
215
|
+
raise ValidationError(f"Maximum {MAX_TAGS} tags allowed, got {len(tags)}")
|
|
216
|
+
|
|
217
|
+
validated = []
|
|
218
|
+
for tag in tags:
|
|
219
|
+
# Must be a string
|
|
220
|
+
if not isinstance(tag, str):
|
|
221
|
+
raise ValidationError(f"Tag must be a string, got {type(tag).__name__}")
|
|
222
|
+
|
|
223
|
+
# Must match pattern: start with letter/number, alphanumeric with dash/underscore/dot
|
|
224
|
+
if not TAG_PATTERN.match(tag):
|
|
225
|
+
raise ValidationError(
|
|
226
|
+
f"Invalid tag format: '{tag}'. Tags must be 1-{MAX_TAG_LENGTH} characters, "
|
|
227
|
+
"start with letter or number, and contain only letters, numbers, dash, "
|
|
228
|
+
"underscore, or dot."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
validated.append(tag)
|
|
232
|
+
|
|
233
|
+
return validated
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def validate_metadata(metadata: dict[str, Any] | None) -> dict[str, Any]:
|
|
237
|
+
"""Validate and return metadata dict.
|
|
238
|
+
|
|
239
|
+
Metadata must:
|
|
240
|
+
- Be a dictionary
|
|
241
|
+
- Be JSON-serializable
|
|
242
|
+
- Not exceed MAX_METADATA_SIZE bytes when serialized
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
metadata: Metadata dictionary to validate (None is treated as empty dict).
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Validated metadata dictionary (empty dict if None was provided).
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
ValidationError: If metadata is invalid.
|
|
252
|
+
|
|
253
|
+
Examples:
|
|
254
|
+
>>> validate_metadata({"key": "value"})
|
|
255
|
+
{'key': 'value'}
|
|
256
|
+
>>> validate_metadata(None)
|
|
257
|
+
{}
|
|
258
|
+
>>> validate_metadata("not a dict")
|
|
259
|
+
Traceback (most recent call last):
|
|
260
|
+
...
|
|
261
|
+
ValidationError: Metadata must be a dictionary...
|
|
262
|
+
"""
|
|
263
|
+
if metadata is None:
|
|
264
|
+
return {}
|
|
265
|
+
|
|
266
|
+
if not isinstance(metadata, dict):
|
|
267
|
+
raise ValidationError(f"Metadata must be a dictionary, got {type(metadata).__name__}")
|
|
268
|
+
|
|
269
|
+
# Check serialized size (max 64KB)
|
|
270
|
+
try:
|
|
271
|
+
serialized = json.dumps(metadata)
|
|
272
|
+
if len(serialized) > MAX_METADATA_SIZE:
|
|
273
|
+
raise ValidationError(
|
|
274
|
+
f"Metadata exceeds 64KB limit ({len(serialized)} bytes)"
|
|
275
|
+
)
|
|
276
|
+
except (TypeError, ValueError) as e:
|
|
277
|
+
raise ValidationError(f"Metadata must be JSON-serializable: {e}") from e
|
|
278
|
+
|
|
279
|
+
return metadata
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def sanitize_string(value: str) -> str:
|
|
283
|
+
"""Sanitize string for safe SQL usage.
|
|
284
|
+
|
|
285
|
+
Prevents SQL injection by:
|
|
286
|
+
1. Validating input type
|
|
287
|
+
2. Detecting dangerous SQL patterns
|
|
288
|
+
3. Escaping single quotes
|
|
289
|
+
|
|
290
|
+
Args:
|
|
291
|
+
value: The string value to sanitize.
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Sanitized string safe for use in filter expressions.
|
|
295
|
+
|
|
296
|
+
Raises:
|
|
297
|
+
ValidationError: If the value contains invalid characters or SQL injection patterns.
|
|
298
|
+
|
|
299
|
+
Examples:
|
|
300
|
+
>>> sanitize_string("hello")
|
|
301
|
+
'hello'
|
|
302
|
+
>>> sanitize_string("it's")
|
|
303
|
+
"it''s"
|
|
304
|
+
>>> sanitize_string("'; DROP TABLE users--")
|
|
305
|
+
Traceback (most recent call last):
|
|
306
|
+
...
|
|
307
|
+
ValidationError: Invalid characters in value...
|
|
308
|
+
"""
|
|
309
|
+
if not isinstance(value, str):
|
|
310
|
+
raise ValidationError(f"Expected string, got {type(value).__name__}")
|
|
311
|
+
|
|
312
|
+
# Check for dangerous SQL injection patterns
|
|
313
|
+
for pattern in DANGEROUS_PATTERNS:
|
|
314
|
+
if re.search(pattern, value, re.IGNORECASE):
|
|
315
|
+
# Only show first 50 chars in error to prevent log flooding
|
|
316
|
+
raise ValidationError(f"Invalid characters in value: {value[:50]}")
|
|
317
|
+
|
|
318
|
+
# Escape single quotes by doubling them (standard SQL escaping)
|
|
319
|
+
return value.replace("'", "''")
|