No description
  • Zig 96.3%
  • Nix 3.7%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 1519c3a40b
All checks were successful
test / test (push) Successful in 6m57s
test / docs (push) Successful in 6m49s
Build with Zig 0.17
Take Zig 0.17.0 from nixpkgs and drop the patched 0.16 test runner, which
0.17 no longer needs. Compile both test binaries with LLVM so that Zig's
own fuzzer gets coverage; `zig build fuzz --fuzz` now works.

Port what 0.17 changed: passthrough args, the docs server reading the
generated directory, `**` replaced by @splat, the enum builtins renamed,
and `zig build` no longer taking --global-cache-dir. Element.enumerated
read is_exhaustive and fields, which 0.17's Type.Enum no longer has; no
test instantiated it with an exhaustive enum, so one does now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HGnbbt9Gi9YZeo6qnXQGWH
2026-10-08 18:11:13 -05:00
.forgejo/workflows Extract the Basic Encoding Rules from zig-ldap 2026-09-12 09:58:44 -05:00
LICENSES Extract the Basic Encoding Rules from zig-ldap 2026-09-12 09:58:44 -05:00
src Build with Zig 0.17 2026-10-08 18:11:13 -05:00
tests Build with Zig 0.17 2026-10-08 18:11:13 -05:00
tools Build with Zig 0.17 2026-10-08 18:11:13 -05:00
.gitignore Extract the Basic Encoding Rules from zig-ldap 2026-09-12 09:58:44 -05:00
build.zig Build with Zig 0.17 2026-10-08 18:11:13 -05:00
build.zig.zon Build with Zig 0.17 2026-10-08 18:11:13 -05:00
flake.lock Build with Zig 0.17 2026-10-08 18:11:13 -05:00
flake.nix Build with Zig 0.17 2026-10-08 18:11:13 -05:00
package.nix Build with Zig 0.17 2026-10-08 18:11:13 -05:00
README.md Build with Zig 0.17 2026-10-08 18:11:13 -05:00
REUSE.toml Extract the Basic Encoding Rules from zig-ldap 2026-09-12 09:58:44 -05:00

zig-asn1

The Basic Encoding Rules for Zig: an encoder, a decoder, and the OBJECT IDENTIFIER type, for protocols that are defined in ASN.1 and carried in BER on a wire.

The API documentation is generated from the doc comments, which carry most of the explanation, and is published at https://jeff.jcollie.page/zig-asn1/.

It builds with Zig 0.17. For Zig 0.16, use the zig-0.16 branch or the v0.1.0 tag.

$ git clone https://git.jcollie.dev/jeff/zig-asn1.git
$ cd zig-asn1
$ nix develop -c zig build test

Where this lives

Two homes, with the same history in both.

What is here

Not the whole of X.690, and deliberately so. What is implemented is the subset that real protocols use and that can be implemented without ambiguity: definite lengths only, the primitive form for simple types, and minimal encodings throughout. LDAP narrows BER to very nearly this in RFC 4511 §5.1 and SNMP does the same in RFC 3417, so the restriction costs nothing and buys a decoder with exactly one reading of every input.

Tag, Class, Universal An identifier: its class, whether it is constructed, and its number. Tag.universal, Tag.application and Tag.context are the three constructors, and application- and context-class tags are first-class rather than an afterthought — they are how most protocols spell their own types.
Encoder Builds an encoding in memory, backpatching lengths. A definite length has to be written before the content that determines it, so begin leaves it out and end fills it in: callers write a message in reading order instead of measuring everything twice.
Decoder, Element Walks a buffer. Allocates nothing and copies nothing — every Element.content is a subslice of the bytes you handed in, which is what makes decoding a large message free.
ObjectId An OBJECT IDENTIFIER, in both of its encodings: base-128 sub-identifiers on the wire and dotted decimal as text. A fixed-capacity value rather than an allocation, with the lexicographic ordering and prefix test that walking a MIB needs.
readElementAlloc Reads exactly one element off an Io.Reader, for a stream protocol that has to know where a message ends. This is framing, not validation — see below.

Using it as a library

$ zig fetch --save git+https://git.jcollie.dev/jeff/zig-asn1.git
const asn1 = @import("asn1");

// Encoding: SEQUENCE { OBJECT IDENTIFIER, NULL }, which is an SNMP varbind.
var e: asn1.Encoder = .init(gpa);
defer e.deinit();
try e.begin(.sequence);
try e.objectId(try asn1.ObjectId.parse("1.3.6.1.2.1.1.1.0"));
try e.@"null"();
try e.end();

// Decoding, out of the bytes as they arrived.
var d: asn1.Decoder = .init(e.buffered());
var varbind = (try d.expect(.sequence)).decoder();
const oid = try varbind.objectId();
std.debug.print("{f}\n", .{oid});   // .1.3.6.1.2.1.1.1.0

What is minimal, and why it matters

The decoder rejects every encoding that has a shorter spelling of the same value: a length written in the long form when the short form would do, an integer with redundant leading 00 or ff octets, a sub-identifier or a high tag number with a leading 0x80, and — the one a fuzzer had to find — the high tag number form used for a number below 31, so that 1f 05 is refused as a second spelling of 05.

That is not pedantry about the standard. A protocol that accepts two encodings of one message is a protocol where "the bytes of the message" is not a well-defined thing, and SNMPv3 computes an HMAC over exactly that. Canonicality is a security property, so the decoder enforces it and the encoder only ever emits the one form. decodeTag composed with encodeTag is the identity on everything the decoder accepts, and that is a fuzz target rather than a hope.

Framing is not validation

readElementAlloc answers "where does this element end", so that a caller reading from a stream knows how many bytes to hold before it holds any of them. It does not promise the bytes are a legal element — it will happily frame one whose tag is non-minimally encoded, and Decoder is what then refuses it. The two are separate on purpose, and the doc comment says so, because a fuzz property that assumed otherwise is what surfaced the distinction.

Tests

$ nix develop -c zig build test --summary all
$ nix develop -c zig build fuzz --fuzz=1M
$ nix develop -c zig build fuzz-run -- --seconds 60

Unit tests live beside what they cover. tests/fuzz.zig holds properties rather than examples — that the decoder terminates on any input, that every Element.content really is a subslice of the input, and that anything which decodes re-encodes to the identical bytes. zig build fuzz --fuzz runs them under Zig's own coverage-guided fuzzer; both test binaries are compiled with LLVM even in Debug, because the self-hosted backend emits no coverage instrumentation. tools/fuzz.zig drives the same properties from a loop of our own, seeded and bounded by a count, which is what the workflow runs.

Both defects found so far were found by those properties and are described above: the non-minimal high tag number, and the framing/validation confusion.

The API documentation

$ nix develop -c zig build docs         # into zig-out/docs
$ nix develop -c zig build docs-serve   # http://127.0.0.1:8000

It has to be served rather than opened: the viewer Zig emits is WebAssembly that fetches sources.tar at runtime, which a browser refuses from a file:// page.

Licence

MIT. See LICENSES/MIT.txt.