Skip to content

Commit 8934da5

Browse files
committed
feat: zig-xml 0.1.0 — a lenient XML/HTML tokenizer
A small, allocation-light pull tokenizer for untrusted markup: Reader yields element open/close, text, CDATA, comment, processing-instruction, and declaration events with names and raw values borrowed from the input, and decode/decodeInto resolve HTML5 named plus numeric character references. Unterminated constructs run to end of input rather than erroring, and there is no DTD or external-entity processing, so it is safe against XXE and entity-expansion attacks. The HTML5 named-entity table is vendored from Bun (MIT); see THIRD_PARTY_NOTICES.md.
0 parents  commit 8934da5

16 files changed

Lines changed: 2925 additions & 0 deletions

.github/workflows/ci.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: ci
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
test:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v4
16+
- uses: mlugg/setup-zig@v2
17+
with:
18+
version: 0.17.0-dev.1441+d5181a9c9
19+
- run: zig fmt --check build.zig src
20+
- run: zig build test

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- 'v*'
7+
8+
permissions:
9+
contents: write
10+
11+
jobs:
12+
release:
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v6
16+
17+
- name: Create Release
18+
uses: stacksjs/action-releaser@v1.2.9
19+
env:
20+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
21+
22+
- name: Publish to Pantry
23+
uses: home-lang/pantry/packages/action@main
24+
with:
25+
publish: zig
26+
install: 'false'
27+
env:
28+
PANTRY_TOKEN: ${{ secrets.PANTRY_TOKEN }}

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.zig-cache/
2+
zig-out/
3+
pantry/
4+
.DS_Store

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Changelog
2+
3+
## 0.1.0
4+
5+
Initial release.
6+
7+
- Lenient, pull-based XML/HTML tokenizer (`Reader`) yielding element
8+
open/close, text, CDATA, comment, processing-instruction, and declaration
9+
events, borrowing all names and raw values from the input.
10+
- Quoted, single-quoted, unquoted, and valueless attribute forms.
11+
- Character-reference decoding (`decode` / `decodeInto`) over the HTML5
12+
named-entity table plus decimal and hex numeric references.
13+
- Unterminated constructs run to end of input without erroring, suiting
14+
inspection of hostile or malformed markup.

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 Chris Breuer
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# zig-xml
2+
3+
A small, lenient, allocation-light **XML/HTML tokenizer** for Zig, with HTML5
4+
character-reference decoding.
5+
6+
It is a *pull* tokenizer: you call `next()` and get one structural event at a
7+
time — element open/close, text, CDATA, comments, processing instructions, and
8+
declarations — with every name and raw value **borrowed from the input**. There
9+
is no allocation per event (attribute storage is a single reused list), and no
10+
implicit entity decoding; decoding is a separate, explicit step.
11+
12+
Leniency is deliberate. zig-xml is built to inspect untrusted, possibly
13+
malformed markup (WAF request bodies, scrapers, editor tooling) the way
14+
browsers and Go's non-strict `encoding/xml` do — not to validate
15+
well-formedness. Unterminated constructs run to end of input instead of
16+
erroring, mismatched close tags are reported verbatim, and unquoted or
17+
valueless attributes are accepted. It performs no DTD processing and no external
18+
entity resolution, so it is not exposed to XXE or entity-expansion attacks.
19+
20+
## Install
21+
22+
With [Pantry](https://github.com/home-lang/pantry):
23+
24+
```sh
25+
pantry add zig-xml
26+
```
27+
28+
Then add the module in `build.zig`:
29+
30+
```zig
31+
const xml = b.dependency("xml", .{ .target = target, .optimize = optimize });
32+
your_module.addImport("xml", xml.module("xml"));
33+
```
34+
35+
## Usage
36+
37+
```zig
38+
const std = @import("std");
39+
const xml = @import("xml");
40+
41+
pub fn main() !void {
42+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
43+
const allocator = gpa.allocator();
44+
45+
var reader = xml.Reader.init(allocator, "<a href=\"?x=1&amp;y=2\">hello</a>");
46+
defer reader.deinit();
47+
48+
while (try reader.next()) |event| switch (event) {
49+
.open, .self_closing => |element| {
50+
std.debug.print("<{s}>\n", .{element.name});
51+
for (element.attributes) |attr| {
52+
// Attribute values are raw; decode when you need the text.
53+
const value = try xml.decode(allocator, attr.value);
54+
defer allocator.free(value);
55+
std.debug.print(" {s} = {s}\n", .{ attr.name, value });
56+
}
57+
},
58+
.text => |raw| {
59+
const text = try xml.decode(allocator, raw);
60+
defer allocator.free(text);
61+
std.debug.print("text: {s}\n", .{text});
62+
},
63+
.close => |name| std.debug.print("</{s}>\n", .{name}),
64+
else => {},
65+
};
66+
}
67+
```
68+
69+
Attribute slices (and the `Element.attributes` list) are valid only until the
70+
next call to `next()` — copy anything you need to retain.
71+
72+
## API
73+
74+
- `Reader.init(allocator, input) Reader` / `reader.deinit()`
75+
- `reader.next() !?Event`
76+
- `Event``open` / `self_closing` (`Element`), `close` (name), `text`,
77+
`cdata`, `comment`, `processing_instruction`, `declaration`
78+
- `Element{ name, attributes }`, with `element.attribute(name) ?[]const u8`
79+
- `decode(allocator, raw) ![]u8` / `decodeInto(&list, allocator, raw) !void`
80+
- `entity.lookup(name) ?[2]u21` — the underlying HTML5 named-entity table
81+
82+
## License
83+
84+
MIT. The bundled HTML5 named-entity table is vendored from Bun (MIT) — see
85+
[THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).

SECURITY.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
# Security policy
2+
3+
## Reporting a vulnerability
4+
5+
Please report suspected vulnerabilities privately to the maintainers via a
6+
GitHub security advisory on this repository. Do not open a public issue for
7+
security-sensitive reports.
8+
9+
## Scope
10+
11+
zig-xml is a lenient tokenizer intended to inspect untrusted, possibly hostile
12+
markup. It performs no allocation per event, runs unterminated constructs to
13+
end of input rather than erroring, and never executes or resolves external
14+
entities (there is no DTD processing and no network or file access), so it is
15+
not subject to XML external-entity (XXE) or billion-laughs entity-expansion
16+
attacks. Reports of input that causes a crash, unbounded memory growth, or a
17+
non-terminating loop are in scope.

THIRD_PARTY_NOTICES.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Third-party notices
2+
3+
## HTML5 named-entity table (`src/entity.zig`)
4+
5+
Vendored from [Bun](https://github.com/oven-sh/bun) `src/md/entity.zig`
6+
(upstream SHA `fd0b6f1a271fca0b8124b69f230b100f4d636af6`), which is MIT
7+
licensed. Only the pure, std-only entity data and its binary-search `lookup`
8+
were taken; no other Bun code is included.
9+
10+
```
11+
MIT License
12+
13+
Copyright (c) Bun contributors
14+
15+
Permission is hereby granted, free of charge, to any person obtaining a copy
16+
of this software and associated documentation files (the "Software"), to deal
17+
in the Software without restriction, including without limitation the rights
18+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
19+
copies of the Software, and to permit persons to whom the Software is
20+
furnished to do so, subject to the following conditions:
21+
22+
The above copyright notice and this permission notice shall be included in all
23+
copies or substantial portions of the Software.
24+
25+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
26+
```

build.zig

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
const std = @import("std");
2+
3+
pub fn build(b: *std.Build) void {
4+
const target = b.standardTargetOptions(.{});
5+
const optimize = b.standardOptimizeOption(.{});
6+
7+
// The importable module, exposed to consumers as `xml`.
8+
const mod = b.addModule("xml", .{
9+
.root_source_file = b.path("src/root.zig"),
10+
.target = target,
11+
.optimize = optimize,
12+
});
13+
14+
const tests = b.addTest(.{
15+
.root_module = b.createModule(.{
16+
.root_source_file = b.path("src/root.zig"),
17+
.target = target,
18+
.optimize = optimize,
19+
}),
20+
});
21+
const run_tests = b.addRunArtifact(tests);
22+
const test_step = b.step("test", "Run unit tests");
23+
test_step.dependOn(&run_tests.step);
24+
25+
const check = b.addTest(.{ .root_module = mod });
26+
const check_step = b.step("check", "Compile the public module");
27+
check_step.dependOn(&check.step);
28+
}

build.zig.zon

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
.{
2+
.name = .zig_xml,
3+
.version = "0.1.0",
4+
.minimum_zig_version = "0.17.0-dev.1441+d5181a9c9",
5+
.fingerprint = 0x8c46bb51c5b7fc2e, // Changing this has security and trust implications.
6+
.paths = .{
7+
"README.md",
8+
"LICENSE",
9+
"SECURITY.md",
10+
"THIRD_PARTY_NOTICES.md",
11+
"build.zig",
12+
"build.zig.zon",
13+
"src",
14+
},
15+
}

0 commit comments

Comments
 (0)