Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ gradlew.bat text eol=crlf

*.jar binary

zon-grammar/src/test/resources/zig-official-zon-test/** linguist-vendored
zon-grammar/src/test/resources/zig-official-zon-test/** linguist-vendored

.cifuzz-corpus/** binary
59 changes: 59 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Continuous Fuzzing

on:
push:
branches:
- '*'
schedule:
- cron: '0 0 * * *'
workflow_dispatch:

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

permissions:
contents: read

jobs:
fuzz:
name: Run Jazzer Fuzzing
runs-on: ubuntu-latest
timeout-minutes: 70

steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Set up JDK 25
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
with:
java-version: "25"
distribution: "temurin"

- name: Setup Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0

- name: Run Fuzz Tests
# Run for 1 hour inside CI with 2 parallel processes
env:
RUNNER_DEBUG: ${{ runner.debug }}
JAZZER_FUZZ: 1
JAZZER_ARGS: "-fork=2 -max_total_time=3600"
run: |
./gradlew :zig-grammar:fuzzTest -Pduration="1h"

- name: Upload Crash Artifacts
# Only upload files if the fuzzing task fails (found a crash)
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: jazzer-crash-reports
path: |
**/crash-*
**/slow-unit-*
**/timeout-*
if-no-files-found: ignore
retention-days: 7
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.gradle
build
.kotlin
.cifuzz-corpus
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ kotlinpoet = "2.3.0"
clikt = "5.1.0"
mordant = "3.0.2"
junit = "6.1.3"
jazzer-junit = "0.30.0"

[libraries]
ksp-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" }
Expand All @@ -13,6 +14,7 @@ kotlinpoet-ksp = { group = "com.squareup", name = "kotlinpoet-ksp", version.ref
clikt = { group = "com.github.ajalt.clikt", name = "clikt", version.ref = "clikt" }
mordant = { group = "com.github.ajalt.mordant", name = "mordant", version.ref = "mordant" }
junit-jupiter = { group = "org.junit.jupiter", name = "junit-jupiter", version.ref = "junit" }
jazzer-junit = { group = "com.code-intelligence", name = "jazzer-junit", version.ref = "jazzer-junit" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
Expand Down
39 changes: 38 additions & 1 deletion zig-grammar/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,54 @@ plugins {
dependencies {
testImplementation(libs.junit.jupiter)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
testImplementation(libs.jazzer.junit)
}

kotlin {
jvmToolchain(25)
}

tasks.test {
useJUnitPlatform()
useJUnitPlatform {
excludeTags("fuzz")
}

systemProperty("junit.jupiter.execution.parallel.enabled", "true")
systemProperty("junit.jupiter.execution.parallel.mode.default", "concurrent")
systemProperty("junit.jupiter.execution.parallel.mode.classes.default", "concurrent")
systemProperty("junit.jupiter.execution.parallel.config.executor-service", "worker_thread_pool")
}

tasks.register<Test>("fuzzTest") {
description = "Runs Jazzer fuzz tests."
group = "verification"

val testTask = tasks.test.get()
testClassesDirs = testTask.testClassesDirs
classpath = testTask.classpath

useJUnitPlatform {
includeTags("fuzz")
}

maxHeapSize = "2048m"

// Disable parallel testing, conflicts with Jazzer
systemProperty("junit.jupiter.execution.parallel.enabled", "false")

val instrumentedPackages = listOf(
"net.landless_city.zigocracy.zig.parser.*",
"net.landless_city.zigocracy.zig.scanner.*",
"net.landless_city.zigocracy.zig.scanner.impl.*",
"net.landless_city.zigocracy.zig.scanner.util.*",
"net.landless_city.zigocracy.zig.syntax.*",
"net.landless_city.zigocracy.zig.shared.*",
)
systemProperty(
"jazzer.instrument",
instrumentedPackages.joinToString(","),
)

// Fuzzing relies on randomness and must never be considered up-to-date or cached.
doNotTrackState("Fuzzing yields different results every run")
}
221 changes: 221 additions & 0 deletions zig-grammar/src/test/kotlin/fuzz/ParserFuzzTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
package net.landless_city.zigocracy.zig.fuzz

import com.code_intelligence.jazzer.junit.FuzzTest
import com.code_intelligence.jazzer.mutation.annotation.WithLength
import net.landless_city.zigocracy.zig.parser.Parser
import net.landless_city.zigocracy.zig.parser.ParserResult
import net.landless_city.zigocracy.zig.scanner.util.ScannerUtils
import net.landless_city.zigocracy.zig.syntax.NodeEvent
import net.landless_city.zigocracy.zig.syntax.TokenEvent
import net.landless_city.zigocracy.zig.syntax.TokenKind
import net.landless_city.zigocracy.zig.text.SourceFile
import org.junit.jupiter.api.Assertions.*
import org.junit.jupiter.api.Tag
import org.opentest4j.AssertionFailedError

@Tag("fuzz")
class ParserFuzzTest {
@FuzzTest
fun `fuzz parser`(text: @WithLength(max = 200) String?) {
if (text == null) return
val sourceFile = SourceFile.forTesting(text)
val result = Parser.parseSyntax(sourceFile)

try {
validateSyntaxStreamInvariants(text, result)
} catch (e: Throwable) {
throw AssertionFailedError(
/* message = */ "Fuzzer Invariant Broken: ${e.message}",
/* expected = */ "No Crash",
/* actual = */ FuzzFailureReport(text, result).toString(),
/* cause = */ e
)
}
}

private fun validateSyntaxStreamInvariants(input: String, result: ParserResult) {
// Pass 1: Walk through tokens to ensure bounds match the file and verify spacing rules
validateTokenStream(input, result)

// Pass 2: Reconstruct the structural nodes with a stack to check tree soundness
validateTreeStructure(input, result)

// Pass 3: Ensure diagnostic error ranges align accurately inside the source file boundaries
validateDiagnosticRanges(input, result)
}

private fun validateTokenStream(input: String, result: ParserResult) {
var currentOffset = 0

for (event in result.stream.events) {
if (event is TokenEvent) {
val tokenWidth = event.width
val tokenKind = event.kind

// Rule: Tokens must never cross past the end of the text
assertTrue(currentOffset + tokenWidth <= input.length) {
"Token $tokenKind leaked out of bounds! Offset: $currentOffset, Width: $tokenWidth, File Total: ${input.length}"
}

val tokenText = input.substring(currentOffset, currentOffset + tokenWidth)

// Rule: Keywords, identifiers, and symbols must not accidentally swallow spaces or newlines
if (tokenText.isNotEmpty() && !tokenKind.canEndWithWhitespace()) {
val lastChar = tokenText.last()
val isWhitespace = ScannerUtils.isHorizontalWhitespace(lastChar) ||
ScannerUtils.isVerticalWhitespace(lastChar)

assertTrue(!isWhitespace) {
"Structural token $tokenKind with text [${escapeTrivia(tokenText)}] mistakenly included trailing whitespace!"
}
}

currentOffset += tokenWidth
}
}

// Rule: The pipeline must consume the entire input string completely
assertEquals(input.length, currentOffset) {
"The parser stopped early and did not consume the full file text! Remainder offset: $currentOffset"
}
}

private fun validateTreeStructure(input: String, result: ParserResult) {
val widthStack = ArrayDeque<Int>()

for ((index, event) in result.stream.events.withIndex()) {
when (event) {
is TokenEvent -> {
widthStack.addLast(event.width)
}

is NodeEvent -> {
// Rule: A node cannot declare more children than what is available on the stack
assertTrue(widthStack.size >= event.childCount) {
"Tree structure is corrupt! Node needs ${event.childCount} children, but stack only has ${widthStack.size} items."
}

var accumulatedSubtreeWidth = 0
repeat(event.childCount) {
accumulatedSubtreeWidth += widthStack.removeLast()
}

// Rule: Verify that bottom-up stack width matches the tree API response
val apiComputedWidth = result.stream.computeWidthAt(index)
assertEquals(apiComputedWidth, accumulatedSubtreeWidth) {
"Width desynchronization detected at index ${index}! Stack accumulated $accumulatedSubtreeWidth but API reported $apiComputedWidth."
}

// Pass the combined width up to the parent node context
widthStack.addLast(accumulatedSubtreeWidth)
}
}
}

// Rule: A successful parse must finish with exactly one unified root node representing the entire file
assertEquals(1, widthStack.size) {
"The syntax stream did not resolve into a single root node! Leftover items on stack: ${widthStack.size}"
}

// Rule: The combined width of the tree must equal the length of the input text
assertEquals(input.length, widthStack.first()) {
"The sum of node widths (${widthStack.first()}) does not match the actual file size (${input.length})!"
}
}

private fun validateDiagnosticRanges(input: String, result: ParserResult) {
for (diag in result.diagnostics) {
assertAll(
"Diagnostic boundary validation",
{ assertTrue(diag.startPosition >= 0) { "Diagnostic error index is negative: ${diag.startPosition}" } },
{ assertTrue(diag.startPosition + diag.width <= input.length) { "Diagnostic error range extends past the file length! End: ${diag.startPosition + diag.width}, File length: ${input.length}" } }
)
}
}
}

/**
* Returns true if the token type can naturally end with whitespace.
* Includes formatting padding, comments, and literal text blocks.
*/
private fun TokenKind.canEndWithWhitespace(): Boolean = when (this) {
TokenKind.Whitespace,
TokenKind.Newline,
TokenKind.Comment,
TokenKind.DocComment,
TokenKind.TopLevelDocComment,
TokenKind.StringLiteral,
TokenKind.MultilineStringPart,
TokenKind.CharLiteral -> true

else -> false
}

/**
* Replaces raw terminal whitespaces and hidden null indicators with
* explicit code representations to ensure visible layout verification logs.
*/
private fun escapeTrivia(str: String): String = buildString(capacity = str.length * 2) {
for (c in str) {
when (c) {
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
'\u0000' -> append("\\u0000")
else -> append(c)
}
}
}

private data class FuzzFailureReport(
val rawInput: String,
val result: ParserResult
) {
override fun toString(): String {
val offsets = result.stream.events.scan(0) { currentOffset, event ->
currentOffset + if (event is TokenEvent) event.width else 0
}

val eventStreamBlock = result.stream.events.withIndex().joinToString(separator = "\n") { (index, event) ->
val currentOffset = offsets[index]
when (event) {
is TokenEvent -> {
val endOffset = currentOffset + event.width
val textChunk = if (endOffset <= rawInput.length) rawInput.substring(currentOffset, endOffset) else "CRITICAL_OUT_OF_BOUNDS"
" [%3d] TOKEN : %-25s | Width: %2d | Range: [%2d..%2d] | Text: [%s]".format(
index, event.kind, event.width, currentOffset, endOffset, escapeTrivia(textChunk)
)
}

is NodeEvent -> {
" [%3d] NODE : %-25s | Declared Child Count: %d".format(
index, event.kind, event.childCount
)
}
}
}

val diagnosticsBlock = result.diagnostics.withIndex()
.map { (index, diag) ->
" [%2d] CODE: %-30s | Range: [%2d..%2d] | Width: %2d".format(
index, diag.code, diag.startPosition, diag.endPosition, diag.width
)
}
.ifEmpty { listOf(" None (No errors or warnings captured)") }
.joinToString(separator = "\n")

return """
|
|=== FUZZER CRASH DETECTED ===
|File Size (Chars) : ${rawInput.length}
|Escaped File Content : [${escapeTrivia(rawInput)}]
|
|--- PARSER EVENT STREAM ---
|$eventStreamBlock
|
|--- DETECTED DIAGNOSTICS ---
|$diagnosticsBlock
|===============================
""".trimMargin()
}
}
Loading