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
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ same stability guarantee.

## [Unreleased]

## [0.11.0] — 2026-07-22
## [0.11.0] — 2026-07-23

### Added

Expand Down Expand Up @@ -55,6 +55,31 @@ same stability guarantee.
the definition and synthesis paths. CommonMark/GFM spec conformance is
unchanged (652/652 + 24/24).

### Fixed

- **Front-matter YAML plain scalars containing an interior indicator character
now parse** (`&`, `*`, `!`, and a block-sequence `-`). Previously an unquoted value such as
`name: cloud & edge` failed with `ParseFailure` and the whole document was
rejected — a `&` was mistaken for a mid-scalar anchor indicator even though
YAML only treats it as one at node start. This regularly bit titles and
descriptions (issue #81, reported downstream from PolicyPress). Fixed in the
vendored `zig-yaml` parser (bumped 0.3.1 → 0.3.2): interior
anchor/alias/tag/seq-item tokens are folded into the plain scalar as content
instead of terminating it — the same class as the 0.3.1
comment-in-plain-scalar fix. Quoting the value already worked and is
unchanged; CommonMark/GFM spec conformance is unchanged (652/652 + 24/24).

### Added

- **Front-matter round-trip fuzz oracle** (`fuzz_frontmatter_yaml_roundtrip` in
`src/fuzz.zig`). Beyond the existing no-crash targets, it asserts that a value
set into YAML front matter survives `serialize` → re-parse. Because the
emitter leaves interior indicator characters unquoted, this catches
serializer↔parser disagreements — the class of spurious `ParseFailure` that
the no-crash targets swallow — and would have caught both issue #81 and the
earlier `#`-comment case automatically. `zig build fuzz` (smoke) runs it in
CI; `zig build fuzz --fuzz` runs it coverage-guided.

## [0.10.0] — 2026-07-18

### Changed
Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@
.lazy = true,
},
.yaml = .{
.url = "git+https://github.com/sc2in/zig-yaml#7c60d9cd6ae596c8247ac74b5b8e32c329e7c1d0",
.hash = "zig_yaml-0.3.1-C1161s2zAgBOYGjCQB3uWdAadPevfdNiTaffefbhAhBq",
.url = "git+https://github.com/sc2in/zig-yaml#15d7885f277f438a3304dd241c1d9afc39e7ba76",
.hash = "zig_yaml-0.3.2-C1161q3CAgCEpqWl_MKjkQpPZmos4DHBJewpb58XMcAL",
},
},
// Specifies the set of files and directories that are included in this package.
Expand Down
6 changes: 3 additions & 3 deletions build.zig.zon2json-lock
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@
"url": "git+https://github.com/mnemnion/mvzr?ref=v0.3.10#dd0e1bd2d6b10f9650317b30baef7ab7bc9dd9ec",
"hash": "sha256-hUGdJPjC1PAm6zWL9eAE7A+wAfzf6Q7uSutT2Xe7EMU="
},
"zig_yaml-0.3.1-C1161s2zAgBOYGjCQB3uWdAadPevfdNiTaffefbhAhBq": {
"zig_yaml-0.3.2-C1161q3CAgCEpqWl_MKjkQpPZmos4DHBJewpb58XMcAL": {
"name": "yaml",
"url": "git+https://github.com/sc2in/zig-yaml#7c60d9cd6ae596c8247ac74b5b8e32c329e7c1d0",
"hash": "sha256-JAYq7B7gXd4+c60KXZ9b5IuaKLY7x7Bn9TNynyx4kog="
"url": "git+https://github.com/sc2in/zig-yaml#15d7885f277f438a3304dd241c1d9afc39e7ba76",
"hash": "sha256-kILMfi0bF69Mm7MOZZ7+DeJ+iMdOkAfGd0mR7LXK3ns="
}
}
55 changes: 54 additions & 1 deletion src/fuzz.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
//!
//! Targets take a `*std.testing.Smith` (the Zig 0.16 structured-input source)
//! and pull up to `max_input` bytes of fuzzer-chosen data via `smith.slice`.
//! Each target asserts only the absence of crashes/UB/leaks, not correctness.
//! Most targets assert only the absence of crashes/UB/leaks, not correctness.
//! The exception is `fuzz_frontmatter_yaml_roundtrip`, which asserts a
//! serializer<->parser round-trip invariant (see its comment) — that is the
//! oracle that catches spurious `ParseFailure`s such as issue #81, which are
//! graceful errors the no-crash targets swallow.

const std = @import("std");
const zigmark = @import("zigmark");
Expand Down Expand Up @@ -59,6 +63,10 @@ test "fuzz_frontmatter_zon" {
try std.testing.fuzz({}, fuzzFrontmatterZon, .{});
}

test "fuzz_frontmatter_yaml_roundtrip" {
try std.testing.fuzz({}, fuzzFrontmatterYamlRoundtrip, .{});
}

// ── Implementations ───────────────────────────────────────────────────────────

fn fuzzParse(_: void, smith: *Smith) anyerror!void {
Expand Down Expand Up @@ -163,3 +171,48 @@ fn fuzzFrontmatterZon(_: void, smith: *Smith) anyerror!void {
var fm = zigmark.Frontmatter.init(arena.allocator(), input, .zon) catch return;
fm.deinit();
}

// A value byte drawn onto this alphabet lands as a word char, a space, or one
// of the plain-scalar *indicator* characters called out in issue #81 (`&`
// anchor, `*` alias, `!` tag, `- ` seq-item). Biasing toward spaces and
// indicators makes the fuzzer hit the "space + indicator" positions that used
// to abort the YAML parse mid plain-scalar. This is deliberately scoped to the
// indicator surface; interior quote / flow-bracket round-tripping is a
// separate class not covered here.
const scalar_alphabet = "abcdefgh &*!- ";

/// Round-trip oracle: a string value set into YAML front matter must survive
/// `serialize` -> `initFromMarkdown`. zigmark's emitter leaves interior
/// indicator characters unquoted (they are legal plain-scalar content), so if
/// the parser rejects them the document zigmark just produced fails to re-parse
/// — a serializer<->parser disagreement. Unlike the no-crash targets, the
/// re-parse error is NOT swallowed: it propagates and fails the fuzz iteration,
/// reporting the offending value (this is how issue #81 is re-caught).
fn fuzzFrontmatterYamlRoundtrip(_: void, smith: *Smith) anyerror!void {
var buf: [max_input]u8 = undefined;
const raw = buf[0..smith.slice(&buf)];
for (raw) |*b| b.* = scalar_alphabet[b.* % scalar_alphabet.len];
// YAML strips leading/trailing whitespace from plain scalars, so surrounding
// spaces cannot round-trip as a plain scalar — trim them to isolate the
// indicator behaviour under test.
const value = std.mem.trim(u8, raw, " ");
if (value.len == 0) return;

var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
const alloc = arena.allocator();

// Build the document programmatically so the first parse cannot fail on
// syntax; the emitter alone decides whether `value` is quoted.
var fm = zigmark.Frontmatter.init(alloc, "seed: 1", .yaml) catch return;
defer fm.deinit();
fm.set("v", .{ .string = value }) catch return;
const out = fm.serialize(alloc) catch return;

// Re-parse zigmark's own output — this MUST succeed and preserve `value`.
var fm2 = try zigmark.Frontmatter.initFromMarkdown(alloc, out);
defer fm2.deinit();
const got = fm2.get("v") orelse return error.RoundTripLostValue;
if (got != .string) return error.RoundTripChangedType;
try std.testing.expectEqualStrings(value, got.string);
}
70 changes: 70 additions & 0 deletions src/markdown/frontmatter_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1040,3 +1040,73 @@ test "frontmatter: trailing comment keeps top-level scalar intact" {
const t = fm.get("title") orelse return error.Missing;
try tst.expectEqualStrings("My Policy", t.string);
}

test "frontmatter: YAML plain scalar with interior ampersand (issue #81)" {
// Regression: an unquoted YAML scalar containing `&` failed with
// ParseFailure before zig-yaml 0.3.2 — `&` is only an anchor indicator at
// node start, so mid-scalar it is ordinary content. Dropped a whole
// PolicyPress policy whose description read "… cloud & edge …".
const alloc = tst.allocator;
var fm = try FrontMatter.init(alloc, "name: foo & bar", .yaml);
defer fm.deinit();
try tst.expectEqualStrings("foo & bar", fm.get("name").?.string);

var fm2 = try FrontMatter.init(alloc, "name: cloud & edge & core", .yaml);
defer fm2.deinit();
try tst.expectEqualStrings("cloud & edge & core", fm2.get("name").?.string);

// The exact PolicyPress shape: a `&` inside a block-sequence mapping value.
const md =
"---\n" ++
"extra:\n" ++
" revisions:\n" ++
" - description: Initial cloud & edge policy\n" ++
"---\n" ++
"Body.\n";
var fm3 = try FrontMatter.initFromMarkdown(alloc, md);
defer fm3.deinit();
const revs = fm3.get("extra.revisions") orelse return error.Missing;
const desc = revs.array.items[0].object.get("description") orelse return error.Missing;
try tst.expectEqualStrings("Initial cloud & edge policy", desc.string);

// No space between `&` and the following word is still content, not an
// anchor — proves the fix is in the parser, not just a tokenizer
// whitespace guard.
var fm4 = try FrontMatter.init(alloc, "name: foo &bar", .yaml);
defer fm4.deinit();
try tst.expectEqualStrings("foo &bar", fm4.get("name").?.string);
}

test "frontmatter: YAML plain scalar with interior alias/tag/seq indicators (issue #81)" {
// Same class as `&`: `*` (alias), `!` (tag), and `- ` (seq-item) are only
// indicators at node start; mid plain-scalar they are content.
const alloc = tst.allocator;
const cases = [_]struct { src: []const u8, want: []const u8 }{
.{ .src = "name: a * b", .want = "a * b" },
.{ .src = "name: a ! b", .want = "a ! b" },
.{ .src = "name: foo - bar", .want = "foo - bar" },
.{ .src = "name: read & write * always", .want = "read & write * always" },
};
for (cases) |c| {
var fm = try FrontMatter.init(alloc, c.src, .yaml);
defer fm.deinit();
try tst.expectEqualStrings(c.want, fm.get("name").?.string);
}
}

test "frontmatter: YAML indicator scalar survives serialize round-trip (issue #81)" {
// Deterministic mirror of the `fuzz_frontmatter_yaml_roundtrip` oracle:
// the emitter leaves interior indicator chars unquoted, so the value must
// re-parse from zigmark's own output.
const alloc = tst.allocator;
var fm = try FrontMatter.init(alloc, "seed: 1", .yaml);
defer fm.deinit();
try fm.set("v", .{ .string = "foo & bar * baz ! qux" });

const out = try fm.serialize(alloc);
defer alloc.free(out);

var fm2 = try FrontMatter.initFromMarkdown(alloc, out);
defer fm2.deinit();
try tst.expectEqualStrings("foo & bar * baz ! qux", fm2.get("v").?.string);
}
11 changes: 7 additions & 4 deletions src/markdown/security_test.zig
Original file line number Diff line number Diff line change
Expand Up @@ -244,9 +244,12 @@ test "security: large frontmatter integer does not panic" {
}

test "security: malformed YAML frontmatter fails cleanly without leaking (#73)" {
// Upstream zig-yaml rejects a plain scalar containing an inline " - " with
// ParseFailure. The fix here guarantees the failure frees the parser's
// error bundle — testing.allocator would flag a leak otherwise.
const src = "---\nauthor: Foo - Bar Baz\n---\n\nbody\n";
// Genuinely malformed YAML (an unclosed flow sequence) must fail with
// ParseFailure, and the failure path must free the parser's error bundle —
// testing.allocator would flag a leak otherwise. (The original repro used
// `author: Foo - Bar Baz`, but that is a *valid* plain scalar that only
// failed due to the issue #81 bug; it now parses, so a real malformed
// input is used here.)
const src = "---\ntags: [a, b\n---\n\nbody\n";
try tst.expectError(error.ParseFailure, Frontmatter.initFromMarkdown(tst.allocator, src));
}
Loading