No description
  • Zig 97.5%
  • Nix 2.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-10-11 19:45:40 -05:00
.forgejo/workflows Build in CI, and push to the Nix cache 2026-10-11 19:45:40 -05:00
LICENSES Initial commit: an NZB decoder for Zig 2026-08-30 14:19:38 -05:00
src Build with Zig 0.17 2026-10-11 19:42:26 -05:00
tools Build and publish the API documentation 2026-09-12 03:54:36 -05:00
.gitignore Ignore zig-pkg/ 2026-08-30 15:57:11 -05:00
build.zig Build with Zig 0.17 2026-10-11 19:42:26 -05:00
build.zig.zon Build with Zig 0.17 2026-10-11 19:42:26 -05:00
flake.lock Build with Zig 0.17 2026-10-11 19:42:26 -05:00
flake.nix Build with Zig 0.17 2026-10-11 19:42:26 -05:00
README.md Build with Zig 0.17 2026-10-11 19:42:26 -05:00
REUSE.toml Initial commit: an NZB decoder for Zig 2026-08-30 14:19:38 -05:00

znzb

A decoder for NZB files, for Zig 0.17.

The decoder fetches nothing. An NZB names articles by Message-ID and newsgroup; retrieving them is a job for an NNTP client such as znntp, and Segment formats itself with the angle brackets those commands expect.

The API documentation is generated from the doc comments, which is where most of the explanation lives.

Installation

$ zig fetch --save git+https://git.jcollie.dev/jeff/znzb.git

For Zig 0.16, use the zig-0.16 branch, which holds the last of znzb to build with it (version 0.1.0):

$ zig fetch --save git+https://git.jcollie.dev/jeff/znzb.git#zig-0.16

Its one dependency is zxml, a small XML pull parser with no dependencies of its own — Zig's standard library has none. zig fetch resolves it over git+https; git+ssh is not a scheme Zig understands, so the dependency URL is the https form even where the push remote is ssh.

Modules

  • znzb.nzb decodes NZB 1.1 documents into a Nzb value: the <head> metadata and, per file, its poster, subject, newsgroups, and segments.
  • znzb.subject recovers the filename and part numbering that an NZB records nowhere except inside the article subject.

The XML parsing is zxml's. Depend on that package directly if what you want is the parser rather than the NZB decoder built on it.

Design

  • One arena per document. Nzb owns an arena holding every string it hands out, so the whole result is released with one deinit and nothing borrows from the input buffer after parse returns.
  • Liberal about what it accepts. Unknown elements, namespace prefixes, and extension attributes are skipped rather than rejected. Fields with no structural weight — bytes, date, poster — may be missing or malformed and come back zero, null, or empty. Only something that would leave a segment unfetchable, a missing number or Message-ID, is an error.
  • Encoding is handled. Documents declaring ISO-8859-1, as the NZB specification's own example does, are transcoded to UTF-8; the C1 range is decoded as Windows-1252, which is what mislabelled files actually contain. That, and the refusal to resolve external entities, come from zxml.
  • Fuzz target included. src/nzb.zig carries a std.testing.fuzz target that runs the decoder under the testing allocator, so zig build test replays the corpus on every run; the XML parser has its own in zxml. zig build test --fuzz fuzzes it with coverage feedback.

Usage

const std = @import("std");
const znzb = @import("znzb");

pub fn main(init: std.process.Init) !void {
    const gpa = init.gpa;

    var doc = try znzb.nzb.parse(gpa, bytes, .{});
    defer doc.deinit();

    std.debug.print("{?s}: {d} files, {d} bytes\n", .{
        doc.title(), doc.files.len, doc.totalBytes(),
    });

    for (doc.files) |file| {
        const subject = file.parsedSubject();
        std.debug.print("{s}\n", .{subject.displayName()});
        for (file.segments) |segment| {
            // Prints as <message-id>, ready for an NNTP ARTICLE command.
            std.debug.print("  {f} from {s}\n", .{ segment, file.groups[0] });
        }
    }
}

Reading straight from a stream instead of a slice:

var doc = try znzb.nzb.parseReader(gpa, reader, .limited(64 * 1024 * 1024), .{});
defer doc.deinit();

Filenames

An NZB <file> has no filename field. The name of the file its segments reassemble into exists only in the article subject, which posting software formats by convention rather than by rule. znzb.subject handles the shapes that dominate in practice:

const s = znzb.subject.parse("[1/8] - \"Some.Release.part1.rar\" yEnc (1/120)");
// s.name        == "Some.Release.part1.rar"
// s.part        == .{ .index = 1, .total = 120 }   // which segment
// s.file_index  == .{ .index = 1, .total = 8 }     // which file
// s.yenc        == true

The segment counter is what makes File.isComplete possible: an NZB records no count of its own, so a posting missing half its articles is otherwise indistinguishable from a whole one.

Command line

The package also builds a znzb binary, which decodes an NZB and summarizes it. It is a worked example of the API as much as a tool.

$ zig build run -- --list example.nzb
example.nzb: NZB
  title: Your File!
  tag: Example
  2 files, 4 segments, 1.1 MiB
  [1] abc-mr2a.r01
      2 segments of 2, 104.4 KiB
      poster: Joe Bloggs <[email protected]>
      date:   1071674882
      groups: alt.binaries.newzbin, alt.binaries.mojo

A path of - reads standard input, so a downloaded NZB can be piped straight in:

$ curl -s "$INDEXER/getnzb/abc123.nzb" | zig build run -- -l -

Testing

$ zig build test

The fuzz targets run their corpus as part of that. To fuzz them:

$ zig build test --fuzz          # until interrupted, with a web interface
$ zig build test --fuzz=1M       # a bounded run, then a report

The test binaries are always compiled with LLVM, because the self-hosted backend Debug otherwise uses emits no coverage instrumentation, and the fuzzer needs it.

Documentation

$ zig build docs        # into zig-out/docs
$ zig build docs-serve  # and read it at http://127.0.0.1:8000

It has to be served rather than opened: the viewer Zig generates fetches sources.tar and main.wasm at runtime, and a browser refuses both from a file:// page. -Ddocs-port=N moves the port. Pushing to main publishes the same output to https://jeff.jcollie.page/znzb/.

Standards

Standard Title Support in znzb
NZB 1.1 newzBin NZB The whole format: <head> metadata, and per file its poster, date, subject, groups and segments. Namespace http://www.newzbin.com/DTD/2003/nzb
XML 1.0 (5th ed.) Extensible Markup Language Parsing is zxml's; this library only walks the result
ISO/IEC 8859-1 Latin alphabet No. 1 Transcoded to UTF-8 when declared, as the NZB specification's own example does
RFC 5322 Internet Message Format The poster attribute is a From value, kept as written rather than parsed
RFC 5536 Netnews Article Format Message-IDs, which NZB stores without angle brackets and NNTP wants with them

The <file> subject is not covered by any standard. What znzb.subject recovers from it is convention among posting software, described in src/subject.zig.

Repository

The repository's home is my Forgejo instance at git.jcollie.dev/jeff/znzb, which is where CI runs.

$ git clone https://git.jcollie.dev/jeff/znzb.git

It is mirrored on Tangled at https://tangled.org/jcollie.dev/znzb, and it is also published on Radicle, a peer-to-peer forge built on git, where the copy needs no account and no server anyone has to keep running. The repository's identifier there is

rad:z23b7qppaT6qaFaj7vVYkiJUUMB3L

and this fetches it:

$ rad clone rad:z23b7qppaT6qaFaj7vVYkiJUUMB3L

Any of the three is the whole project, on the main branch, with the same history.

rad clone finds seeds through your local node's routing table rather than through a known host, so the node has to be running before it can find anything:

$ rad node start

If you already have the repository and only want to help host it, seeding it tells your node to carry a copy for others:

$ rad seed rad:z23b7qppaT6qaFaj7vVYkiJUUMB3L

License

MIT.