fix: comprehensive bug audit — 15 critical/high/medium fixes

Critical fixes:
- B-Tree splitChild: fixed out-of-bounds on child.children.len (mid+1..len → mid+1..<len)
- Wire protocol: all deserializeValue cases now properly assign to result
- Wire protocol: fkBool/fkInt8 now increment pos after reading
- MVCC: write-write conflict now detected for completed transactions
- MVCC: commit only marks visible versions as deleted (not concurrent uncommitted)
- Lexer: buffer overrun on backslash escape at end of input — added bounds check
- Bulgarian/Russian stemmers: fixed UTF-8 byte offsets for Cyrillic suffixes (2 bytes per char)

High fixes:
- WebSocket: added missing std/tables import
- Raft: added randomize() call for election timeouts
- MemTable.get: binary search O(log n) instead of linear scan O(n)

Medium fixes:
- Zerocopy: string length now written as 4-byte int32 (was single byte)
- Raft appendEntries: guard against nextIdx underflow
- Columnar: null tracking with nulls seq on ColumnPtr
- Removed dead code in lexer readIdent (redundant true/false checks)
- Updated Bulgarian stemming test to match corrected byte offsets

All 214 tests pass across 48 suites with 0 failures.
This commit is contained in:
2026-05-06 02:46:24 +03:00
parent c6de13ef7a
commit 805dbc8ad6
11 changed files with 89 additions and 55 deletions
+9 -4
View File
@@ -19,6 +19,7 @@ type
ColumnPtr* = ref object
typ*: ColumnType
nulls*: seq[bool]
case kind: ColumnType
of ctInt64: intData: seq[int64]
of ctFloat64: floatData: seq[float64]
@@ -34,35 +35,39 @@ proc newColumnBatch*(): ColumnBatch =
ColumnBatch(columns: initTable[string, ColumnPtr](), rowCount: 0)
proc addInt64Col*(batch: var ColumnBatch, name: string): var ColumnPtr =
var col = ColumnPtr(typ: ctInt64, kind: ctInt64, intData: @[])
var col = ColumnPtr(typ: ctInt64, kind: ctInt64, nulls: @[], intData: @[])
batch.columns[name] = col
return batch.columns[name]
proc addFloat64Col*(batch: var ColumnBatch, name: string): var ColumnPtr =
var col = ColumnPtr(typ: ctFloat64, kind: ctFloat64, floatData: @[])
var col = ColumnPtr(typ: ctFloat64, kind: ctFloat64, nulls: @[], floatData: @[])
batch.columns[name] = col
return batch.columns[name]
proc addStringCol*(batch: var ColumnBatch, name: string): var ColumnPtr =
var col = ColumnPtr(typ: ctString, kind: ctString, strData: @[])
var col = ColumnPtr(typ: ctString, kind: ctString, nulls: @[], strData: @[])
batch.columns[name] = col
return batch.columns[name]
proc addBoolCol*(batch: var ColumnBatch, name: string): var ColumnPtr =
var col = ColumnPtr(typ: ctBool, kind: ctBool, boolData: @[])
var col = ColumnPtr(typ: ctBool, kind: ctBool, nulls: @[], boolData: @[])
batch.columns[name] = col
return batch.columns[name]
proc appendInt64*(col: var ColumnPtr, val: int64, isNull: bool = false) =
col.nulls.add(isNull)
col.intData.add(val)
proc appendFloat64*(col: var ColumnPtr, val: float64, isNull: bool = false) =
col.nulls.add(isNull)
col.floatData.add(val)
proc appendString*(col: var ColumnPtr, val: string, isNull: bool = false) =
col.nulls.add(isNull)
col.strData.add(val)
proc appendBool*(col: var ColumnPtr, val: bool, isNull: bool = false) =
col.nulls.add(isNull)
col.boolData.add(val)
proc rowCount*(batch: ColumnBatch): int =
+12 -3
View File
@@ -170,9 +170,12 @@ proc write*(tm: TxnManager, txn: Transaction, key: string, value: seq[byte]): bo
if tm.activeTxns[creator].state == tsCommitted:
release(tm.lock)
return false # write-write conflict
elif creator notin tm.activeTxns:
# Already completed and visible
discard
else:
# Creator already completed — check if it committed
# If not in activeTxns, it either committed or aborted
# We treat completed-but-visible as a conflict
release(tm.lock)
return false # write-write conflict with completed txn
let lsn = tm.allocLsn()
let version = VersionedRecord(
@@ -201,8 +204,14 @@ proc commit*(tm: TxnManager, txn: Transaction): bool =
tm.globalVersions[key] = @[]
# Mark previous versions as deleted by this txn
# Only mark versions that were visible to this transaction
for i in 0..<tm.globalVersions[key].len:
if tm.globalVersions[key][i].xmax == TxnId(0):
let creator = tm.globalVersions[key][i].xmin
# Only mark as deleted if the version was visible (committed before our snapshot)
if creator != txn.id:
if uint64(creator) <= uint64(txn.snapshotMaxTxn) and
creator notin txn.snapshotTxns:
tm.globalVersions[key][i].xmax = txn.id
tm.globalVersions[key].add(version)
+2
View File
@@ -64,6 +64,7 @@ type
messageQueue*: Deque[RaftMessage]
proc newRaftNode*(id: string, peers: seq[string]): RaftNode =
randomize()
RaftNode(
id: id,
state: rsFollower,
@@ -211,6 +212,7 @@ proc appendEntries*(node: RaftNode, peerId: string): RaftMessage =
prevTerm = node.log[prevIdx - 1].term
var entries: seq[LogEntry] = @[]
if nextIdx > 0:
for i in int(nextIdx - 1)..<node.log.len:
entries.add(node.log[i])
+26 -22
View File
@@ -81,17 +81,19 @@ proc stemEnglish*(word: string): string =
proc stemBulgarian*(word: string): string =
if word.len <= 3: return word
# Bulgarian suffixes
if word.endsWith("ища"): return word[0..^4]
if word.endsWith("ище"): return word[0..^4]
if word.endsWith("ция"): return word[0..^4]
if word.endsWith("ние"): return word[0..^4]
if word.endsWith("ост"): return word[0..^4]
if word.endsWith("ство"): return word[0..^5]
if word.endsWith("ски"): return word[0..^4]
if word.endsWith("ски"): return word[0..^4]
if word.endsWith("на"): return word[0..^3]
if word.endsWith("та"): return word[0..^3]
# Bulgarian suffixes (all Cyrillic chars are 2 bytes)
# 3 Cyrillic chars = 6 bytes -> ^7
if word.endsWith("ища"): return word[0..^7]
if word.endsWith("ище"): return word[0..^7]
if word.endsWith("ция"): return word[0..^7]
if word.endsWith("ние"): return word[0..^7]
if word.endsWith("ост"): return word[0..^7]
if word.endsWith("ски"): return word[0..^7]
# 4 Cyrillic chars = 8 bytes -> ^9
if word.endsWith("ство"): return word[0..^9]
# 2 Cyrillic chars = 4 bytes -> ^5
if word.endsWith("на"): return word[0..^5]
if word.endsWith("та"): return word[0..^5]
return word
proc stemGerman*(word: string): string =
@@ -124,17 +126,19 @@ proc stemFrench*(word: string): string =
proc stemRussian*(word: string): string =
if word.len <= 3: return word
# Russian suffixes
if word.endsWith("ость"): return word[0..^5]
if word.endsWith("ение"): return word[0..^5]
if word.endsWith("ание"): return word[0..^5]
if word.endsWith("тель"): return word[0..^5]
if word.endsWith("ский"): return word[0..^5]
if word.endsWith("ция"): return word[0..^4]
if word.endsWith("ние"): return word[0..^4]
if word.endsWith("ать"): return word[0..^4]
if word.endsWith("ить"): return word[0..^4]
if word.endsWith("ыть"): return word[0..^4]
# Russian suffixes (all Cyrillic chars are 2 bytes)
# 4 Cyrillic chars = 8 bytes -> ^9
if word.endsWith("ость"): return word[0..^9]
if word.endsWith("ение"): return word[0..^9]
if word.endsWith("ание"): return word[0..^9]
if word.endsWith("тель"): return word[0..^9]
if word.endsWith("ский"): return word[0..^9]
# 3 Cyrillic chars = 6 bytes -> ^7
if word.endsWith("ция"): return word[0..^7]
if word.endsWith("ние"): return word[0..^7]
if word.endsWith("ать"): return word[0..^7]
if word.endsWith("ить"): return word[0..^7]
if word.endsWith("ыть"): return word[0..^7]
return word
proc getLanguageConfig*(lang: Language): LanguageConfig =
+1
View File
@@ -5,6 +5,7 @@ import std/strutils
import std/base64
import std/sha1
import std/hashes
import std/tables
const
WS_FIN* = 0x80'u8
+17 -13
View File
@@ -180,44 +180,48 @@ proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
let kind = FieldKind(buf[pos])
inc pos
case kind
of fkNull: WireValue(kind: fkNull)
of fkBool: WireValue(kind: fkBool, boolVal: buf[pos] != 0)
of fkInt8: WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
of fkNull: result = WireValue(kind: fkNull)
of fkBool:
result = WireValue(kind: fkBool, boolVal: buf[pos] != 0)
inc pos
of fkInt8:
result = WireValue(kind: fkInt8, int8Val: cast[int8](buf[pos]))
inc pos
of fkInt16:
var val: int16
var bytes: array[2, byte]
for i in 0..1: bytes[i] = buf[pos + i]
bigEndian16(addr val, unsafeAddr bytes)
pos += 2
WireValue(kind: fkInt16, int16Val: val)
result = WireValue(kind: fkInt16, int16Val: val)
of fkInt32:
WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
result = WireValue(kind: fkInt32, int32Val: int32(readUint32(buf, pos)))
of fkInt64:
WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
result = WireValue(kind: fkInt64, int64Val: int64(readUint64(buf, pos)))
of fkFloat32:
var fl: float32
var bytes: array[4, byte]
for i in 0..3: bytes[i] = buf[pos + i]
copyMem(addr fl, unsafeAddr bytes, 4)
pos += 4
WireValue(kind: fkFloat32, float32Val: fl)
result = WireValue(kind: fkFloat32, float32Val: fl)
of fkFloat64:
var fl: float64
var bytes: array[8, byte]
for i in 0..7: bytes[i] = buf[pos + i]
copyMem(addr fl, unsafeAddr bytes, 8)
pos += 8
WireValue(kind: fkFloat64, float64Val: fl)
result = WireValue(kind: fkFloat64, float64Val: fl)
of fkString:
WireValue(kind: fkString, strVal: readString(buf, pos))
result = WireValue(kind: fkString, strVal: readString(buf, pos))
of fkBytes:
WireValue(kind: fkBytes, bytesVal: readBytes(buf, pos))
result = WireValue(kind: fkBytes, bytesVal: readBytes(buf, pos))
of fkArray:
let count = int(readUint32(buf, pos))
var arr: seq[WireValue] = @[]
for i in 0..<count:
arr.add(deserializeValue(buf, pos))
WireValue(kind: fkArray, arrayVal: arr)
result = WireValue(kind: fkArray, arrayVal: arr)
of fkObject:
let count = int(readUint32(buf, pos))
var obj: seq[(string, WireValue)] = @[]
@@ -225,7 +229,7 @@ proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
let name = readString(buf, pos)
let val = deserializeValue(buf, pos)
obj.add((name, val))
WireValue(kind: fkObject, objVal: obj)
result = WireValue(kind: fkObject, objVal: obj)
of fkVector:
let count = int(readUint32(buf, pos))
var vec: seq[float32] = @[]
@@ -236,7 +240,7 @@ proc deserializeValue*(buf: openArray[byte], pos: var int): WireValue =
copyMem(addr fl, unsafeAddr bytes, 4)
pos += 4
vec.add(fl)
WireValue(kind: fkVector, vecVal: vec)
result = WireValue(kind: fkVector, vecVal: vec)
proc serializeMessage*(msg: WireMessage): seq[byte] =
result = @[]
+2 -1
View File
@@ -176,7 +176,8 @@ proc encodeRecord*(buf: var ZeroBuf, schema: ZcSchema,
var v: int64 = 0
bigEndian64(addr buf.data[field.offset], unsafeAddr v)
of ztString:
buf.data[field.offset] = byte(value.len)
var len32 = int32(value.len)
bigEndian32(addr buf.data[field.offset], unsafeAddr len32)
if value.len > 0:
copyMem(addr buf.data[field.offset + 4], unsafeAddr value[0], value.len)
else:
+1 -4
View File
@@ -260,6 +260,7 @@ proc readString(l: var Lexer, quote: char): string =
while l.pos < l.input.len and l.input[l.pos] != quote:
if l.input[l.pos] == '\\':
discard l.advance()
if l.pos >= l.input.len: break
case l.input[l.pos]
of 'n': result.add('\n')
of 't': result.add('\t')
@@ -295,10 +296,6 @@ proc readIdent(l: var Lexer, startLine, startCol: int): Token =
let lowerIdent = ident.toLower()
if lowerIdent in keywords:
Token(kind: keywords[lowerIdent], value: ident, line: startLine, col: startCol)
elif lowerIdent == "true":
Token(kind: tkBoolLit, value: "true", line: startLine, col: startCol)
elif lowerIdent == "false":
Token(kind: tkBoolLit, value: "false", line: startLine, col: startCol)
else:
Token(kind: tkIdent, value: ident, line: startLine, col: startCol)
+1 -1
View File
@@ -48,7 +48,7 @@ proc splitChild[K, V](parent: BTreeNode[K, V], index: int, order: int) =
newNode.values.add(child.values[j])
if not child.isLeaf:
for j in mid+1..child.children.len:
for j in mid+1..<child.children.len:
newNode.children.add(child.children[j])
child.children.setLen(mid + 1)
+14 -3
View File
@@ -62,9 +62,20 @@ proc put*(mt: var MemTable, key: string, value: seq[byte], timestamp: uint64, de
return true
proc get*(mt: MemTable, key: string): (bool, Entry) =
for entry in mt.entries:
if entry.key == key:
return (true, entry)
if mt.entries.len == 0:
return (false, Entry())
# Binary search since entries are kept sorted by key
var lo = 0
var hi = mt.entries.len - 1
while lo <= hi:
let mid = (lo + hi) div 2
let cmp = cmp(mt.entries[mid].key, key)
if cmp == 0:
return (true, mt.entries[mid])
elif cmp < 0:
lo = mid + 1
else:
hi = mid - 1
return (false, Entry())
proc scan*(mt: MemTable, startKey, endKey: string): seq[Entry] =
+1 -1
View File
@@ -1489,7 +1489,7 @@ suite "Multi-Language FTS":
check mlang.stemEnglish("programming") == "programm"
test "Bulgarian stemming":
check mlang.stemBulgarian("красота") == "красот"
check mlang.stemBulgarian("красота") == "красо"
suite "Zero-Copy Serialization":
test "Write and read int32":