Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
cd2c37c
Add SQLancer fuzz regressions
adamziel Jun 17, 2026
940a270
Handle MySQL IS UNKNOWN expressions
adamziel Jun 17, 2026
890f3ea
Handle SQLancer functional index cases
adamziel Jun 17, 2026
88f9f05
Handle SQLancer aggregate edge cases
adamziel Jun 17, 2026
e2e4711
Handle SQLancer ALTER TABLE renames
adamziel Jun 17, 2026
5372002
Handle SQLancer signed integer casts
adamziel Jun 17, 2026
a052daf
Handle SQLancer ORDER BY constants
adamziel Jun 17, 2026
f0a9163
Handle SQLancer integer ordering expressions
adamziel Jun 17, 2026
ddb02b6
Handle SQLancer drop index after table rename
adamziel Jun 17, 2026
ec16b15
Round numeric strings for integer column saves
adamziel Jun 17, 2026
c7b584c
Handle SQLancer memory table defaults
adamziel Jun 17, 2026
95aee16
Handle SQLancer literal index expressions
adamziel Jun 17, 2026
9ef077b
Handle SQLancer redundant index drops
adamziel Jun 17, 2026
d97cca1
Honor DECIMAL scale when saving values
adamziel Jun 17, 2026
17be34a
Handle SQLancer zerofill unsigned saves
adamziel Jun 17, 2026
f2e090f
Handle SQLancer renamed functional indexes
adamziel Jun 17, 2026
5e1a2bf
Clip SQLancer signed integer saves
adamziel Jun 17, 2026
0293763
Handle SQLancer HEAP decimal defaults
adamziel Jun 17, 2026
954a924
Restore MySQL state while filtering SQLancer logs
adamziel Jun 17, 2026
c055831
Use tmpfs for SQLancer MySQL data
adamziel Jun 17, 2026
6bd1059
Honor SQLancer text prefix indexes
adamziel Jun 17, 2026
16b467e
Handle SQLancer IF truthiness indexes
adamziel Jun 17, 2026
c461233
Allow alternate SQLancer MySQL oracles
adamziel Jun 17, 2026
5af0a4b
Drop MySQL SELECT optimizer hints
adamziel Jun 17, 2026
daa7f1c
Add scheduled SQLancer fuzz workflow
adamziel Jun 17, 2026
5801b0f
Fix SQLancer fuzz CI checks
adamziel Jun 17, 2026
e1247da
Keep SQLancer regressions portable on older SQLite
adamziel Jun 17, 2026
f53ec37
Cancel stale SQLancer push runs
adamziel Jun 17, 2026
50c2251
Handle legacy SQLite SQLancer smallint results
adamziel Jun 17, 2026
3dc1dfb
Narrow legacy SQLite SQLancer expectation
adamziel Jun 17, 2026
22efacb
Avoid overlapping SQLancer CI runs
adamziel Jun 17, 2026
75d4ee6
Run SQLancer e2e regression from repo root
adamziel Jun 17, 2026
615545e
Quote SQLancer e2e eval payload
adamziel Jun 17, 2026
dab203b
Handle SQLancer MEMORY implicit defaults
adamziel Jun 17, 2026
9f46216
Preserve numeric affinity for MySQL integer casts
adamziel Jun 17, 2026
f697358
Update integer cast translation expectation
adamziel Jun 17, 2026
7ef3f31
Record SQLancer findings in a capped issue
adamziel Jun 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
241 changes: 241 additions & 0 deletions .github/workflows/sqlancer-sqlite-fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
name: SQLancer SQLite Fuzz

on:
push:
branches:
- codex/sqlancer-sqlite-fuzz
schedule:
# Bounded recurring fuzzing. GitHub Actions cannot run forever, so this
# workflow rotates deterministic seeds/oracles on a schedule.
- cron: '17 */6 * * *'
workflow_dispatch:
inputs:
oracle:
description: 'SQLancer MySQL oracle to run, or auto to rotate by run number.'
required: false
default: 'auto'
type: choice
options:
- auto
- FUZZER
- TLP_WHERE
- PQS
- DQP
- DQE
seed:
description: 'Optional deterministic SQLancer seed.'
required: false
default: ''
num_queries:
description: 'Optional query count. Defaults are chosen per oracle.'
required: false
default: ''
max_generated_databases:
description: 'Maximum generated databases.'
required: false
default: '1'
append_findings:
description: 'Append replay failures to the SQLancer findings issue.'
required: false
default: true
type: boolean

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions: {}

jobs:
fuzz:
name: SQLancer ${{ github.event_name == 'workflow_dispatch' && inputs.oracle || 'auto' }}
runs-on: ubuntu-latest
timeout-minutes: 120
permissions:
contents: read
issues: write # Required to create/update the findings issue.

env:
FINDINGS_ISSUE_TITLE: SQLancer SQLite replay findings
FINDINGS_LIMIT: 1000
INPUT_ORACLE: ${{ github.event_name == 'workflow_dispatch' && inputs.oracle || 'auto' }}
INPUT_SEED: ${{ github.event_name == 'workflow_dispatch' && inputs.seed || '' }}
INPUT_NUM_QUERIES: ${{ github.event_name == 'workflow_dispatch' && inputs.num_queries || '' }}
INPUT_MAX_GENERATED_DATABASES: ${{ github.event_name == 'workflow_dispatch' && inputs.max_generated_databases || '1' }}
INPUT_APPEND_FINDINGS: ${{ github.event_name != 'workflow_dispatch' || inputs.append_findings }}

steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Set up PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
coverage: none

- name: Install Composer dependencies (root)
uses: ramsey/composer-install@v3
with:
ignore-cache: 'yes'
composer-options: '--optimize-autoloader'

- name: Install Composer dependencies (mysql-on-sqlite)
uses: ramsey/composer-install@v3
with:
working-directory: packages/mysql-on-sqlite
ignore-cache: 'yes'
composer-options: '--optimize-autoloader'

- name: Choose fuzz settings
id: settings
run: |
set -euo pipefail

ORACLES=( FUZZER TLP_WHERE PQS DQP DQE )
DEFAULT_QUERIES=( 10000 3000 1000 300 300 )

ORACLE="$INPUT_ORACLE"
if [ -z "$ORACLE" ] || [ "$ORACLE" = 'auto' ]; then
INDEX=$(( GITHUB_RUN_NUMBER % ${#ORACLES[@]} ))
ORACLE="${ORACLES[$INDEX]}"
fi

NUM_QUERIES="$INPUT_NUM_QUERIES"
if [ -z "$NUM_QUERIES" ]; then
NUM_QUERIES=1000
for i in "${!ORACLES[@]}"; do
if [ "${ORACLES[$i]}" = "$ORACLE" ]; then
NUM_QUERIES="${DEFAULT_QUERIES[$i]}"
break
fi
done
fi

SEED="$INPUT_SEED"
if [ -z "$SEED" ]; then
SEED=$(( 20270000 + GITHUB_RUN_NUMBER ))
fi

MAX_GENERATED_DATABASES="$INPUT_MAX_GENERATED_DATABASES"
if [ -z "$MAX_GENERATED_DATABASES" ]; then
MAX_GENERATED_DATABASES=1
fi

RUNNER_OUTPUT_DIR="$RUNNER_TEMP/sqlancer-output-$ORACLE-$SEED"

{
echo "oracle=$ORACLE"
echo "seed=$SEED"
echo "num_queries=$NUM_QUERIES"
echo "max_generated_databases=$MAX_GENERATED_DATABASES"
echo "runner_output_dir=$RUNNER_OUTPUT_DIR"
} >> "$GITHUB_OUTPUT"

{
echo "SQLANCER_MYSQL_ORACLE=$ORACLE"
echo "RANDOM_SEED=$SEED"
echo "NUM_QUERIES=$NUM_QUERIES"
echo "MAX_GENERATED_DATABASES=$MAX_GENERATED_DATABASES"
echo "ARTIFACTS_DIR=$RUNNER_OUTPUT_DIR"
echo "RUNNER_OUTPUT_DIR=$RUNNER_OUTPUT_DIR"
} >> "$GITHUB_ENV"

- name: Run SQLancer replay
id: fuzz
continue-on-error: true
run: |
set +e
mkdir -p "$RUNNER_OUTPUT_DIR"
./bin/run-sqlancer-sqlite-fuzz.sh > "$RUNNER_OUTPUT_DIR/runner-output.txt" 2>&1
STATUS=$?
cat "$RUNNER_OUTPUT_DIR/runner-output.txt"
echo "status=$STATUS" >> "$GITHUB_OUTPUT"
exit "$STATUS"

- name: Detect replay failure
id: replay_failure
if: steps.fuzz.outputs.status != '0'
run: |
if grep -q '^FAIL line [0-9][0-9]*:' "$RUNNER_OUTPUT_DIR/runner-output.txt"; then
echo "found=true" >> "$GITHUB_OUTPUT"
else
echo "found=false" >> "$GITHUB_OUTPUT"
fi

- name: Append replay failure to findings issue
if: steps.replay_failure.outputs.found == 'true' && env.INPUT_APPEND_FINDINGS == 'true'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail

COMMENT_FILE="$RUNNER_TEMP/sqlancer-finding-comment.md"
MARKER_FILE="$RUNNER_TEMP/sqlancer-finding-marker.txt"

php tests/fuzz/append-sqlancer-finding.php \
--output="$RUNNER_OUTPUT_DIR/runner-output.txt" \
--comment="$COMMENT_FILE" \
--marker="$MARKER_FILE" \
--oracle="$SQLANCER_MYSQL_ORACLE" \
--seed="$RANDOM_SEED" \
--num-queries="$NUM_QUERIES" \
--max-generated-databases="$MAX_GENERATED_DATABASES" \
--commit="$GITHUB_SHA" \
--run-url="$RUN_URL"

MARKER="$(cat "$MARKER_FILE")"

ISSUE_NUMBER="$(
gh issue list \
--state open \
--search "$FINDINGS_ISSUE_TITLE in:title" \
--json number,title \
--jq ".[] | select(.title == \"$FINDINGS_ISSUE_TITLE\") | .number" \
| head -n 1
)"

if [ -z "$ISSUE_NUMBER" ]; then
ISSUE_BODY="$RUNNER_TEMP/sqlancer-findings-issue.md"
cat > "$ISSUE_BODY" <<'EOF'
This issue is maintained by the SQLancer SQLite fuzz workflow.

Each finding is stored as one issue comment with a hidden `sqlancer-finding` marker. The workflow skips duplicate markers and stops appending after 1000 finding comments. Reduce entries into `tests/e2e/specs/sqlancer-fuzz-regressions.test.js` before fixing them.
EOF
ISSUE_URL="$(gh issue create --title "$FINDINGS_ISSUE_TITLE" --body-file "$ISSUE_BODY")"
ISSUE_NUMBER="${ISSUE_URL##*/}"
fi

EXISTING_MARKERS="$(
gh api --paginate "repos/$GITHUB_REPOSITORY/issues/$ISSUE_NUMBER/comments" \
--jq '.[].body' \
| grep -F '<!-- sqlancer-finding hash=' \
|| true
)"

if printf '%s\n' "$EXISTING_MARKERS" | grep -Fqx "$MARKER"; then
echo "SQLancer finding already exists in issue #$ISSUE_NUMBER: $MARKER"
exit 0
fi

FINDING_COUNT="$(printf '%s\n' "$EXISTING_MARKERS" | grep -c '<!-- sqlancer-finding hash=' || true)"
if [ "$FINDING_COUNT" -ge "$FINDINGS_LIMIT" ]; then
echo "SQLancer findings issue #$ISSUE_NUMBER already has $FINDING_COUNT entries; not appending."
exit 0
fi

gh issue comment "$ISSUE_NUMBER" --body-file "$COMMENT_FILE"
echo "Appended SQLancer finding to issue #$ISSUE_NUMBER: $MARKER"

- name: Fail when fuzzing finds or hits an error
if: steps.fuzz.outputs.status != '0'
run: |
if [ "${{ steps.replay_failure.outputs.found }}" = 'true' ]; then
echo "SQLancer found a SQLite replay failure. See the run log and findings issue."
else
echo "SQLancer failed before producing a SQLite replay failure. See the run log."
fi
exit "${{ steps.fuzz.outputs.status }}"
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ composer run wp-test-start # Start WordPress environment (Docker)
composer run wp-test-php # Run WordPress PHPUnit tests
composer run wp-test-e2e # Run WordPress E2E tests (Playwright)
composer run wp-test-clean # Clean up WordPress environment (Docker and DB)

# SQLancer fuzzing
bin/run-sqlancer-sqlite-fuzz.sh # Generate MySQL SQLancer queries and replay MySQL-accepted statements through SQLite
```

## Release workflow
Expand Down
147 changes: 147 additions & 0 deletions bin/run-sqlancer-sqlite-fuzz.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
SQLANCER_DIR="${SQLANCER_DIR:-/tmp/sqlancer}"
SQLANCER_REPO="${SQLANCER_REPO:-https://github.com/sqlancer/sqlancer.git}"
SQLANCER_IMAGE="${SQLANCER_IMAGE:-maven:3.9-eclipse-temurin-21}"
MYSQL_IMAGE="${MYSQL_IMAGE:-mysql:8.4}"
MYSQL_PASSWORD="${MYSQL_PASSWORD:-sqlancer}"
MYSQL_TMPFS_SIZE="${MYSQL_TMPFS_SIZE:-1024m}"
RANDOM_SEED="${RANDOM_SEED:-20260617}"
NUM_QUERIES="${NUM_QUERIES:-200}"
MAX_GENERATED_DATABASES="${MAX_GENERATED_DATABASES:-1}"
SQLANCER_MYSQL_ORACLE="${SQLANCER_MYSQL_ORACLE:-FUZZER}"
DATABASE_PREFIX="${DATABASE_PREFIX:-sdi_fuzz}"
ARTIFACTS_DIR="${ARTIFACTS_DIR:-/tmp/sdi-sqlancer-artifacts/$(date -u +%Y%m%d-%H%M%S)}"

NETWORK="sdi-sqlancer-$$"
MYSQL_CONTAINER="sdi-sqlancer-mysql-$$"
CURRENT_DB=""
SKIP_ARGS=()

cleanup() {
docker rm -f "$MYSQL_CONTAINER" >/dev/null 2>&1 || true
docker network rm "$NETWORK" >/dev/null 2>&1 || true
}
trap cleanup EXIT

if [ ! -f "$SQLANCER_DIR/pom.xml" ]; then
git clone --depth 1 "$SQLANCER_REPO" "$SQLANCER_DIR"
fi

SQLANCER_JAR="$(find "$SQLANCER_DIR/target" -maxdepth 1 -type f -name 'sqlancer-*.jar' 2>/dev/null | sort | head -n 1 || true)"
if [ -z "$SQLANCER_JAR" ]; then
docker run --rm \
-v "$SQLANCER_DIR:/src" \
-w /src \
"$SQLANCER_IMAGE" \
mvn -DskipTests package
SQLANCER_JAR="$(find "$SQLANCER_DIR/target" -maxdepth 1 -type f -name 'sqlancer-*.jar' | sort | head -n 1)"
fi

mkdir -p "$ARTIFACTS_DIR"
docker run --rm \
-v "$SQLANCER_DIR:/sqlancer" \
-w /sqlancer \
"$SQLANCER_IMAGE" \
sh -c 'rm -rf logs/mysql'

docker network create "$NETWORK" >/dev/null
docker run -d --rm \
--name "$MYSQL_CONTAINER" \
--network "$NETWORK" \
--tmpfs "/var/lib/mysql:rw,size=$MYSQL_TMPFS_SIZE" \
-e MYSQL_ROOT_PASSWORD="$MYSQL_PASSWORD" \
-e MYSQL_ROOT_HOST=% \
"$MYSQL_IMAGE" \
--mysql-native-password=ON >/dev/null

for _ in $(seq 1 60); do
if docker run --rm --network "$NETWORK" --tmpfs /var/lib/mysql:rw,size=16m "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null 2>&1; then
break
fi
sleep 1
done

docker run --rm --network "$NETWORK" --tmpfs /var/lib/mysql:rw,size=16m "$MYSQL_IMAGE" mysqladmin ping -h"$MYSQL_CONTAINER" -uroot -p"$MYSQL_PASSWORD" --silent >/dev/null

docker run --rm \
--network "$NETWORK" \
-v "$SQLANCER_DIR:/sqlancer" \
-w /sqlancer \
"$SQLANCER_IMAGE" \
java -jar "target/$(basename "$SQLANCER_JAR")" \
--num-threads 1 \
--num-queries "$NUM_QUERIES" \
--max-generated-databases "$MAX_GENERATED_DATABASES" \
--num-tries 1 \
--database-prefix "$DATABASE_PREFIX" \
--random-seed "$RANDOM_SEED" \
--username root \
--password "$MYSQL_PASSWORD" \
--host "$MYSQL_CONTAINER" \
--port 3306 \
mysql --oracle "$SQLANCER_MYSQL_ORACLE"

LOG_FILE="$(find "$SQLANCER_DIR/logs/mysql" -maxdepth 1 -type f -name '*-cur.log' | sort | head -n 1)"
if [ -z "$LOG_FILE" ]; then
echo "No SQLancer MySQL log was generated." >&2
exit 1
fi

cp "$LOG_FILE" "$ARTIFACTS_DIR/"
LOG_FILE="$ARTIFACTS_DIR/$(basename "$LOG_FILE")"
MYSQL_FAILURES_FILE="$ARTIFACTS_DIR/mysql-rejected-lines.txt"
MYSQL_ACCEPTED_FILE="$ARTIFACTS_DIR/mysql-accepted-prefix.sql"
: > "$MYSQL_FAILURES_FILE"
: > "$MYSQL_ACCEPTED_FILE"

LINE_NUMBER=0
while IFS= read -r LINE || [ -n "$LINE" ]; do
LINE_NUMBER=$(( LINE_NUMBER + 1 ))
SQL="$(php -r '
$line = trim(stream_get_contents(STDIN));
if ($line === "" || strpos($line, "--") === 0) {
exit;
}
echo preg_replace("/;\s*--\s*\d+ms;?$/", ";", $line);
' <<< "$LINE")"

if [ -z "$SQL" ]; then
continue
fi

if [[ "$SQL" =~ ^[Uu][Ss][Ee][[:space:]]+([^[:space:];]+) ]]; then
CURRENT_DB="${BASH_REMATCH[1]}"
printf '%s\n' "$SQL" >> "$MYSQL_ACCEPTED_FILE"
continue
fi

TMP_SQL="$(mktemp)"
printf '%s\n' "$SQL" > "$TMP_SQL"
if [ -n "$CURRENT_DB" ]; then
MYSQL_ARGS=( --database="$CURRENT_DB" )
else
MYSQL_ARGS=()
fi

if ! docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_PASSWORD" --batch --raw "${MYSQL_ARGS[@]}" < "$TMP_SQL" >/dev/null 2>&1; then
printf '%s\n' "$LINE_NUMBER" >> "$MYSQL_FAILURES_FILE"
SKIP_ARGS+=( "--skip-line=$LINE_NUMBER" )
# Non-transactional MySQL engines can keep partial changes after errors.
# Rebuild from the accepted prefix so later filtering does not depend on skipped side effects.
if ! docker exec -i "$MYSQL_CONTAINER" mysql -uroot -p"$MYSQL_PASSWORD" --batch --raw < "$MYSQL_ACCEPTED_FILE" >/dev/null 2>&1; then
echo "Failed to restore MySQL state from accepted SQLancer prefix after line $LINE_NUMBER." >&2
exit 1
fi
else
printf '%s\n' "$SQL" >> "$MYSQL_ACCEPTED_FILE"
fi
rm -f "$TMP_SQL"
done < "$LOG_FILE"

php "$ROOT_DIR/tests/fuzz/replay-sqlancer-log.php" "$LOG_FILE" "${SKIP_ARGS[@]}"

printf 'SQLancer log: %s\n' "$LOG_FILE"
printf 'MySQL-rejected line list: %s\n' "$MYSQL_FAILURES_FILE"
Loading
Loading