fix: stabilization session — auth bypass, raft quorum, wire DoS, query operators
CI / test (push) Has been cancelled
CI / raft-e2e (push) Has been cancelled
CI / verify (push) Has been cancelled
Clients CI / build-server (push) Has been cancelled
Clients CI / test-python (push) Has been cancelled
Clients CI / test-javascript (push) Has been cancelled
Clients CI / test-nim (push) Has been cancelled
Clients CI / test-rust (push) Has been cancelled

Security:
- MIGRATE handler now requires auth (unauthenticated arbitrary writes)
- parseHeader rejects oversized messages before allocation (pre-auth DoS)

Correctness:
- raft commit uses strict majority (N div 2 + 1), fixing even-N minority commit
- power (**) and concat (++) no longer lowered to equality
- != is now the exact complement of = for numerically-equal values
- legacy REP payload carries explicit put/delete tag (PK-only rows survive)
- REP receiver maintains secondary indexes via applyReplicatedPut/Delete
- snapshot send runs gzip off the event loop (heartbeat stall mitigation)

Docs: PLAN.md (session 13), BUG_AUDIT_2026-08.md (~28 findings, 23 tracked),
known-limitations.md, CHANGELOG.md.

Verified: baradadb build clean; test_all + bugfix_test pass.
This commit is contained in:
2026-08-02 22:49:30 +03:00
parent a843f0a1a3
commit ccc54e8f18
13 changed files with 458 additions and 40 deletions
+73
View File
@@ -7,6 +7,7 @@ import ../src/barabadb/query/exec/params
import ../src/barabadb/query/exec/dml
import ../src/barabadb/core/types
import ../src/barabadb/core/config
import ../src/barabadb/core/replication
import ../src/barabadb/storage/lsm
const testDir = "/tmp/baradb_bugfix_test"
@@ -552,3 +553,75 @@ suite "Raft TLS config":
check cfg.raftTlsKeyFile == "/tmp/raft.key"
check cfg.raftTlsCaFile == "/tmp/raft-ca.crt"
check cfg.raftTlsVerifyPeer == true
suite "Legacy REP payload encoding — empty value is not a delete":
test "PK-only put (empty value) round-trips as a put, not a delete":
## Regression: the legacy REP receiver used to infer a delete from an empty
## value, so PK-only rows (empty LSM value) vanished on the replica.
let decoded = decodeRepPayload(encodeRepPayload(false, "pkonly.id=3", @[]))
check decoded.op == ropPut
check decoded.key == "pkonly.id=3"
check decoded.value.len == 0
test "delete round-trips as a delete":
let decoded = decodeRepPayload(encodeRepPayload(true, "users.id=1", @[]))
check decoded.op == ropDelete
check decoded.key == "users.id=1"
check decoded.value.len == 0
test "put with a non-empty value preserves the value bytes":
let decoded = decodeRepPayload(
encodeRepPayload(false, "users.id=1", cast[seq[byte]]("bob")))
check decoded.op == ropPut
check decoded.key == "users.id=1"
check cast[string](decoded.value) == "bob"
test "value containing a null byte survives the round-trip":
## Decode splits on the FIRST null (the key/value separator) only.
let value = @[byte('a'), byte(0), byte('b')]
let decoded = decodeRepPayload(encodeRepPayload(false, "k", value))
check decoded.op == ropPut
check decoded.key == "k"
check decoded.value == value
test "empty or untagged payloads decode as invalid, not delete":
check decodeRepPayload(@[]).op == ropInvalid
check decodeRepPayload(cast[seq[byte]]("Xfoo")).op == ropInvalid
suite "Query operator correctness — audit batch 1":
test "power operator ** evaluates, not lowered to equality":
## Regression: bkPow used to fall through to `else: irOp = irEq`, so
## `2 ** 3` evaluated as `2 = 3` (false) instead of 8.
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let r = executeQuery(ctx, parse("SELECT 2 ** 3 AS x FROM users"))
check r.success
check r.rows.len == 1
check parseFloat(valueToString(r.rows[0]["x"])) == 8.0
test "concat operator ++ concatenates strings":
## Regression: bkConcat also fell through to irEq, so `'a' ++ 'b'`
## evaluated as `'a' = 'b'` (false) instead of "ab".
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'a')"))
let r = executeQuery(ctx, parse("SELECT 'a' ++ 'b' AS x FROM users"))
check r.success
check r.rows.len == 1
check valueToString(r.rows[0]["x"]) == "ab"
test "!= is the complement of = for numerically equal values":
## Regression: irNeq short-circuited on string inequality, so `1 != 1.0`
## was true while `1 = 1.0` was also true (not complements).
var ctx = setupCtx()
defer: teardown(ctx)
discard executeQuery(ctx, parse("INSERT INTO users (id, name) VALUES (1, 'alice')"))
let eq = executeQuery(ctx, parse("SELECT * FROM users WHERE id = 1.0"))
let neq = executeQuery(ctx, parse("SELECT * FROM users WHERE id != 1.0"))
check eq.rows.len == 1 # 1 = 1.0 -> true
check neq.rows.len == 0 # 1 != 1.0 -> false (old bug returned the row)
+71 -2
View File
@@ -2895,6 +2895,29 @@ suite "Raft InstallSnapshot Send":
check node.matchIndex["peer-1"] == 101
check node.nextIndex["peer-1"] == 102
test "commit requires strict majority for even-sized clusters":
## Regression: the commit quorum used (N+1) div 2, which for a 4-node
## cluster commits at 2/4 (a minority). Strict majority is N div 2 + 1.
var node = newRaftNode("leader", @["p1", "p2", "p3"])
node.currentTerm = 5
node.state = rsLeader
let e = node.appendLog("put", cast[seq[byte]]("k\x00v"))
check e.index == 1
check e.term == 5
node.nextIndex["p1"] = 2
node.nextIndex["p2"] = 2
node.nextIndex["p3"] = 2
# Leader + 1 peer (count=2) is NOT a majority of 4.
node.handleAppendReply("p1", RaftMessage(
kind: rmkAppendEntriesReply, term: 5, senderId: "p1",
success: true, matchIdx: 1))
check node.commitIndex == 0
# Leader + 2 peers (count=3) IS a strict majority of 4 -> commits.
node.handleAppendReply("p2", RaftMessage(
kind: rmkAppendEntriesReply, term: 5, senderId: "p2",
success: true, matchIdx: 1))
check node.commitIndex == 1
test "InstallSnapshotReply success advances match/next index and clears streak":
var node = newRaftNode("leader", @["peer-1"])
node.currentTerm = 5
@@ -3021,7 +3044,14 @@ suite "Raft InstallSnapshot Send":
bt: uint64): bool {.gcsafe.} =
gotBaseIndex = bi
gotBaseTerm = bt
result = readFile(p) == payload
# sendSnapshot now gzips the tar off the event loop, so the assembled
# archive is gzip-compressed; decompress before comparing the bytes.
let raw = p & ".raw"
defer:
if fileExists(raw): removeFile(raw)
if not gunzipFile(p, raw):
return false
result = readFile(raw) == payload
let netL = newRaftNetwork(leader)
let netF = newRaftNetwork(follower)
@@ -3049,8 +3079,10 @@ suite "Raft InstallSnapshot Send":
check follower.lastSnapshotTerm == 4
check gotBaseIndex == 100
check gotBaseTerm == 4
# Temp archive cleaned up after the transfer
# Temp archives (uncompressed tar + compressed .tar.gz) cleaned up after
# the transfer
check not fileExists(tmp / "raft-l" / "snap_out_100.tar.gz")
check not fileExists(tmp / "raft-l" / "snap_out_100.tar")
scenario()
test "sendSnapshot single-flight guard skips a concurrent send":
@@ -3441,6 +3473,43 @@ suite "Raft SQL Write Path":
let (found, _) = db.get("t.id=1")
check not found
test "REP receiver chain (encode -> decode -> apply) maintains indexes":
## Mirrors server.nim's legacy REP handler: decodeRepPayload decides the op,
## then applyReplicatedPut/Delete keep secondary indexes consistent. Guards
## the wiring the receiver relies on — an indexed put must populate the
## B-tree and a PK-only put (empty value) must apply as a put, not vanish.
var testDir = getTempDir() / "baradb_rep_recv_idx_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
createDir(testDir)
defer: removeDir(testDir)
var db = newLSMTree(testDir)
var ctx = qexec.newExecutionContext(db)
discard qexec.executeQuery(ctx, parse(
"CREATE TABLE t (id INT PRIMARY KEY, name STRING)"))
discard qexec.executeQuery(ctx, parse(
"CREATE INDEX idx_name ON t (name)"))
# Leader ships an indexed put; the receiver decodes and applies it.
let put = decodeRepPayload(
encodeRepPayload(false, "t.id=1", cast[seq[byte]]("name=alice")))
check put.op == ropPut
if put.op == ropPut:
applyReplicatedPut(ctx, put.key, put.value)
check ctx.btrees["t.name"].get("alice").len >= 1
# A PK-only put (empty value) must apply as a put, not a delete.
let pk = decodeRepPayload(encodeRepPayload(false, "t.id=2", @[]))
check pk.op == ropPut
if pk.op == ropPut:
applyReplicatedPut(ctx, pk.key, pk.value)
let (foundPk, _) = db.get("t.id=2")
check foundPk
# Leader ships a delete; the receiver drops the row and the index entry.
let del = decodeRepPayload(encodeRepPayload(true, "t.id=1", @[]))
check del.op == ropDelete
if del.op == ropDelete:
applyReplicatedDelete(ctx, del.key)
check ctx.btrees["t.name"].get("alice").len == 0
let (foundDel, _) = db.get("t.id=1")
check not foundDel
test "applyReplicatedPut updates in-memory graphs":
var testDir = getTempDir() / "baradb_raft_apply_g_" & $getCurrentProcessId() & "_" & $getMonoTime().ticks
createDir(testDir)