Skip to content

Commit 920058f

Browse files
committed
refactor(config): delete dead parsers, real YAML support, wire fuzz harness
Three related cleanups from the audit's Phase 5: 1. Delete 5 orphaned config_parse_{ini,json,kv,lua,xml}.zig (727 LOC). They duplicated logic already live and tested inline in config_extract.zig, were never referenced by build.zig, and had silently drifted (the fuzz harness below wouldn't even compile against the current API). 2. Real YAML parser, replacing the .YAML -> parseKeyValue stub. New parseYAML handles a scoped subset (block mappings -> dotted keys, block sequences -> parent.N, scalars, quotes, comments, ---/... markers), same complexity class as parseTOML/parseLua. detectFormat now recognises YAML via a leading --- or colon-mappings with no '=' lines/sections, without stealing from INI/TOML/ENV/KeyValue. The repo-wide '8 formats' claim is now true. Detection + nested-parse tests added. 3. Wire the orphaned fuzz_config.zig into (updated to the Zig 0.15 std.testing.fuzz two-arg testOne API). Running it immediately caught a real invariant violation: some parsers emitted config fields with an empty key (a lone '=value' line, a '"":x' JSON key), which breaks getField and A2ML emission. Fixed at the single addField funnel so 'every field has a non-empty key' now holds by construction. 159 tests green (+2 YAML) plus 104 in the fuzz seed run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TaWWedv6VQqeZaPvc94keN
1 parent e4c83c0 commit 920058f

10 files changed

Lines changed: 230 additions & 741 deletions

src/interface/ffi/build.zig

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,27 @@ pub fn build(b: *std.Build) void {
205205
const behavioral_step = b.step("test-behavioral", "Run behavioral tests against in-process mock servers");
206206
behavioral_step.dependOn(&run_behavioral_tests.step);
207207

208+
// ---------------------------------------------------------------
209+
// Fuzz harnesses — exercise the config parsers on arbitrary bytes.
210+
// `zig build fuzz` runs the seed corpus once (CI-safe); add `--fuzz`
211+
// for continuous fuzzing. Previously orphaned: src/fuzz_config.zig was
212+
// never referenced by any build step.
213+
// ---------------------------------------------------------------
214+
const fuzz_mod = b.createModule(.{
215+
.root_source_file = b.path("src/fuzz_config.zig"),
216+
.target = target,
217+
.optimize = optimize,
218+
.link_libc = true,
219+
});
220+
221+
const fuzz_tests = b.addTest(.{
222+
.root_module = fuzz_mod,
223+
});
224+
225+
const run_fuzz_tests = b.addRunArtifact(fuzz_tests);
226+
const fuzz_step = b.step("fuzz", "Run config-parser fuzz harnesses (append --fuzz for continuous fuzzing)");
227+
fuzz_step.dependOn(&run_fuzz_tests.step);
228+
208229
// ---------------------------------------------------------------
209230
// Benchmarks — micro-benchmark executable (prints to stderr)
210231
// ---------------------------------------------------------------

src/interface/ffi/src/config_extract.zig

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,13 @@ pub const ParsedConfig = struct {
8585
range_max: ?f64,
8686
is_secret: bool,
8787
) !void {
88+
// A field with an empty key is meaningless (and breaks getField lookup
89+
// and A2ML emission). Malformed input — e.g. a lone "=value" line or a
90+
// JSON key of "" — must not produce one. Silently skip it; this is the
91+
// single funnel every parser routes through, so the invariant "every
92+
// field has a non-empty key" holds by construction (asserted by the
93+
// config fuzz harness).
94+
if (key.len == 0) return;
8895
try self.fields.append(.{
8996
.key = try self.allocator.dupe(u8, key),
9097
.value = try self.allocator.dupe(u8, value),
@@ -155,12 +162,18 @@ pub fn detectFormat(data: []const u8) ConfigFormat {
155162
}
156163
}
157164

165+
// YAML document marker is an unambiguous signal.
166+
if (std.mem.startsWith(u8, trimmed, "---")) return .YAML;
167+
158168
// Scan lines for format indicators
159169
var has_section_header = false;
160170
var has_export = false;
161171
var has_dotted_key = false;
162172
var has_lua_table = false;
163173
var has_triple_bracket = false;
174+
var yaml_kv_lines: usize = 0; // `key: value` or `key:` (mapping) lines
175+
var yaml_seq_lines: usize = 0; // `- item` block-sequence lines
176+
var eq_lines: usize = 0; // lines containing `=`
164177

165178
var line_iter = std.mem.splitScalar(u8, data, '\n');
166179
while (line_iter.next()) |line| {
@@ -172,6 +185,7 @@ pub fn detectFormat(data: []const u8) ConfigFormat {
172185
if (std.mem.startsWith(u8, stripped, "[[")) has_triple_bracket = true;
173186
}
174187
if (std.mem.startsWith(u8, stripped, "export ")) has_export = true;
188+
if (std.mem.indexOfScalar(u8, stripped, '=') != null) eq_lines += 1;
175189
if (std.mem.indexOf(u8, stripped, ".") != null and std.mem.indexOf(u8, stripped, "=") != null) {
176190
// Check if it looks like "section.key = value" (TOML dotted keys)
177191
if (std.mem.indexOfScalar(u8, stripped, '=')) |eq_pos| {
@@ -183,16 +197,69 @@ pub fn detectFormat(data: []const u8) ConfigFormat {
183197
if (std.mem.indexOf(u8, stripped, "= {") != null or std.mem.indexOf(u8, stripped, "={") != null) {
184198
has_lua_table = true;
185199
}
200+
// YAML block sequence: "- " or a bare "-"
201+
if (std.mem.startsWith(u8, stripped, "- ") or std.mem.eql(u8, stripped, "-")) yaml_seq_lines += 1;
202+
// YAML mapping line: "key:" or "key: value" (colon followed by space or EOL),
203+
// with no '=' on the line and a plausible bareword key.
204+
if (std.mem.indexOfScalar(u8, stripped, '=') == null) {
205+
if (std.mem.indexOfScalar(u8, stripped, ':')) |ci| {
206+
const after = stripped[ci + 1 ..];
207+
const key = stripped[0..ci];
208+
if (isYamlKey(key) and (after.len == 0 or after[0] == ' ')) yaml_kv_lines += 1;
209+
}
210+
}
186211
}
187212

188213
if (has_triple_bracket or (has_section_header and has_dotted_key)) return .TOML;
189214
if (has_lua_table) return .Lua;
190215
if (has_section_header) return .INI;
191216
if (has_export) return .ENV;
217+
// YAML: colon-delimited mappings and/or block sequences, and crucially no
218+
// `=` lines (which would make it ENV/INI/KeyValue).
219+
if (eq_lines == 0 and (yaml_kv_lines > 0 or yaml_seq_lines > 0)) return .YAML;
192220

193221
return .KeyValue;
194222
}
195223

224+
/// A YAML key is a bareword of `[A-Za-z0-9._-]` (no spaces) — enough to
225+
/// distinguish `key: value` mappings from prose lines that merely contain a
226+
/// colon (e.g. a URL or a sentence).
227+
fn isYamlKey(s: []const u8) bool {
228+
if (s.len == 0) return false;
229+
for (s) |c| {
230+
if (!(std.ascii.isAlphanumeric(c) or c == '.' or c == '_' or c == '-')) return false;
231+
}
232+
return true;
233+
}
234+
235+
/// One level of YAML block-mapping nesting: its indentation and key.
236+
const YamlEntry = struct { indent: usize, key: []const u8 };
237+
238+
/// Strip surrounding single/double quotes from a YAML scalar.
239+
fn yamlScalar(s: []const u8) []const u8 {
240+
if (s.len >= 2 and
241+
((s[0] == '"' and s[s.len - 1] == '"') or (s[0] == '\'' and s[s.len - 1] == '\'')))
242+
{
243+
return s[1 .. s.len - 1];
244+
}
245+
return s;
246+
}
247+
248+
/// Join the mapping-key path (`a`, `a.b`, …) into `buf`, returning the slice.
249+
fn buildYamlPath(buf: []u8, entries: []const YamlEntry) []const u8 {
250+
var len: usize = 0;
251+
for (entries, 0..) |e, i| {
252+
if (i != 0 and len < buf.len) {
253+
buf[len] = '.';
254+
len += 1;
255+
}
256+
const n = @min(e.key.len, buf.len - len);
257+
@memcpy(buf[len .. len + n], e.key[0..n]);
258+
len += n;
259+
}
260+
return buf[0..len];
261+
}
262+
196263
// ═══════════════════════════════════════════════════════════════════════════════
197264
// Format parsers
198265
// ═══════════════════════════════════════════════════════════════════════════════
@@ -830,6 +897,91 @@ pub fn parseKeyValue(allocator: Allocator, data: []const u8) !ParsedConfig {
830897
return config;
831898
}
832899

900+
/// Parse a YAML config — a scoped subset (block mappings, block sequences of
901+
/// scalars, scalar values, comments, `---`/`...` document markers), NOT full
902+
/// YAML 1.2. Nested mappings flatten to dotted keys (`parent.child`) and
903+
/// sequence items to `parent.0`, `parent.1`, … — the same flat model
904+
/// parseTOML/parseJSON use. Anchors, aliases, flow collections, and multi-line
905+
/// scalars are out of scope.
906+
pub fn parseYAML(allocator: Allocator, data: []const u8) !ParsedConfig {
907+
var config = ParsedConfig.init(allocator, .YAML, "");
908+
errdefer config.deinit();
909+
config.raw_text = try allocator.dupe(u8, data);
910+
911+
var stack: [16]YamlEntry = undefined;
912+
var depth: usize = 0;
913+
var seq_indent: ?usize = null;
914+
var seq_index: usize = 0;
915+
916+
var it = std.mem.splitScalar(u8, data, '\n');
917+
while (it.next()) |raw_line| {
918+
var line = raw_line;
919+
if (line.len > 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];
920+
921+
var indent: usize = 0;
922+
while (indent < line.len and line[indent] == ' ') indent += 1;
923+
const content = line[indent..];
924+
if (content.len == 0 or content[0] == '#') continue;
925+
if (std.mem.startsWith(u8, content, "---") or std.mem.startsWith(u8, content, "...")) continue;
926+
927+
// Pop mapping keys no longer in scope; reset sequence tracking on dedent.
928+
while (depth > 0 and stack[depth - 1].indent >= indent) depth -= 1;
929+
if (seq_indent) |si| {
930+
if (indent < si) {
931+
seq_indent = null;
932+
seq_index = 0;
933+
}
934+
}
935+
936+
// Block sequence item: parent.N
937+
if (std.mem.startsWith(u8, content, "- ") or std.mem.eql(u8, content, "-")) {
938+
const item = if (content.len > 2) yamlScalar(std.mem.trim(u8, content[2..], " \t")) else "";
939+
if (seq_indent == null or indent != seq_indent.?) {
940+
seq_indent = indent;
941+
seq_index = 0;
942+
}
943+
var kb: [1024]u8 = undefined;
944+
const base = buildYamlPath(&kb, stack[0..depth]);
945+
var full: [1152]u8 = undefined;
946+
const fk = std.fmt.bufPrint(&full, "{s}{s}{d}", .{
947+
base, if (base.len > 0) "." else "", seq_index,
948+
}) catch continue;
949+
try config.addField(fk, item, "string", fk, "", null, null, false);
950+
seq_index += 1;
951+
continue;
952+
}
953+
954+
// Mapping line: `key:` (parent) or `key: value` (leaf).
955+
const ci = std.mem.indexOfScalar(u8, content, ':') orelse continue;
956+
const key = std.mem.trim(u8, content[0..ci], " \t");
957+
if (!isYamlKey(key)) continue;
958+
const after = std.mem.trim(u8, content[ci + 1 ..], " \t");
959+
960+
if (after.len == 0) {
961+
if (depth < stack.len) {
962+
stack[depth] = .{ .indent = indent, .key = key };
963+
depth += 1;
964+
}
965+
continue;
966+
}
967+
968+
var kb: [1024]u8 = undefined;
969+
const base = buildYamlPath(&kb, stack[0..depth]);
970+
var full: [1152]u8 = undefined;
971+
const fk = if (base.len > 0)
972+
(std.fmt.bufPrint(&full, "{s}.{s}", .{ base, key }) catch continue)
973+
else
974+
key;
975+
const val = yamlScalar(after);
976+
const is_secret = std.mem.indexOf(u8, fk, "password") != null or
977+
std.mem.indexOf(u8, fk, "secret") != null or
978+
std.mem.indexOf(u8, fk, "token") != null;
979+
try config.addField(fk, val, "string", fk, "", null, null, is_secret);
980+
}
981+
982+
return config;
983+
}
984+
833985
/// Dispatch to the correct parser based on format.
834986
pub fn parseAuto(allocator: Allocator, data: []const u8) !ParsedConfig {
835987
const format = detectFormat(data);
@@ -841,7 +993,7 @@ pub fn parseAuto(allocator: Allocator, data: []const u8) !ParsedConfig {
841993
.TOML => parseTOML(allocator, data),
842994
.Lua => parseLua(allocator, data),
843995
.KeyValue => parseKeyValue(allocator, data),
844-
.YAML => parseKeyValue(allocator, data), // basic YAML fallback
996+
.YAML => parseYAML(allocator, data),
845997
};
846998
}
847999

src/interface/ffi/src/config_parse_ini.zig

Lines changed: 0 additions & 63 deletions
This file was deleted.

0 commit comments

Comments
 (0)