diff --git a/CHANGELOG.md b/CHANGELOG.md index 4827c40..104e559 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ same stability guarantee. ## [Unreleased] -## [0.11.0] — 2026-07-22 +## [0.11.0] — 2026-07-23 ### Added @@ -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 diff --git a/build.zig.zon b/build.zig.zon index 998e088..a67a49a 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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. diff --git a/build.zig.zon2json-lock b/build.zig.zon2json-lock index 3009296..fe86497 100644 --- a/build.zig.zon2json-lock +++ b/build.zig.zon2json-lock @@ -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=" } } \ No newline at end of file diff --git a/src/fuzz.zig b/src/fuzz.zig index 0b4732b..458f031 100644 --- a/src/fuzz.zig +++ b/src/fuzz.zig @@ -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"); @@ -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 { @@ -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); +} diff --git a/src/markdown/frontmatter_test.zig b/src/markdown/frontmatter_test.zig index e0602a3..4318ba1 100644 --- a/src/markdown/frontmatter_test.zig +++ b/src/markdown/frontmatter_test.zig @@ -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); +} diff --git a/src/markdown/security_test.zig b/src/markdown/security_test.zig index ef80f4f..68eb98f 100644 --- a/src/markdown/security_test.zig +++ b/src/markdown/security_test.zig @@ -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)); }