10 · Project — CI Pipeline with Sanitizers, Coverage and Fuzzing¶
This capstone assembles every technique from Level 3 into one real, buildable project with a complete GitHub Actions pipeline: a small length-prefixed message parser (the kind of code most worth fuzzing), tested at the unit level, sanitized, measured for coverage, fuzzed, and gated in CI — with the C portions actually compiled and run on this machine.
Environment note
The C parser, its unit tests, and the ThreadSanitizer-checked concurrent cache in section 4 were compiled and run on this host — their output below is real. The GoogleTest/CMake/GitHub Actions scaffolding follows the same broken-libc++/no-CI-runner limitations noted throughout this level and was checked structurally, not executed.
The system under test¶
msgparser/
include/msgparser/parser.h
src/parser.c
src/cache.c
include/msgparser/cache.h
tests/
test_parser.c
test_cache_concurrency.c
fuzz/
fuzz_parser.c
corpus/
regressions/
CMakeLists.txt
.github/workflows/ci.yml
A length-prefixed message: [1-byte type][2-byte big-endian length][payload].
Plus a small thread-safe LRU-ish cache (size-bounded, not full LRU) that
stores recently parsed message types, to give the concurrency section
something real to test.
include/msgparser/parser.h¶
#ifndef MSGPARSER_PARSER_H
#define MSGPARSER_PARSER_H
#include <stdint.h>
#include <stddef.h>
typedef struct {
uint8_t type;
uint16_t len;
const uint8_t *payload;
} message_t;
/* Returns 0 and fills *out on success; -1 on any malformed input.
Never reads past buf[0..buf_len). */
int parse_message(const uint8_t *buf, size_t buf_len, message_t *out);
#endif
src/parser.c¶
#include "msgparser/parser.h"
int parse_message(const uint8_t *buf, size_t buf_len, message_t *out) {
if (buf == NULL || out == NULL) return -1;
if (buf_len < 3) return -1; /* type + 2-byte length */
uint16_t len = (uint16_t)((uint16_t)buf[1] << 8 | buf[2]);
/* Guard against len+3 overflowing size_t on exotic platforms, and
against a declared length that overruns the actual buffer. */
if (len > buf_len - 3) return -1;
out->type = buf[0];
out->len = len;
out->payload = buf + 3;
return 0;
}
tests/test_parser.c¶
#include <assert.h>
#include <string.h>
#include "msgparser/parser.h"
static void test_rejects_short_buffer(void) {
uint8_t buf[2] = {0x01, 0x00};
message_t m;
assert(parse_message(buf, sizeof(buf), &m) == -1);
}
static void test_rejects_overrunning_length(void) {
uint8_t buf[3] = {0x01, 0xFF, 0xFF}; /* claims 65535-byte payload */
message_t m;
assert(parse_message(buf, sizeof(buf), &m) == -1);
}
static void test_parses_valid_message(void) {
uint8_t buf[6] = {0x02, 0x00, 0x03, 'a', 'b', 'c'};
message_t m;
assert(parse_message(buf, sizeof(buf), &m) == 0);
assert(m.type == 0x02);
assert(m.len == 3);
assert(memcmp(m.payload, "abc", 3) == 0);
}
static void test_rejects_null_inputs(void) {
message_t m;
assert(parse_message(NULL, 10, &m) == -1);
uint8_t buf[3] = {0, 0, 0};
assert(parse_message(buf, sizeof(buf), NULL) == -1);
}
static void test_accepts_zero_length_payload(void) {
uint8_t buf[3] = {0x05, 0x00, 0x00};
message_t m;
assert(parse_message(buf, sizeof(buf), &m) == 0);
assert(m.len == 0);
}
int main(void) {
test_rejects_short_buffer();
test_rejects_overrunning_length();
test_parses_valid_message();
test_rejects_null_inputs();
test_accepts_zero_length_payload();
return 0;
}
Running the unit tests, with sanitizers, on this host¶
gcc -std=c11 -Wall -Wextra -Iinclude \
-fsanitize=address,undefined -g \
src/parser.c tests/test_parser.c -o test_parser
./test_parser && echo "ALL UNIT TESTS PASSED (clean under ASan+UBSan)"
src/cache.c and include/msgparser/cache.h — the concurrency piece¶
#ifndef MSGPARSER_CACHE_H
#define MSGPARSER_CACHE_H
#include <stdint.h>
#include <pthread.h>
#define CACHE_SIZE 8
typedef struct {
uint8_t types[CACHE_SIZE];
size_t next_slot;
pthread_mutex_t lock;
} type_cache_t;
void cache_init(type_cache_t *c);
void cache_record(type_cache_t *c, uint8_t type);
int cache_contains(type_cache_t *c, uint8_t type);
#endif
#include "msgparser/cache.h"
#include <string.h>
void cache_init(type_cache_t *c) {
memset(c->types, 0xFF, sizeof(c->types));
c->next_slot = 0;
pthread_mutex_init(&c->lock, NULL);
}
void cache_record(type_cache_t *c, uint8_t type) {
pthread_mutex_lock(&c->lock);
c->types[c->next_slot] = type;
c->next_slot = (c->next_slot + 1) % CACHE_SIZE;
pthread_mutex_unlock(&c->lock);
}
int cache_contains(type_cache_t *c, uint8_t type) {
pthread_mutex_lock(&c->lock);
int found = 0;
for (size_t i = 0; i < CACHE_SIZE; ++i) {
if (c->types[i] == type) { found = 1; break; }
}
pthread_mutex_unlock(&c->lock);
return found;
}
tests/test_cache_concurrency.c — TSan-checked, real output¶
#include <assert.h>
#include <pthread.h>
#include "msgparser/cache.h"
static type_cache_t cache;
static void *recorder(void *arg) {
uint8_t base = *(uint8_t *)arg;
for (int i = 0; i < 1000; ++i) {
cache_record(&cache, (uint8_t)(base + (i % 4)));
cache_contains(&cache, base);
}
return NULL;
}
int main(void) {
cache_init(&cache);
pthread_t t1, t2;
uint8_t a = 1, b = 100;
pthread_create(&t1, NULL, recorder, &a);
pthread_create(&t2, NULL, recorder, &b);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
/* No assertion on final contents -- it's a ring buffer, order is
nondeterministic under concurrency by design. The property under
test is the absence of a data race, which TSan checks, not a
specific final state. */
return 0;
}
gcc -fsanitize=thread -Iinclude src/cache.c tests/test_cache_concurrency.c -o test_cache_tsan -lpthread
./test_cache_tsan && echo "PASS: cache is race-free under TSan"
fuzz/fuzz_parser.c¶
#include <stdint.h>
#include <stddef.h>
#include "msgparser/parser.h"
int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
message_t m;
parse_message(data, size, &m);
return 0;
}
As with Level 3 module 04, this host's Command Line Tools install is
missing the libFuzzer runtime (libclang_rt.fuzzer_osx.a), so this target
was not linked or run here — the CI workflow below runs it on a Linux
runner where the dependency is present via the distro's clang package.
CMakeLists.txt¶
cmake_minimum_required(VERSION 3.16)
project(msgparser C)
set(CMAKE_C_STANDARD 11)
option(ENABLE_SANITIZERS "" OFF)
option(ENABLE_COVERAGE "" OFF)
add_library(msgparser src/parser.c src/cache.c)
target_include_directories(msgparser PUBLIC include)
target_compile_options(msgparser PRIVATE -Wall -Wextra)
find_package(Threads REQUIRED)
target_link_libraries(msgparser PUBLIC Threads::Threads)
if(ENABLE_SANITIZERS)
target_compile_options(msgparser PUBLIC -fsanitize=address,undefined -g)
target_link_options(msgparser PUBLIC -fsanitize=address,undefined)
endif()
if(ENABLE_COVERAGE)
target_compile_options(msgparser PUBLIC --coverage -g -O0)
target_link_options(msgparser PUBLIC --coverage)
endif()
enable_testing()
add_executable(test_parser tests/test_parser.c)
target_link_libraries(test_parser PRIVATE msgparser)
add_test(NAME parser COMMAND test_parser)
add_executable(test_cache_concurrency tests/test_cache_concurrency.c)
target_link_libraries(test_cache_concurrency PRIVATE msgparser)
add_test(NAME cache_concurrency COMMAND test_cache_concurrency)
.github/workflows/ci.yml¶
name: CI
on: [push, pull_request]
jobs:
unit-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cmake -S . -B build
- run: cmake --build build
- run: ctest --test-dir build --output-on-failure
sanitized-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: cmake -S . -B build-san -DENABLE_SANITIZERS=ON
- run: cmake --build build-san
- run: ctest --test-dir build-san --output-on-failure
thread-sanitizer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: gcc -fsanitize=thread -Iinclude src/cache.c tests/test_cache_concurrency.c -o test_cache_tsan -lpthread
- run: ./test_cache_tsan
coverage:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y lcov
- run: cmake -S . -B build-cov -DENABLE_COVERAGE=ON
- run: cmake --build build-cov
- run: ctest --test-dir build-cov --output-on-failure
- run: |
lcov --capture --directory build-cov --output-file coverage.info --exclude '*/tests/*'
lcov --summary coverage.info
fuzz-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: clang -std=c11 -Iinclude -g -fsanitize=fuzzer,address,undefined src/parser.c fuzz/fuzz_parser.c -o fuzz_parser
- run: ./fuzz_parser -max_total_time=60 fuzz/corpus/
- run: for f in fuzz/regressions/*; do [ -e "$f" ] && ./fuzz_parser "$f"; done
Running it end to end (what's real vs. structural)¶
# Real, run on this host:
gcc -std=c11 -Wall -Wextra -Iinclude -fsanitize=address,undefined -g \
src/parser.c tests/test_parser.c -o test_parser
./test_parser
# -> ALL UNIT TESTS PASSED (clean under ASan+UBSan)
gcc -fsanitize=thread -Iinclude src/cache.c tests/test_cache_concurrency.c \
-o test_cache_tsan -lpthread
./test_cache_tsan
# -> PASS: cache is race-free under TSan
# Structural only (needs libFuzzer runtime / a working CI runner):
# ./fuzz_parser -max_total_time=60 fuzz/corpus/
How It Actually Works: why ASan and TSan can't be combined in one binary¶
Notice the pipeline above compiles two separate binaries — one with
-fsanitize=address,undefined, another with -fsanitize=thread — rather
than one binary with all three. This isn't pipeline sprawl for its own sake;
it's forced by how the sanitizers implement themselves.
- Both ASan and TSan need exclusive control of the same resources: a
large reserved region of virtual address space for their respective shadow
memory, and interception of the same allocator entry points (
malloc,free,mmap) to keep their shadow state synchronized with real allocations. ASan's shadow layout and TSan's shadow layout are each designed assuming they are the only sanitizer instrumenting the binary; running both means two independent runtimes racing to intercept the same allocator and reserve overlapping address ranges, which is why the toolchains refuse the combination outright rather than silently producing wrong shadow state. - This is also why the CI matrix in Module 6 runs jobs, not one universal build — a real pipeline needs a normal build (fast, for day-to-day correctness), an ASan+UBSan build (memory/language-rule safety), and a separate TSan build (data-race detection), because "maximum instrumentation in one binary" is not an available point on the trade-off curve; the underlying shadow-memory mechanisms are mutually exclusive by design, not just by convention.
- The
-> Structural onlyline for the fuzzer isn't a cop-out — it names a real dependency this project's host build cannot satisfy.libFuzzerrequires-fsanitize=fuzzer, which needs Clang's fuzzer runtime linked in; a host without that runtime (or a from-scratch GCC-only toolchain) simply can't produce that binary, which is exactly the same class of tooling dependency Module 4 flagged when contrasting host-only builds against a CI image built specifically to include the sanitizer/fuzzer runtimes.
Stretch goals¶
- Add a checksum byte to the message format (as suggested in module 04's
exercise), extend
test_parser.cwith cases for a corrupted checksum, and confirm the ASan/UBSan run stays clean. - Replace the ring-buffer cache with a real bounded LRU (needs a doubly linked list or an index array) and re-run the TSan concurrency test — a more complex data structure is exactly where a subtle race is more likely to hide.
- Wire the four jobs in
ci.ymlinto GitHub branch protection as required checks forunit-tests,sanitized-tests, andthread-sanitizer, leavingcoverageandfuzz-smokenon-blocking initially, per module 06's guidance. - Write a property-based test (module 05's hand-rolled style, since
RapidCheck needs libc++) asserting that for any buffer,
parse_messageeither returns -1 or returns amessage_twhosepayloadpointer pluslennever reads pastbuf + buf_len.
Completing this project means you're ready for Level 4 · Master.