Bug fixes: composite PK, nl_to_sql sandbox, FK check, SQL injection, storage correctness

Critical fixes:
- Composite PK: execInsert + validateConstraints use all PK columns
- nl_to_sql: non-SELECT SQL no longer executed directly during validation
- FK check: removed O(N) scanMemTable fallback, uses db.get() with SSTables
- exprToSql: nkIdent wrapped in quotes to prevent SQL injection
- restoreSchema: try/except around tokenize/parse for crash resilience
- recovery.nim: lastTxnId tracking + putUnsafe/deleteUnsafe without WAL
- SCRAM: verifyClientProof length check + DefaultIterationCount restored

Storage fixes:
- lsm.nim: SSTable sort order fixed (ascending), close() flushes all memtables
- compaction.nim: tombstones preserved during compaction
- wal.nim: header written for empty existing files, readEntries checks magic
- btree.nim: B+ tree leaf split keeps boundary key
- bloom.nim: deserialize raises on short data
- mmap.nim: bounds checks for adviseWillNeed/DontNeed + posix.close()

Protocol fixes:
- zerocopy.nim: readString bounds check
- wire.nim: deserializeValue 32-bit underflow check
- auth.nim: JWT JSON escaping for claims
- server.nim: readUint32BE bounds check + specific exception handling
- raft.nim: readData checks + specific exception handling

Tests:
- Added Composite Primary Key test suite (4 tests)

Build: 0 warnings, 0 errors
This commit is contained in:
2026-05-18 11:33:11 +03:00
parent a28c845476
commit 967c0855a5
25 changed files with 376 additions and 171 deletions
+107 -61
View File
@@ -185,6 +185,14 @@ proc newExecutionContext*(db: LSMTree): ExecutionContext =
# AST to SQL serializer (for VIEW DDL persistence)
# ----------------------------------------------------------------------
proc sqlEscapeIdent*(ident: string): string =
## Escape SQL identifiers by doubling double-quotes.
result = ident.replace("\"", "\"\"")
proc sqlEscapeString*(s: string): string =
## Escape SQL string literals by doubling single-quotes.
result = s.replace("'", "''")
proc exprToSql(node: Node): string =
if node == nil:
return ""
@@ -200,7 +208,7 @@ proc exprToSql(node: Node): string =
of nkNullLit:
return "null"
of nkIdent:
return node.identName
return "\"" & node.identName.replace("\"", "\"\"") & "\""
of nkStar:
return "*"
of nkBinOp:
@@ -297,8 +305,13 @@ proc restoreSchema(ctx: ExecutionContext) =
if not entry.key.startsWith("_schema:"): continue
let ddl = cast[string](entry.value)
if ddl.len == 0: continue
let tokens = qlex.tokenize(ddl)
let astNode = qpar.parse(tokens)
var astNode: Node
try:
let tokens = qlex.tokenize(ddl)
astNode = qpar.parse(tokens)
except:
# Skip corrupted schema entries during startup
continue
if astNode.stmts.len > 0:
let stmt = astNode.stmts[0]
case stmt.kind
@@ -992,9 +1005,15 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
if expr.binRight.kind == irekSubquery:
let subRows = executePlan(ctx, expr.binRight.subqueryPlan)
for row in subRows:
# Compare against the first non-internal column only (SQL semantics)
var firstVal = ""
var found = false
for k, v in row:
if k.startsWith("$"): continue
if v == left: return "true"
firstVal = v
found = true
break
if found and firstVal == left: return "true"
return "false"
try:
let lv = parseFloat(left)
@@ -1006,9 +1025,15 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
if expr.binRight.kind == irekSubquery:
let subRows = executePlan(ctx, expr.binRight.subqueryPlan)
for row in subRows:
# Compare against the first non-internal column only (SQL semantics)
var firstVal = ""
var found = false
for k, v in row:
if k.startsWith("$"): continue
if v == left: return "false"
firstVal = v
found = true
break
if found and firstVal == left: return "false"
return "true"
try:
let lv = parseFloat(left)
@@ -1334,23 +1359,43 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
if sql.len == 0:
return ""
# Validate by trying EXPLAIN or LIMIT-wrapped query
var validateSql = sql
if validateSql.toLower().startsWith("select"):
validateSql = "SELECT * FROM (" & sql & ") LIMIT 0"
let tokens = qlex.tokenize(validateSql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len > 0:
let validateRes = executeQuery(ctx, astNode)
if not validateRes.success:
# Self-correction: send error back to LLM
let correctionPrompt = "Schema:\n" & schemaInfo & "\nQuestion: " & question & "\n\nPrevious SQL: " & sql & "\n\nError: " & validateRes.message & "\n\nGenerate corrected SQL:"
# Validate by parsing only (never execute LLM-generated SQL directly)
let sqlLower = sql.toLower().strip()
let isSafeQuery = sqlLower.startsWith("select") or sqlLower.startsWith("explain") or
sqlLower.startsWith("with")
let allowDml = ctx.sessionVars.getOrDefault("nl_to_sql.allow_dml", "false") == "true"
if not isSafeQuery and not allowDml:
# For non-SELECT: only do syntax validation via tokenize+parse, no execution
let tokens = qlex.tokenize(sql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len > 0:
return sql
else:
let correctionPrompt = "Schema:\n" & schemaInfo & "\nQuestion: " & question & "\n\nPrevious SQL: " & sql & "\n\nError: Syntax error. Generate corrected SQL:"
var correctedResponse = llmmod.generate(ctx.llmClient, correctionPrompt, systemPrompt)
var correctedSql = llmmod.extractSQL(correctedResponse)
if correctedSql.len > 0:
return correctedSql
return sql
return ""
else:
# For SELECT/EXPLAIN or when ALLOW_DML is set: validate with LIMIT 0 or EXPLAIN
var validateSql = sql
if sqlLower.startsWith("select"):
validateSql = "SELECT * FROM (" & sql & ") LIMIT 0"
elif sqlLower.startsWith("with"):
validateSql = "WITH _cte_ AS (" & sql & ") SELECT 1 LIMIT 0"
let tokens = qlex.tokenize(validateSql)
let astNode = qpar.parse(tokens)
if astNode.stmts.len > 0:
let validateRes = executeQuery(ctx, astNode)
if not validateRes.success:
# Self-correction: send error back to LLM
let correctionPrompt = "Schema:\n" & schemaInfo & "\nQuestion: " & question & "\n\nPrevious SQL: " & sql & "\n\nError: " & validateRes.message & "\n\nGenerate corrected SQL:"
var correctedResponse = llmmod.generate(ctx.llmClient, correctionPrompt, systemPrompt)
var correctedSql = llmmod.extractSQL(correctedResponse)
if correctedSql.len > 0:
return correctedSql
return sql
of "schema_prompt":
if expr.irFuncArgs.len < 1: return ""
let table = evalExpr(expr.irFuncArgs[0], row, ctx)
@@ -1358,33 +1403,33 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
return "Table '" & table & "' not found"
let tbl = ctx.tables[table]
var result = ""
result.add("CREATE TABLE " & table & " (\n")
var ddl = ""
ddl.add("CREATE TABLE " & table & " (\n")
for i, col in tbl.columns:
result.add(" " & col.name & " " & col.colType)
if col.isPk: result.add(" PRIMARY KEY")
if col.isNotNull: result.add(" NOT NULL")
if col.autoIncrement: result.add(" AUTO_INCREMENT")
ddl.add(" " & col.name & " " & col.colType)
if col.isPk: ddl.add(" PRIMARY KEY")
if col.isNotNull: ddl.add(" NOT NULL")
if col.autoIncrement: ddl.add(" AUTO_INCREMENT")
if col.fkTable.len > 0:
result.add(" REFERENCES " & col.fkTable & "(" & col.fkColumn & ")")
if i < tbl.columns.len - 1: result.add(",")
result.add("\n")
ddl.add(" REFERENCES " & col.fkTable & "(" & col.fkColumn & ")")
if i < tbl.columns.len - 1: ddl.add(",")
ddl.add("\n")
# Sample data
var kvPairs: seq[(string, seq[byte])] = @[]
let rows = execScan(ctx, table)
let sampleLimit = min(5, rows.len)
if sampleLimit > 0:
result.add(");\n\n-- Sample data:\n")
ddl.add(");\n\n-- Sample data:\n")
for i in 0..<sampleLimit:
result.add("-- ")
ddl.add("-- ")
var parts: seq[string] = @[]
for col in tbl.columns:
parts.add(col.name & "=" & rows[i].getOrDefault(col.name, ""))
result.add(parts.join(", "))
result.add("\n")
ddl.add(parts.join(", "))
ddl.add("\n")
else:
result.add(");")
ddl.add(");")
# Indexes
var idxList: seq[string] = @[]
@@ -1395,20 +1440,20 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
if idxKey.startsWith(table & "."):
idxList.add("HNSW: " & idxKey)
if idxList.len > 0:
result.add("\n-- Indexes: " & idxList.join(", "))
ddl.add("\n-- Indexes: " & idxList.join(", "))
# RLS policies
if table in ctx.policies and ctx.policies[table].len > 0:
result.add("\n-- RLS Policies:\n")
ddl.add("\n-- RLS Policies:\n")
for pol in ctx.policies[table]:
result.add("-- CREATE POLICY " & pol.name & " FOR " & pol.command & "\n")
ddl.add("-- CREATE POLICY " & pol.name & " FOR " & pol.command & "\n")
if tbl.foreignKeys.len > 0:
result.add("\n-- Foreign Keys:\n")
ddl.add("\n-- Foreign Keys:\n")
for fk in tbl.foreignKeys:
result.add("-- " & fk.refTable & "(" & fk.refColumn & ") ON DELETE " & fk.onDelete & "\n")
ddl.add("-- " & fk.refTable & "(" & fk.refColumn & ") ON DELETE " & fk.onDelete & "\n")
return result
return ddl
of "similarity_nodes":
if expr.irFuncArgs.len < 1: return "[]"
let graphName = evalExpr(expr.irFuncArgs[0], row, ctx)
@@ -1508,7 +1553,6 @@ proc evalExpr*(expr: IRExpr, row: Table[string, string], ctx: ExecutionContext =
finally:
release(ctx.sharedLock.lock)
return "0"
return "0"
of "snowflake_id":
# Snowflake ID: timestamp_ms(41 bits) | node_id(10 bits) | sequence(12 bits)
var nodeId: int64 = 0
@@ -1662,20 +1706,30 @@ proc execInsert*(ctx: ExecutionContext, table: string, fields: seq[string], valu
kvPairs: var seq[(string, seq[byte])]): int =
if not hasPrivilege(ctx, table, "INSERT"):
return 0
let tblDef = if table in ctx.tables: ctx.tables[table] else: TableDef()
var count = 0
for rowVals in values:
var key = ""
var keyFound = false
var valParts: seq[string] = @[]
# Build composite PK key from all PK columns
if tblDef.pkColumns.len > 0:
var pkParts: seq[string] = @[]
for pkCol in tblDef.pkColumns:
let pkVal = getValue(rowVals, fields, pkCol)
pkParts.add(pkCol & "=" & escapeRowVal(pkVal))
key = pkParts.join(":")
keyFound = true
for i, f in fields:
if i < rowVals.len:
if not keyFound:
key = f & "=" & escapeRowVal(rowVals[i])
keyFound = true
else:
elif tblDef.pkColumns.len == 0 or f.toLower() notin tblDef.pkColumns.mapIt(it.toLower()):
valParts.add(f & "=" & escapeRowVal(rowVals[i]))
elif f.len > 0:
valParts.add(f & "=")
if tblDef.pkColumns.len == 0 or f.toLower() notin tblDef.pkColumns.mapIt(it.toLower()):
valParts.add(f & "=")
let valStr = valParts.join(",")
let fullKey = table & "." & key
@@ -2107,32 +2161,24 @@ proc validateConstraints*(ctx: ExecutionContext, tableName: string,
if not typeOk:
return (false, typeErr)
# FK check
# FK check — uses LSM get which searches memtable + SSTables
if col.fkTable.len > 0 and col.fkColumn.len > 0 and not isNull(val):
let fkKey = col.fkTable & "." & col.fkColumn & "=" & val
let (fkExists, _) = ctx.db.get(fkKey)
if not fkExists:
# Also check if value is in any row's first field
var found = false
let prefix = col.fkTable & "."
for entry in ctx.db.scanMemTable():
if entry.deleted: continue
if entry.key.startsWith(prefix):
let rest = entry.key[prefix.len..^1]
if rest.startsWith(col.fkColumn & "=") and rest[col.fkColumn.len+1..^1] == val:
found = true
break
if not found:
return (false, "FOREIGN KEY violation: '" & val & "' not found in " & col.fkTable & "." & col.fkColumn)
return (false, "FOREIGN KEY violation: '" & val & "' not found in " & col.fkTable & "." & col.fkColumn)
# PK uniqueness (skip during UPDATE — PK shouldn't change)
if not skipPkCheck and tbl.pkColumns.len > 0:
var pkVals: seq[string] = @[]
var pkParts: seq[string] = @[]
for pkCol in tbl.pkColumns:
pkVals.add(getValue(rowVals, fields, pkCol))
let pkVal = getValue(rowVals, fields, pkCol)
pkVals.add(pkVal)
pkParts.add(pkCol & "=" & escapeRowVal(pkVal))
let pkStr = pkVals.join("|")
# Check with field=value format (as stored by execInsert)
var pkKey = tableName & "." & tbl.pkColumns[0] & "=" & escapeRowVal(pkVals[0])
# Check with composite PK format (as stored by execInsert)
let pkKey = tableName & "." & pkParts.join(":")
let (exists, _) = ctx.db.get(pkKey)
if exists:
return (false, "UNIQUE constraint violated: duplicate key '" & pkStr & "'")
@@ -4739,7 +4785,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
ctx.views[stmt.cvName] = stmt.cvQuery
let viewKey = "_schema:views:" & stmt.cvName
let viewSql = selectToSql(stmt.cvQuery)
let viewDdl = "CREATE VIEW " & stmt.cvName & " AS " & viewSql
let viewDdl = "CREATE VIEW \"" & sqlEscapeIdent(stmt.cvName) & "\" AS " & viewSql
ctx.db.put(viewKey, cast[seq[byte]](viewDdl))
return okResult(msg="CREATE VIEW " & stmt.cvName)
@@ -4762,7 +4808,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
ctx.tables[stmt.trigTable].triggers = triggers
# Persist trigger to LSM-Tree
let trigKey = "_schema:triggers:" & stmt.trigTable & ":" & stmt.trigName
let trigDdl = "CREATE TRIGGER " & stmt.trigName & " ON " & stmt.trigTable & " " &
let trigDdl = "CREATE TRIGGER \"" & sqlEscapeIdent(stmt.trigName) & "\" ON \"" & sqlEscapeIdent(stmt.trigTable) & "\" " &
stmt.trigTiming & " " & stmt.trigEvent & " AS " & stmt.trigAction.strVal
ctx.db.put(trigKey, cast[seq[byte]](trigDdl))
return okResult(msg="CREATE TRIGGER " & stmt.trigName)
@@ -5031,7 +5077,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
ctx.users[stmt.cuName] = UserDef(name: stmt.cuName, passwordHash: stmt.cuPassword,
isSuperuser: stmt.cuSuperuser, roles: @[])
let userKey = "_schema:users:" & stmt.cuName
let userDdl = "CREATE USER " & stmt.cuName & " WITH PASSWORD '" & stmt.cuPassword & "'" &
let userDdl = "CREATE USER \"" & sqlEscapeIdent(stmt.cuName) & "\" WITH PASSWORD '" & sqlEscapeString(stmt.cuPassword) & "'" &
(if stmt.cuSuperuser: " SUPERUSER" else: " NOSUPERUSER")
ctx.db.put(userKey, cast[seq[byte]](userDdl))
return okResult(msg="CREATE USER " & stmt.cuName)
@@ -5050,7 +5096,7 @@ proc executeQueryImpl(ctx: ExecutionContext, astNode: Node, params: seq[WireValu
withCheckExpr: stmt.cpWithCheck))
ctx.policies[stmt.cpTable] = pols
let polKey = "_schema:policies:" & stmt.cpTable & ":" & stmt.cpName
var polDdl = "CREATE POLICY " & stmt.cpName & " ON " & stmt.cpTable
var polDdl = "CREATE POLICY \"" & sqlEscapeIdent(stmt.cpName) & "\" ON \"" & sqlEscapeIdent(stmt.cpTable) & "\""
if stmt.cpCommand != "ALL":
polDdl.add(" FOR " & stmt.cpCommand)
if stmt.cpUsing != nil: