Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .devcontainer/Containerfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ USER code
RUN curl -LsSf https://astral.sh/uv/install.sh | sh

# Update path for uv and zig from extension
ENV PATH=${PATH}:/home/code/.local/bin:/home/code/.cursor-server/data/User/globalStorage/ziglang.vscode-zig/zig/x86_64-linux-0.15.2
ENV PATH=${PATH}:/home/code/.local/bin:/home/code/.cursor-server/data/User/globalStorage/ziglang.vscode-zig/zig/x86_64-linux-0.16.0
2 changes: 1 addition & 1 deletion .github/actions/setup-zig/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ runs:
# build.zig.zon may come from an untrusted PR checkout; only accept a plain semver triple.
version=$(grep -m1 'minimum_zig_version' build.zig.zon | sed 's/.*"\(.*\)".*/\1/')
if [[ -z "${version}" ]] || [[ ! "${version}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::minimum_zig_version must be a semver triple (e.g. 0.15.2)" >&2
echo "::error::minimum_zig_version must be a semver triple (e.g. 0.16.0)" >&2
exit 1
fi

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ legacy
*fuzzer.c
*fuzzer.o
*fuzzer.seed
*.pyc
*.pyc
zig-pkg
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Absolution lets you specify an invariant for a program’s global state and fuzz

## Requirements

- **Zig 0.15.2** (per `build.zig.zon`)
- **Zig 0.16.0** (per `build.zig.zon`)
- **C toolchain** with libFuzzer support (e.g., `clang -fsanitize=fuzzer`)
- **objcopy** (GNU binutils or `llvm-objcopy`) — for static symbol redefinition

Expand Down
2 changes: 1 addition & 1 deletion USAGE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Usage Guide

This guide covers the full workflow for using absolution to fuzz C programs with invariant-constrained global state.
It assumes absolution is already built (`zig build`) or installed; see [README.md](README.md) for requirements (Zig 0.15.2, C toolchain with libFuzzer, objcopy).
It assumes absolution is already built (`zig build`) or installed; see [README.md](README.md) for requirements (Zig 0.16.0, C toolchain with libFuzzer, objcopy).

## Overview

Expand Down
10 changes: 5 additions & 5 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
.name = .absolution,
.version = "1.0.0",
.fingerprint = 0x552b58a887ba4c67, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.2",
.minimum_zig_version = "0.16.0",
.dependencies = .{
.clap = .{
.url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.11.0.tar.gz",
.hash = "clap-0.11.0-oBajB-HnAQDPCKYzwF7rO3qDFwRcD39Q0DALlTSz5H7e",
.url = "https://github.com/Hejsil/zig-clap/archive/refs/tags/0.12.0.tar.gz",
.hash = "clap-0.12.0-oBajB7foAQDqlSwaSG5g0yq7xGbQARUsBk5T64gAOqP5",
},
.aro = .{
.url = "git+https://github.com/0pendev/arocc.git#patch/absolution",
.hash = "aro-0.0.0-JSD1QgjGJwBL69KhefByfs65F9tvNMQyZUSNSJ6use33",
.url = "git+https://github.com/0pendev/arocc.git?ref=fix/normalize_include_path#3ad92489f67880a8abfda834becb1ee3bfd461cd",
.hash = "aro-0.0.0-JSD1Qk3RNgBojOzTztXJ8O-jya-6o41uvMuEgS51Ibd6",
},
},
.paths = .{
Expand Down
127 changes: 63 additions & 64 deletions scripts/integration.zig
Original file line number Diff line number Diff line change
Expand Up @@ -21,45 +21,39 @@ const reset = "\x1b[0m";

const tmp_base = "/tmp/absolution-integration-tests";

pub fn main() !void {
var gpa_impl = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa_impl.deinit();
const gpa = gpa_impl.allocator();

var arena_impl: std.heap.ArenaAllocator = .init(gpa);
defer arena_impl.deinit();
const arena = arena_impl.allocator();
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
const io = init.io;
const arena = init.arena.allocator();

// 0. Parse optional filter from argv
const process_args = try std.process.argsAlloc(gpa);
defer std.process.argsFree(gpa, process_args);
// process_args[0] is the binary itself; anything after is a filter
const process_args = try init.minimal.args.toSlice(arena);
const filter: ?[]const u8 = if (process_args.len > 1) process_args[1] else null;
if (filter) |f| out(bold ++ "Filter: " ++ reset ++ "{s}\n", .{f});
if (filter) |f| out(io, bold ++ "Filter: " ++ reset ++ "{s}\n", .{f});

// 1. Build absolution
out(bold ++ "Building absolution..." ++ reset ++ "\n", .{});
try buildAbsolution(gpa);
out(io, bold ++ "Building absolution..." ++ reset ++ "\n", .{});
try buildAbsolution(gpa, io);

std.fs.cwd().access("zig-out/bin/absolution", .{}) catch {
out(red ++ "absolution binary not found at zig-out/bin/absolution" ++ reset ++ "\n", .{});
std.Io.Dir.cwd().access(io, "zig-out/bin/absolution", .{}) catch {
out(io, red ++ "absolution binary not found at zig-out/bin/absolution" ++ reset ++ "\n", .{});
std.process.exit(1);
};

// 2. Discover tests
var cases: std.ArrayList(TestCase) = .empty;
try discoverTests(arena, &cases);
try discoverTests(arena, io, &cases);
std.mem.sort(TestCase, cases.items, {}, struct {
fn lt(_: void, a: TestCase, b: TestCase) bool {
return std.mem.order(u8, a.test_id, b.test_id) == .lt;
}
}.lt);

out("Collected " ++ bold ++ "{d}" ++ reset ++ " test(s)\n\n", .{cases.items.len});
out(io, "Collected " ++ bold ++ "{d}" ++ reset ++ " test(s)\n\n", .{cases.items.len});

// 3. Prepare temp directory (clean slate each run)
std.fs.deleteTreeAbsolute(tmp_base) catch {};
try std.fs.cwd().makePath(tmp_base);
std.Io.Dir.deleteDirAbsolute(io, tmp_base) catch {};
try std.Io.Dir.cwd().createDirPath(io, tmp_base);

// 4. Run tests
var passed: usize = 0;
Expand All @@ -73,29 +67,29 @@ pub fn main() !void {
}
filtered_count += 1;
if (tc.skip_reason) |reason| {
out(" " ++ yellow ++ "SKIP" ++ reset ++ " {s} " ++ dim ++ "({s})" ++ reset ++ "\n", .{ tc.test_id, reason });
out(io, " " ++ yellow ++ "SKIP" ++ reset ++ " {s} " ++ dim ++ "({s})" ++ reset ++ "\n", .{ tc.test_id, reason });
skipped += 1;
continue;
}

if (runOneTest(gpa, arena, tc, idx)) {
out(" " ++ green ++ "PASS" ++ reset ++ " {s}\n", .{tc.test_id});
if (runOneTest(gpa, arena, io, tc, idx)) {
out(io, " " ++ green ++ "PASS" ++ reset ++ " {s}\n", .{tc.test_id});
passed += 1;
} else |err| {
out(" " ++ red ++ "FAIL" ++ reset ++ " {s} " ++ dim ++ "({s})" ++ reset ++ "\n", .{ tc.test_id, @errorName(err) });
out(io, " " ++ red ++ "FAIL" ++ reset ++ " {s} " ++ dim ++ "({s})" ++ reset ++ "\n", .{ tc.test_id, @errorName(err) });
failed += 1;
}
}

// 5. Summary
if (filter != null and filtered_count == 0) {
out(red ++ "No tests matched filter" ++ reset ++ "\n", .{});
out(io, red ++ "No tests matched filter" ++ reset ++ "\n", .{});
std.process.exit(1);
}
out("\n" ++ bold ++ "{d}" ++ reset ++ " passed", .{passed});
if (failed > 0) out(", " ++ bold ++ red ++ "{d} failed" ++ reset, .{failed});
if (skipped > 0) out(", " ++ bold ++ yellow ++ "{d} skipped" ++ reset, .{skipped});
out("\n", .{});
out(io, "\n" ++ bold ++ "{d}" ++ reset ++ " passed", .{passed});
if (failed > 0) out(io, ", " ++ bold ++ red ++ "{d} failed" ++ reset, .{failed});
if (skipped > 0) out(io, ", " ++ bold ++ yellow ++ "{d} skipped" ++ reset, .{skipped});
out(io, "\n", .{});

if (failed > 0) std.process.exit(1);
}
Expand All @@ -119,16 +113,20 @@ const TestCase = struct {
// Build
// -----------------------------------------------------------------------

fn buildAbsolution(allocator: std.mem.Allocator) !void {
var child = std.process.Child.init(&.{ "zig", "build", "install" }, allocator);
const term = try child.spawnAndWait();
switch (term) {
.Exited => |code| if (code != 0) {
out(red ++ "Build failed (exit code {d})" ++ reset ++ "\n", .{code});
fn buildAbsolution(allocator: std.mem.Allocator, io: std.Io) !void {
const result = try std.process.run(allocator, io, .{
.argv = &.{ "zig", "build", "install" },
});
defer allocator.free(result.stdout);
defer allocator.free(result.stderr);

switch (result.term) {
.exited => |code| if (code != 0) {
out(io, red ++ "Build failed (exit code {d})" ++ reset ++ "\n", .{code});
std.process.exit(1);
},
else => {
out(red ++ "Build terminated abnormally" ++ reset ++ "\n", .{});
out(io, red ++ "Build terminated abnormally" ++ reset ++ "\n", .{});
std.process.exit(1);
},
}
Expand All @@ -138,18 +136,18 @@ fn buildAbsolution(allocator: std.mem.Allocator) !void {
// Test discovery
// -----------------------------------------------------------------------

fn discoverTests(arena: std.mem.Allocator, cases: *std.ArrayList(TestCase)) !void {
const cwd = std.fs.cwd();
var tests_dir = cwd.openDir("tests", .{ .iterate = true }) catch |err| {
fn discoverTests(arena: std.mem.Allocator, io: std.Io, cases: *std.ArrayList(TestCase)) !void {
const cwd = std.Io.Dir.cwd();
var tests_dir = cwd.openDir(io, "tests", .{ .iterate = true }) catch |err| {
std.debug.print("Cannot open tests/ directory: {s}\n", .{@errorName(err)});
return err;
};
defer tests_dir.close();
defer tests_dir.close(io);

var walker = try tests_dir.walk(arena);
defer walker.deinit();

while (try walker.next()) |entry| {
while (try walker.next(io)) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.basename, ".c")) continue;

Expand All @@ -170,7 +168,7 @@ fn discoverTests(arena: std.mem.Allocator, cases: *std.ArrayList(TestCase)) !voi
const test_id = try std.fmt.allocPrint(arena, "{s}/{s}", .{ parent_name, entry.basename });

// Skip marker
if (fileExists(cwd, skip_path)) {
if (fileExists(cwd, io, skip_path)) {
try cases.append(arena, .{
.c_path = c_path,
.golden_path = golden_path,
Expand All @@ -182,16 +180,16 @@ fn discoverTests(arena: std.mem.Allocator, cases: *std.ArrayList(TestCase)) !voi
}

// No golden file → not a test
if (!fileExists(cwd, golden_path)) continue;
if (!fileExists(cwd, io, golden_path)) continue;

// .flags sidecar (extra compiler flags after --)
const flags_path = try std.fmt.allocPrint(arena, "{s}.flags", .{c_path});
const flags: []const []const u8 = readNonCommentLines(arena, cwd, flags_path) catch &.{};
const flags: []const []const u8 = readNonCommentLines(arena, cwd, io, flags_path) catch &.{};

// .targets sidecar (explicit target list, or fall back to the .c file itself)
const targets_path = try std.fmt.allocPrint(arena, "{s}.targets", .{c_path});
const targets: []const []const u8 = blk: {
const lines = readNonCommentLines(arena, cwd, targets_path) catch {
const lines = readNonCommentLines(arena, cwd, io, targets_path) catch {
const one = try arena.alloc([]const u8, 1);
one[0] = c_path;
break :blk one;
Expand All @@ -205,7 +203,7 @@ fn discoverTests(arena: std.mem.Allocator, cases: *std.ArrayList(TestCase)) !voi

// .in sidecar (invariant constraint file)
const inv_path = try std.fmt.allocPrint(arena, "{s}.in", .{c_path});
const invariant_path: ?[]const u8 = if (fileExists(cwd, inv_path)) inv_path else null;
const invariant_path: ?[]const u8 = if (fileExists(cwd, io, inv_path)) inv_path else null;

try cases.append(arena, .{
.c_path = c_path,
Expand All @@ -226,11 +224,12 @@ fn discoverTests(arena: std.mem.Allocator, cases: *std.ArrayList(TestCase)) !voi
fn runOneTest(
gpa: std.mem.Allocator,
arena: std.mem.Allocator,
io: std.Io,
tc: TestCase,
idx: usize,
) !void {
const test_dir = try std.fmt.allocPrint(arena, tmp_base ++ "/{d}", .{idx});
try std.fs.cwd().makePath(test_dir);
try std.Io.Dir.cwd().createDirPath(io, test_dir);

const out_zon = try std.fmt.allocPrint(arena, "{s}/out.zon", .{test_dir});
const out_fuzzer = try std.fmt.allocPrint(arena, "{s}/fuzzer.c", .{test_dir});
Expand All @@ -253,15 +252,15 @@ fn runOneTest(
}

// 1. Run absolution
try execCapture(gpa, argv.items);
try execCapture(gpa, io, argv.items);

// 2. Compile generated fuzzer.c
try execCapture(gpa, &.{ "zig", "cc", "-c", out_fuzzer, "-o", out_obj, "-I", tc.dir_path });
try execCapture(gpa, io, &.{ "zig", "cc", "-c", out_fuzzer, "-o", out_obj, "-I", tc.dir_path });

// 3. Golden-file comparison
const actual = try std.fs.cwd().readFileAlloc(gpa, out_zon, 10 * 1024 * 1024);
const actual = try std.Io.Dir.cwd().readFileAlloc(io, out_zon, gpa, .limited(10 * 1024 * 1024));
defer gpa.free(actual);
const expected = try std.fs.cwd().readFileAlloc(gpa, tc.golden_path, 10 * 1024 * 1024);
const expected = try std.Io.Dir.cwd().readFileAlloc(io, tc.golden_path, gpa, .limited(10 * 1024 * 1024));
defer gpa.free(expected);

if (!std.mem.eql(u8, actual, expected)) {
Expand All @@ -275,11 +274,11 @@ fn runOneTest(
// Subprocess helpers
// -----------------------------------------------------------------------

fn execCapture(gpa: std.mem.Allocator, argv: []const []const u8) !void {
const result = std.process.Child.run(.{
.allocator = gpa,
fn execCapture(gpa: std.mem.Allocator, io: std.Io, argv: []const []const u8) !void {
const result = std.process.run(gpa, io, .{
.argv = argv,
.max_output_bytes = 10 * 1024 * 1024,
.stdout_limit = .limited(10 * 1024 * 1024),
.stderr_limit = .limited(10 * 1024 * 1024),
}) catch |err| {
std.debug.print(" Failed to spawn:", .{});
for (argv) |arg| std.debug.print(" {s}", .{arg});
Expand All @@ -290,13 +289,13 @@ fn execCapture(gpa: std.mem.Allocator, argv: []const []const u8) !void {
defer gpa.free(result.stderr);

switch (result.term) {
.Exited => |code| {
.exited => |code| {
if (code != 0) {
std.debug.print(" Command exited with code {d}:", .{code});
for (argv) |arg| std.debug.print(" {s}", .{arg});
std.debug.print("\n", .{});
if (result.stderr.len > 0) {
std.debug.print(" {s}\n", .{std.mem.trimRight(u8, result.stderr, "\n")});
std.debug.print(" {s}\n", .{std.mem.trimEnd(u8, result.stderr, "\n")});
}
return error.CommandFailed;
}
Expand All @@ -315,25 +314,25 @@ fn execCapture(gpa: std.mem.Allocator, argv: []const []const u8) !void {
// -----------------------------------------------------------------------

/// Write formatted text to stdout. Errors are silently discarded.
fn out(comptime fmt: []const u8, args: anytype) void {
const stdout = std.fs.File.stdout();
fn out(io: std.Io, comptime fmt: []const u8, args: anytype) void {
const stdout = std.Io.File.stdout();
var buf: [8192]u8 = undefined;
const str = std.fmt.bufPrint(&buf, fmt, args) catch return;
stdout.writeAll(str) catch {};
stdout.writeStreamingAll(io, str) catch {};
}

// -----------------------------------------------------------------------
// File helpers
// -----------------------------------------------------------------------

fn fileExists(dir: std.fs.Dir, path: []const u8) bool {
dir.access(path, .{}) catch return false;
fn fileExists(dir: std.Io.Dir, io: std.Io, path: []const u8) bool {
dir.access(io, path, .{}) catch return false;
return true;
}

/// Read non-empty, non-comment lines from a file. Returns error on missing file.
fn readNonCommentLines(arena: std.mem.Allocator, dir: std.fs.Dir, path: []const u8) ![]const []const u8 {
const content = try dir.readFileAlloc(arena, path, 10 * 1024 * 1024);
fn readNonCommentLines(arena: std.mem.Allocator, dir: std.Io.Dir, io: std.Io, path: []const u8) ![]const []const u8 {
const content = try dir.readFileAlloc(io, path, arena, .limited(10 * 1024 * 1024));
var lines: std.ArrayList([]const u8) = .empty;
var iter = std.mem.splitScalar(u8, content, '\n');
while (iter.next()) |line| {
Expand Down
Loading