cinchdb 0.1.17__py3-none-any.whl → 0.1.18__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.
@@ -1,6 +1,8 @@
1
1
  """Tenant management for CinchDB."""
2
2
 
3
3
  import hashlib
4
+ import logging
5
+ import os
4
6
  import shutil
5
7
  import sqlite3
6
8
  import uuid
@@ -13,6 +15,13 @@ from cinchdb.core.path_utils import (
13
15
  get_branch_path,
14
16
  get_tenant_db_path,
15
17
  list_tenants,
18
+ # New tenant-first path utilities
19
+ get_context_root,
20
+ get_tenant_db_path_in_context,
21
+ ensure_context_directory,
22
+ ensure_tenant_db_path,
23
+ calculate_shard,
24
+ invalidate_cache,
16
25
  )
17
26
  from cinchdb.core.connection import DatabaseConnection
18
27
  from cinchdb.core.maintenance_utils import check_maintenance_mode
@@ -20,22 +29,32 @@ from cinchdb.utils.name_validator import validate_name
20
29
  from cinchdb.infrastructure.metadata_db import MetadataDB
21
30
  from cinchdb.infrastructure.metadata_connection_pool import get_metadata_db
22
31
 
32
+ logger = logging.getLogger(__name__)
33
+
23
34
 
24
35
  class TenantManager:
25
36
  """Manages tenants within a branch."""
26
37
 
27
- def __init__(self, project_root: Path, database: str, branch: str):
38
+ def __init__(self, project_root: Path, database: str, branch: str, encryption_manager=None):
28
39
  """Initialize tenant manager.
29
40
 
30
41
  Args:
31
42
  project_root: Path to project root
32
43
  database: Database name
33
44
  branch: Branch name
45
+ encryption_manager: EncryptionManager instance for encrypted connections
34
46
  """
35
47
  self.project_root = Path(project_root)
36
48
  self.database = database
37
49
  self.branch = branch
50
+ self.encryption_manager = encryption_manager
51
+
52
+ # New tenant-first approach: use context root
53
+ self.context_root = get_context_root(self.project_root, database, branch)
54
+
55
+ # Legacy support: keep branch_path for backward compatibility
38
56
  self.branch_path = get_branch_path(self.project_root, database, branch)
57
+
39
58
  self._empty_tenant_name = "__empty__" # Reserved name for lazy tenant template
40
59
 
41
60
  # Lazy-initialized pooled connection
@@ -98,7 +117,8 @@ class TenantManager:
98
117
  return tenants
99
118
 
100
119
  def create_tenant(
101
- self, tenant_name: str, description: Optional[str] = None, lazy: bool = True
120
+ self, tenant_name: str, description: Optional[str] = None, lazy: bool = True,
121
+ encrypt: bool = False, encryption_key: Optional[str] = None
102
122
  ) -> Tenant:
103
123
  """Create a new tenant by copying schema from main tenant.
104
124
 
@@ -106,6 +126,8 @@ class TenantManager:
106
126
  tenant_name: Name for the new tenant
107
127
  description: Optional description
108
128
  lazy: If True, don't create database file until first use
129
+ encrypt: If True, create encrypted tenant database
130
+ encryption_key: Encryption key for encrypted tenant (required if encrypt=True)
109
131
 
110
132
  Returns:
111
133
  Created Tenant object
@@ -115,6 +137,12 @@ class TenantManager:
115
137
  InvalidNameError: If tenant name is invalid
116
138
  MaintenanceError: If branch is in maintenance mode
117
139
  """
140
+ # Validate encryption parameters
141
+ if encrypt and not encryption_key:
142
+ raise ValueError("encryption_key is required when encrypt=True")
143
+ if encryption_key and not encrypt:
144
+ raise ValueError("encrypt=True is required when providing encryption_key")
145
+
118
146
  # Check for reserved name
119
147
  if tenant_name == self._empty_tenant_name:
120
148
  raise ValueError(f"'{self._empty_tenant_name}' is a reserved tenant name")
@@ -140,28 +168,37 @@ class TenantManager:
140
168
  tenant_id = str(uuid.uuid4())
141
169
 
142
170
  # Calculate shard for tenant
143
- shard = self._calculate_shard(tenant_name)
171
+ shard = calculate_shard(tenant_name)
144
172
 
145
173
  # Create tenant in metadata database
146
174
  metadata = {
147
175
  "description": description,
148
176
  "created_at": datetime.now(timezone.utc).isoformat(),
177
+ "encrypted": encrypt,
149
178
  }
150
179
  self.metadata_db.create_tenant(tenant_id, self.branch_id, tenant_name, shard, metadata)
151
180
 
181
+ # Generate encryption key if plugged is available and encryption is enabled
182
+ self._maybe_generate_tenant_key(tenant_name)
183
+
152
184
  if not lazy:
153
- # Ensure __empty__ tenant exists with current schema
154
- self._ensure_empty_tenant()
155
-
156
185
  # Create actual database file using sharded paths
157
- new_db_path = self._get_sharded_tenant_db_path(tenant_name)
158
- empty_db_path = self._get_sharded_tenant_db_path(self._empty_tenant_name)
159
-
160
- # Directory creation is handled by _get_sharded_tenant_db_path
186
+ # Use ensure_tenant_db_path to create directories if needed
187
+ new_db_path = ensure_tenant_db_path(
188
+ self.project_root, self.database, self.branch, tenant_name
189
+ )
161
190
 
162
- # Copy __empty__ tenant database to new tenant
163
- # __empty__ already has 512-byte pages and no data
164
- shutil.copy2(empty_db_path, new_db_path)
191
+ if encrypt:
192
+ # Create encrypted database with schema from main tenant
193
+ self._create_encrypted_tenant_database(new_db_path, encryption_key)
194
+ else:
195
+ # Ensure __empty__ tenant exists with current schema
196
+ self._ensure_empty_tenant()
197
+
198
+ # Copy __empty__ tenant database to new tenant
199
+ # __empty__ already has 512-byte pages and no data
200
+ empty_db_path = self._get_sharded_tenant_db_path(self._empty_tenant_name)
201
+ shutil.copy2(empty_db_path, new_db_path)
165
202
 
166
203
  # Mark as materialized in metadata
167
204
  self.metadata_db.mark_tenant_materialized(tenant_id)
@@ -174,49 +211,35 @@ class TenantManager:
174
211
  is_main=False,
175
212
  )
176
213
 
177
- def _calculate_shard(self, tenant_name: str) -> str:
178
- """Calculate the shard directory for a tenant using SHA256 hash.
214
+
215
+ def _get_tenant_db_path(self, tenant_name: str) -> Path:
216
+ """Get the database path for a tenant using new tenant-first approach.
179
217
 
180
218
  Args:
181
219
  tenant_name: Name of the tenant
182
220
 
183
221
  Returns:
184
- Two-character hex string (e.g., "a0", "ff")
222
+ Path to the tenant database file in the context
185
223
  """
186
- hash_val = hashlib.sha256(tenant_name.encode('utf-8')).hexdigest()
187
- return hash_val[:2]
224
+ # Ensure context directory exists
225
+ ensure_context_directory(self.project_root, self.database, self.branch)
226
+
227
+ # Use new tenant-first path resolution
228
+ return get_tenant_db_path_in_context(self.context_root, tenant_name)
188
229
 
189
230
  def _get_sharded_tenant_db_path(self, tenant_name: str) -> Path:
190
- """Get the sharded database path for a tenant using metadata DB lookup.
231
+ """Get the sharded database path for a tenant (legacy compatibility).
232
+
233
+ This method is kept for backward compatibility but now uses the new
234
+ tenant-first storage approach internally.
191
235
 
192
236
  Args:
193
237
  tenant_name: Name of the tenant
194
238
 
195
239
  Returns:
196
240
  Path to the tenant database file in its shard directory
197
-
198
- Raises:
199
- ValueError: If tenant doesn't exist in metadata
200
241
  """
201
- # For __empty__ tenant, calculate shard directly
202
- if tenant_name == self._empty_tenant_name:
203
- shard = self._calculate_shard(tenant_name)
204
- else:
205
- # Look up shard from metadata DB
206
- tenant_info = self.metadata_db.get_tenant(self.branch_id, tenant_name)
207
- if not tenant_info or not tenant_info.get('shard'):
208
- raise ValueError(f"Tenant '{tenant_name}' not found in metadata or missing shard info")
209
- shard = tenant_info['shard']
210
-
211
- # Build sharded path
212
- branch_path = get_branch_path(self.project_root, self.database, self.branch)
213
- tenants_dir = branch_path / "tenants"
214
- shard_dir = tenants_dir / shard
215
-
216
- # Ensure shard directory exists
217
- shard_dir.mkdir(parents=True, exist_ok=True)
218
-
219
- return shard_dir / f"{tenant_name}.db"
242
+ return self._get_tenant_db_path(tenant_name)
220
243
 
221
244
  def _ensure_empty_tenant(self) -> None:
222
245
  """Ensure the __empty__ tenant exists with current schema.
@@ -238,7 +261,7 @@ class TenantManager:
238
261
  # Create in metadata if doesn't exist (should already be created during branch/database init)
239
262
  if not empty_tenant:
240
263
  tenant_id = str(uuid.uuid4())
241
- shard = self._calculate_shard(self._empty_tenant_name)
264
+ shard = calculate_shard(self._empty_tenant_name)
242
265
  self.metadata_db.create_tenant(
243
266
  tenant_id, self.branch_id, self._empty_tenant_name, shard,
244
267
  metadata={"system": True, "description": "Template for lazy tenants"}
@@ -248,7 +271,10 @@ class TenantManager:
248
271
 
249
272
  # If __empty__ database doesn't exist, create it by copying from main tenant
250
273
  if not empty_db_path.exists():
251
- empty_db_path.parent.mkdir(parents=True, exist_ok=True)
274
+ # Use centralized function to ensure directory exists
275
+ ensure_tenant_db_path(
276
+ self.project_root, self.database, self.branch, "__empty__"
277
+ )
252
278
 
253
279
  # Get main tenant database path (may need to materialize it first)
254
280
  main_db_path = self._get_sharded_tenant_db_path("main")
@@ -258,7 +284,7 @@ class TenantManager:
258
284
  shutil.copy2(main_db_path, empty_db_path)
259
285
 
260
286
  # Clear all data from tables (keep schema only)
261
- with DatabaseConnection(empty_db_path) as conn:
287
+ with DatabaseConnection(empty_db_path, encryption_manager=self.encryption_manager) as conn:
262
288
  # Get all tables
263
289
  result = conn.execute("""
264
290
  SELECT name FROM sqlite_master
@@ -275,7 +301,7 @@ class TenantManager:
275
301
  else:
276
302
  # If main doesn't exist either, create empty database
277
303
  empty_db_path.touch()
278
- with DatabaseConnection(empty_db_path):
304
+ with DatabaseConnection(empty_db_path, encryption_manager=self.encryption_manager):
279
305
  pass # Just initialize with PRAGMAs
280
306
 
281
307
  # Set reasonable default page size for template
@@ -304,16 +330,20 @@ class TenantManager:
304
330
 
305
331
  Raises:
306
332
  ValueError: If tenant doesn't exist, is main tenant, or is reserved
333
+ InvalidNameError: If tenant name is invalid
307
334
  MaintenanceError: If branch is in maintenance mode
308
335
  """
309
- # Check maintenance mode
310
- check_maintenance_mode(self.project_root, self.database, self.branch)
311
-
312
- # Can't delete main or __empty__ tenants
336
+ # Can't delete main or __empty__ tenants (check before validation)
313
337
  if tenant_name == "main":
314
338
  raise ValueError("Cannot delete the main tenant")
315
339
  if tenant_name == self._empty_tenant_name:
316
340
  raise ValueError(f"Cannot delete the reserved '{self._empty_tenant_name}' tenant")
341
+
342
+ # Validate tenant name for security
343
+ validate_name(tenant_name, "tenant")
344
+
345
+ # Check maintenance mode
346
+ check_maintenance_mode(self.project_root, self.database, self.branch)
317
347
 
318
348
  # Ensure initialization
319
349
  self._ensure_initialized()
@@ -349,6 +379,9 @@ class TenantManager:
349
379
  wal_path.unlink()
350
380
  if shm_path.exists():
351
381
  shm_path.unlink()
382
+
383
+ # Invalidate cache for this tenant
384
+ invalidate_cache(tenant=tenant_name)
352
385
 
353
386
 
354
387
  def materialize_tenant(self, tenant_name: str) -> None:
@@ -359,7 +392,11 @@ class TenantManager:
359
392
 
360
393
  Raises:
361
394
  ValueError: If tenant doesn't exist or is already materialized
395
+ InvalidNameError: If tenant name is invalid
362
396
  """
397
+ # Validate tenant name first for security
398
+ validate_name(tenant_name, "tenant")
399
+
363
400
  # Ensure initialization
364
401
  self._ensure_initialized()
365
402
 
@@ -375,17 +412,15 @@ class TenantManager:
375
412
  if tenant_info['materialized']:
376
413
  return # Already materialized
377
414
 
378
- db_path = get_tenant_db_path(
415
+ # Use centralized function to get path and ensure directory exists
416
+ db_path = ensure_tenant_db_path(
379
417
  self.project_root, self.database, self.branch, tenant_name
380
418
  )
381
419
 
382
- # Ensure tenants directory exists
383
- db_path.parent.mkdir(parents=True, exist_ok=True)
384
-
385
420
  # Ensure __empty__ tenant exists with current schema
386
421
  self._ensure_empty_tenant()
387
422
 
388
- # Get __empty__ tenant path for schema copy
423
+ # Get __empty__ tenant path for schema copy using new structure
389
424
  empty_db_path = get_tenant_db_path(
390
425
  self.project_root, self.database, self.branch, self._empty_tenant_name
391
426
  )
@@ -399,6 +434,7 @@ class TenantManager:
399
434
  # Mark as materialized in metadata database
400
435
  self.metadata_db.mark_tenant_materialized(tenant_info['id'])
401
436
 
437
+
402
438
  def copy_tenant(self, source_tenant: str, target_tenant: str) -> Tenant:
403
439
  """Copy a tenant to a new tenant.
404
440
 
@@ -438,7 +474,7 @@ class TenantManager:
438
474
 
439
475
  # Create tenant in metadata database first with shard
440
476
  tenant_id = str(uuid.uuid4())
441
- target_shard = self._calculate_shard(target_tenant)
477
+ target_shard = calculate_shard(target_tenant)
442
478
  metadata = {
443
479
  "description": f"Copied from {source_tenant}",
444
480
  "created_at": datetime.now(timezone.utc).isoformat(),
@@ -447,9 +483,10 @@ class TenantManager:
447
483
 
448
484
  # Get paths using sharded approach
449
485
  source_path = self._get_sharded_tenant_db_path(source_tenant)
450
- target_path = self._get_sharded_tenant_db_path(target_tenant)
451
-
452
- # Directory creation is handled by _get_sharded_tenant_db_path
486
+ # Use ensure function to create directory if needed
487
+ target_path = ensure_tenant_db_path(
488
+ self.project_root, self.database, self.branch, target_tenant
489
+ )
453
490
 
454
491
  # Copy database file
455
492
  shutil.copy2(source_path, target_path)
@@ -473,9 +510,10 @@ class TenantManager:
473
510
 
474
511
  Raises:
475
512
  ValueError: If old doesn't exist, new already exists, or trying to rename main
476
- InvalidNameError: If new tenant name is invalid
513
+ InvalidNameError: If either tenant name is invalid
477
514
  """
478
- # Validate new tenant name
515
+ # Validate both names for security (prevent path traversal)
516
+ validate_name(old_name, "tenant")
479
517
  validate_name(new_name, "tenant")
480
518
 
481
519
  # Can't rename main tenant
@@ -507,18 +545,18 @@ class TenantManager:
507
545
  if tenant_info['materialized']:
508
546
  # Calculate paths before metadata update
509
547
  old_shard = tenant_info['shard']
510
- new_shard = self._calculate_shard(new_name)
548
+ new_shard = calculate_shard(new_name)
511
549
 
512
- branch_path = get_branch_path(self.project_root, self.database, self.branch)
513
- tenants_dir = branch_path / "tenants"
550
+ context_root = get_context_root(self.project_root, self.database, self.branch)
514
551
 
515
- old_path = tenants_dir / old_shard / f"{old_name}.db"
516
- new_shard_dir = tenants_dir / new_shard
517
- new_shard_dir.mkdir(parents=True, exist_ok=True)
518
- new_path = new_shard_dir / f"{new_name}.db"
552
+ old_path = context_root / old_shard / f"{old_name}.db"
553
+ # Use centralized function to ensure new directory exists
554
+ new_path = ensure_tenant_db_path(
555
+ self.project_root, self.database, self.branch, new_name
556
+ )
519
557
 
520
558
  # Update metadata database
521
- new_shard = self._calculate_shard(new_name)
559
+ new_shard = calculate_shard(new_name)
522
560
  with self.metadata_db.conn:
523
561
  self.metadata_db.conn.execute(
524
562
  "UPDATE tenants SET name = ?, shard = ? WHERE id = ?",
@@ -530,7 +568,7 @@ class TenantManager:
530
568
 
531
569
  # Rename database file if it exists
532
570
  if old_path.exists():
533
- new_path.parent.mkdir(parents=True, exist_ok=True)
571
+ # Directory already created by ensure_tenant_db_path above
534
572
  old_path.rename(new_path)
535
573
 
536
574
  # Also rename WAL and SHM files if they exist
@@ -747,13 +785,19 @@ class TenantManager:
747
785
 
748
786
  Raises:
749
787
  ValueError: If tenant doesn't exist
788
+ InvalidNameError: If tenant name is invalid
750
789
 
751
790
  Example:
752
791
  with tenant_manager.get_tenant_connection("main") as conn:
753
792
  conn.execute("SELECT * FROM table")
754
793
  """
794
+ # Validate tenant name first for security (prevent path traversal)
795
+ # Exception: __empty__ and main are system tenants
796
+ if tenant_name not in ("__empty__", "main"):
797
+ validate_name(tenant_name, "tenant")
798
+
755
799
  db_path = self.get_tenant_db_path_for_operation(tenant_name, is_write)
756
- return DatabaseConnection(db_path)
800
+ return DatabaseConnection(db_path, tenant_id=tenant_name, encryption_manager=self.encryption_manager)
757
801
 
758
802
  def vacuum_tenant(self, tenant_name: str) -> dict:
759
803
  """Run VACUUM operation on a specific tenant to reclaim space and optimize performance.
@@ -812,7 +856,7 @@ class TenantManager:
812
856
  error_message = None
813
857
 
814
858
  try:
815
- with DatabaseConnection(db_path) as conn:
859
+ with DatabaseConnection(db_path, tenant_id=tenant_name, encryption_manager=self.encryption_manager) as conn:
816
860
  # Run VACUUM command
817
861
  conn.execute("VACUUM")
818
862
  success = True
@@ -839,3 +883,115 @@ class TenantManager:
839
883
  result["error"] = error_message
840
884
 
841
885
  return result
886
+
887
+ def _maybe_generate_tenant_key(self, tenant_name: str) -> None:
888
+ """Generate encryption key for tenant if plugged is available and encryption enabled."""
889
+ try:
890
+ # Try to import plugged TenantKeyManager
891
+ from plugged.tenant_key_manager import TenantKeyManager
892
+
893
+ # Create tenant ID for plugged (using same format as cinchdb)
894
+ tenant_id = f"{self.database}-{self.branch}-{tenant_name}"
895
+
896
+ # Initialize key manager with our metadata database
897
+ key_manager = TenantKeyManager(self.metadata_db)
898
+
899
+ # Generate encryption key for the new tenant
900
+ encryption_key = key_manager.generate_tenant_key(tenant_id)
901
+
902
+ logger.info(f"Generated encryption key for tenant {tenant_name} (version 1)")
903
+
904
+ except ImportError:
905
+ # Plugged not available - continue without encryption
906
+ logger.debug(f"Plugged not available, skipping key generation for tenant {tenant_name}")
907
+ except Exception as e:
908
+ # Key generation failed - log warning but don't fail tenant creation
909
+ logger.warning(f"Failed to generate encryption key for tenant {tenant_name}: {e}")
910
+
911
+ def rotate_tenant_key(self, tenant_name: str) -> str:
912
+ """Rotate encryption key for tenant if plugged is available."""
913
+ try:
914
+ from plugged.tenant_key_manager import TenantKeyManager
915
+
916
+ tenant_id = f"{self.database}-{self.branch}-{tenant_name}"
917
+ key_manager = TenantKeyManager(self.metadata_db)
918
+
919
+ # Generate new key version
920
+ new_key = key_manager.generate_tenant_key(tenant_id)
921
+
922
+ logger.info(f"Rotated encryption key for tenant {tenant_name}")
923
+ return new_key
924
+
925
+ except ImportError:
926
+ raise ValueError("Plugged extension not available - encryption key rotation requires plugged")
927
+ except Exception as e:
928
+ raise ValueError(f"Failed to rotate key for tenant {tenant_name}: {e}")
929
+
930
+ def _create_encrypted_tenant_database(self, db_path: str, encryption_key: str) -> None:
931
+ """Create an encrypted tenant database with current schema.
932
+
933
+ Args:
934
+ db_path: Path where the encrypted database should be created
935
+ encryption_key: Encryption key for the database
936
+ """
937
+ try:
938
+ # Import sqlite3 to check for SQLCipher support
939
+ import sqlite3
940
+
941
+ # Create encrypted database connection
942
+ conn = sqlite3.connect(db_path)
943
+
944
+ # Try to set encryption key (this will fail if SQLCipher is not available)
945
+ try:
946
+ conn.execute(f"PRAGMA key = '{encryption_key}'")
947
+ except sqlite3.OperationalError as e:
948
+ conn.close()
949
+ # Remove the created file since it's not encrypted
950
+ if os.path.exists(db_path):
951
+ os.remove(db_path)
952
+ raise ValueError(
953
+ "SQLCipher is required for encryption but not available. "
954
+ "Please install pysqlcipher3 or sqlite3 with SQLCipher support."
955
+ ) from e
956
+
957
+ # Set SQLite optimization settings for new database
958
+ conn.execute("PRAGMA page_size = 512")
959
+ conn.execute("PRAGMA journal_mode = WAL")
960
+
961
+ # Get schema from main tenant to replicate
962
+ main_db_path = self._get_sharded_tenant_db_path("main")
963
+ if os.path.exists(main_db_path):
964
+ # Connect to main tenant to get schema
965
+ main_conn = sqlite3.connect(main_db_path)
966
+
967
+ # Get all table creation statements
968
+ cursor = main_conn.execute(
969
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
970
+ )
971
+
972
+ # Create tables in encrypted database
973
+ for (sql,) in cursor.fetchall():
974
+ if sql: # Skip empty SQL statements
975
+ conn.execute(sql)
976
+
977
+ # Get all index creation statements
978
+ cursor = main_conn.execute(
979
+ "SELECT sql FROM sqlite_master WHERE type='index' AND name NOT LIKE 'sqlite_%'"
980
+ )
981
+
982
+ # Create indexes in encrypted database
983
+ for (sql,) in cursor.fetchall():
984
+ if sql: # Skip empty SQL statements
985
+ conn.execute(sql)
986
+
987
+ main_conn.close()
988
+
989
+ # Commit changes and close
990
+ conn.commit()
991
+ conn.close()
992
+
993
+ except Exception as e:
994
+ # Clean up on failure
995
+ if os.path.exists(db_path):
996
+ os.remove(db_path)
997
+ raise ValueError(f"Failed to create encrypted database: {e}") from e
@@ -7,8 +7,9 @@ and follow consistent naming conventions.
7
7
  import re
8
8
 
9
9
 
10
- # Regex pattern for valid names: lowercase letters, numbers, dash, underscore, period
11
- VALID_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-_\.]*[a-z0-9]$|^[a-z0-9]$")
10
+ # Regex pattern for valid names: lowercase letters, numbers, dash, underscore
11
+ # Period removed to prevent directory traversal attempts like "../"
12
+ VALID_NAME_PATTERN = re.compile(r"^[a-z0-9][a-z0-9\-_]*[a-z0-9]$|^[a-z0-9]$")
12
13
 
13
14
  # Reserved names that cannot be used
14
15
  RESERVED_NAMES = {
@@ -52,6 +53,7 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
52
53
  - Be at least 1 character long
53
54
  - Not exceed 255 characters (filesystem limit)
54
55
  - Not be a reserved name
56
+ - Not contain path traversal sequences
55
57
 
56
58
  Args:
57
59
  name: The name to validate
@@ -67,6 +69,19 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
67
69
  raise InvalidNameError(
68
70
  f"{entity_type.capitalize()} name cannot exceed 255 characters"
69
71
  )
72
+
73
+ # Critical: Check for path traversal attempts
74
+ if ".." in name or "/" in name or "\\" in name or "~" in name:
75
+ raise InvalidNameError(
76
+ f"Security violation: {entity_type} name '{name}' contains "
77
+ f"forbidden path traversal characters"
78
+ )
79
+
80
+ # Check for null bytes and other control characters
81
+ if "\x00" in name or any(ord(c) < 32 for c in name):
82
+ raise InvalidNameError(
83
+ f"Security violation: {entity_type} name contains invalid control characters"
84
+ )
70
85
 
71
86
  # Check for lowercase requirement
72
87
  if name != name.lower():
@@ -80,7 +95,7 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
80
95
  raise InvalidNameError(
81
96
  f"Invalid {entity_type} name '{name}'. "
82
97
  f"Names must contain only lowercase letters (a-z), numbers (0-9), "
83
- f"dash (-), underscore (_), and period (.). "
98
+ f"dash (-), and underscore (_). "
84
99
  f"Names must start and end with alphanumeric characters."
85
100
  )
86
101
 
@@ -90,11 +105,6 @@ def validate_name(name: str, entity_type: str = "entity") -> None:
90
105
  or "__" in name
91
106
  or "-_" in name
92
107
  or "_-" in name
93
- or ".." in name
94
- or ".-" in name
95
- or "-." in name
96
- or "._" in name
97
- or "_." in name
98
108
  ):
99
109
  raise InvalidNameError(
100
110
  f"Invalid {entity_type} name '{name}'. "
@@ -132,14 +142,14 @@ def clean_name(name: str) -> str:
132
142
  # Replace spaces with dashes
133
143
  cleaned = cleaned.replace(" ", "-")
134
144
 
135
- # Remove invalid characters
136
- cleaned = re.sub(r"[^a-z0-9\-_\.]", "", cleaned)
145
+ # Remove invalid characters (period no longer allowed)
146
+ cleaned = re.sub(r"[^a-z0-9\-_]", "", cleaned)
137
147
 
138
148
  # Remove consecutive special characters
139
- cleaned = re.sub(r"[-_\.]{2,}", "-", cleaned)
149
+ cleaned = re.sub(r"[-_]{2,}", "-", cleaned)
140
150
 
141
151
  # Remove leading/trailing special characters
142
- cleaned = cleaned.strip("-_.")
152
+ cleaned = cleaned.strip("-_")
143
153
 
144
154
  return cleaned
145
155
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cinchdb
3
- Version: 0.1.17
3
+ Version: 0.1.18
4
4
  Summary: A Git-like SQLite database management system with branching and multi-tenancy
5
5
  Project-URL: Homepage, https://github.com/russellromney/cinchdb
6
6
  Project-URL: Documentation, https://russellromney.github.io/cinchdb
@@ -18,6 +18,7 @@ Classifier: Programming Language :: Python :: 3.10
18
18
  Classifier: Programming Language :: Python :: 3.11
19
19
  Classifier: Programming Language :: Python :: 3.12
20
20
  Requires-Python: >=3.10
21
+ Requires-Dist: httpx>=0.25.0
21
22
  Requires-Dist: pydantic>=2.0.0
22
23
  Requires-Dist: requests>=2.28.0
23
24
  Requires-Dist: rich>=13.0.0
@@ -68,6 +69,10 @@ cinch branch merge-into-main feature
68
69
  cinch tenant create customer_a
69
70
  cinch query "SELECT * FROM users" --tenant customer_a
70
71
 
72
+ # Tenant encryption (bring your own keys)
73
+ cinch tenant create secure_customer --encrypt --key="your-secret-key"
74
+ cinch query "SELECT * FROM users" --tenant secure_customer --key="your-secret-key"
75
+
71
76
  # Future: Remote connectivity planned for production deployment
72
77
 
73
78
  # Autogenerate Python SDK from database
@@ -157,6 +162,35 @@ db.update("posts", post_id, {"content": "Updated content"})
157
162
 
158
163
  ## Architecture
159
164
 
165
+ ### Storage Architecture
166
+
167
+ CinchDB uses a **tenant-first storage model** where database and branch are organizational metadata concepts, while tenants represent the actual isolated data stores:
168
+
169
+ ```
170
+ .cinchdb/
171
+ ├── metadata.db # Organizational metadata
172
+ └── {database}-{branch}/ # Context root (e.g., main-main, prod-feature)
173
+ ├── {shard}/ # SHA256-based sharding (first 2 chars)
174
+ │ ├── {tenant}.db # Actual SQLite database
175
+ │ └── {tenant}.db-wal # WAL file
176
+ └── ...
177
+ ```
178
+
179
+ **Key Design Decisions:**
180
+ - **Tenant-first**: Each tenant gets its own SQLite database file
181
+ - **Flat hierarchy**: Database/branch form a single context root, avoiding deep nesting
182
+ - **Hash sharding**: Tenants are distributed across 256 shards using SHA256 for scalability
183
+ - **Lazy initialization**: Tenant databases are created on first access, not on tenant creation
184
+ - **WAL mode**: All databases use Write-Ahead Logging for better concurrency
185
+
186
+ This architecture enables:
187
+ - True multi-tenant isolation at the file system level
188
+ - Efficient branching without duplicating tenant data
189
+ - Simple backup/restore per tenant
190
+ - Horizontal scaling through sharding
191
+
192
+ ### Components
193
+
160
194
  - **Python SDK**: Core functionality for local development
161
195
  - **CLI**: Full-featured command-line interface
162
196