- Zig 98.8%
- Nix 1.2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Replaces actions/checkout@v4 with the full code.forgejo.org URL at v7.0.1, pinned to commit 3d3c42e with the tag in a trailing comment, so that a moving tag cannot change what CI executes. v7.0.1 is the newest tag on the instance, released 2026-07-17; the moving v7 tag points at the same commit. Note that it declares a node24 runtime, where v4 did not, so the runner image has to provide one. The pin has to be maintained by hand, because the pinact in the dev shell only understands bare GitHub owner/repo references and errors out on the full URLs Forgejo needs for actions hosted elsewhere. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MXGhGpSNDfzzwtBZc4aRvM |
||
| .forgejo/workflows | ||
| LICENSES | ||
| src | ||
| .gitignore | ||
| build.zig | ||
| build.zig.zon | ||
| flake.lock | ||
| flake.nix | ||
| README.md | ||
| REUSE.toml | ||
zuucode
A Zig library for encoding and decoding the classic Unix uuencode format,
plus a small command line tool that uses it.
Requires Zig 0.16.
The format
A uuencoded stream is a sequence of lines. Each line starts with one character
giving the number of payload bytes it carries, followed by that payload written
as groups of three bytes expanded into four printable characters, six bits per
character with 0x20 added. A line carries at most 45 bytes, so a full line is
61 columns. The body ends with a line whose length character is zero, and a
complete file wraps the body in begin <mode> <name> and end lines:
begin 644 cat.txt
#0V%T
`
end
Two spellings of a zero six-bit group are in circulation: a space, as in the original implementation, and a backtick, as in the BSD one. They decode identically. This library emits backticks by default because they survive transports that strip trailing whitespace, and it accepts either when decoding.
Using the library
const zuucode = @import("zuucode");
// Encode a complete file, `begin` and `end` lines included.
const text = try zuucode.encodeFileAlloc(gpa, .{ .name = "cat.txt" }, payload, .{});
defer gpa.free(text);
// Decode it again.
const file = try zuucode.decodeFileAlloc(gpa, text, .{});
defer gpa.free(file.data);
Nothing allocates unless you ask it to. encode and decode write into a
buffer you provide, sized with encodedLen and decodedLen:
var buf: [64]u8 = undefined;
const body = zuucode.encode(&buf, "Cat", .{}); // "#0V%T\n`\n"
const back = try zuucode.decode(&buf, body, .{}); // "Cat"
Encoder streams to an Io.Writer, accepting payload in chunks of any size:
var encoder: zuucode.Encoder = .init(writer, .{});
try encoder.begin(.{ .name = "big.bin", .mode = 0o600 });
while (try source.next()) |chunk| try encoder.write(chunk);
try encoder.end();
decodeStream and decodeFileStream go the other way, from an Io.Reader to
an Io.Writer. The reader's buffer has to be big enough to hold one line.
Options
EncodeOptions selects the charset (.backtick or .space) and the
line_ending (.lf or .crlf).
DecodeOptions controls how forgiving the decoder is:
pad_truncated_lines(default true) accepts a data line that lost its trailing spaces in transit, padding it back out instead of rejecting it.require_terminator(default false) insists that the body end with a zero-length line, which is how you notice a payload that was cut short.
Using the tool
zuucode [options] [file]
Encodes file, or standard input, to standard output; -d decodes instead.
Run zuucode --help for the full option list.
$ printf 'Cat' | zuucode -n cat.txt
begin 644 cat.txt
#0V%T
`
end
Performance
Full lines go through vectorised kernels in src/simd.zig; short lines, which
is only ever the last one, keep to the scalar path. Both produce the same
bytes, and encodeLineScalar and decodeLineScalar remain available as the
reference implementations.
uuencode takes to SIMD better than base64 does. Base64 needs a lookup to map a
six-bit group onto its alphabet; uuencode's alphabet is contiguous, so the map
is group + 0x20 with one blend for the zero group. A line is 45 bytes, which
is exactly 15 groups and 60 characters, so a whole line fits one
@Vector(60, u8).
Measured on a Ryzen 7 5800X with zig build bench, throughput over payload
bytes for a 16 MiB payload:
| scalar | vectorised | ||
|---|---|---|---|
| encode, backtick charset | 1123 MB/s | 2782 MB/s | 2.48x |
| encode, space charset | 1120 MB/s | 2964 MB/s | 2.65x |
| decode | 650 MB/s | 1189 MB/s | 1.83x |
The benchmark also reports a scalar build with the charset and line ending fixed at comptime, which is the control: it separates the gain that comes from specialising the loop from the gain that comes from the vector instructions. Specialisation alone accounts for 1.14x of the encode figure with the backtick charset and 1.57x with the space charset, where fixing the charset deletes the zero-group branch outright.
Whether the kernels are used is decided at comptime by simd_available, from
the target's CPU features and the -Dsimd build flag, which defaults to true.
zig build -Dsimd=false forces the scalar path everywhere, which is useful for
measuring what the kernels buy and for stepping around them if a target ever
miscompiles them. The flag can only take the kernels away: it cannot enable
them on a target that has no vector registers. The thresholds were measured, not guessed: on x86
even SSE2 alone is worth 2.25x, and SSE2 is part of the x86_64 baseline, so
there is nothing to gate on there. aarch64 gates on NEON and wasm on simd128.
Anything unmeasured takes the scalar path, which is also what a build for a
target without vector registers gets, since @Vector would otherwise lower to
scalar code that is no faster than the ordinary loop.
Tests
zig build test
Use zig build test rather than running zig test on a source file directly.
The library reads the -Dsimd flag through a generated build_options module,
which only the build system supplies.
Benchmarks live in src/bench.zig and run with zig build bench; they are
built with optimizations even if none were asked for, since timings from a
Debug build mean nothing.
The suite checks encoder output against vectors from Python's
binascii.b2a_uu, round-trips every payload length up to several lines in both
charsets and both line endings, and decodes the corrupted inputs the format
tends to arrive in.
src/fuzz.zig states the properties worth checking once and drives them from
two directions. A pseudorandom sweep runs under a plain zig build test,
generating payloads, encoding them, corrupting the result, and holding the
buffered and streaming decoders to the same verdict. The same properties are
also exposed as std.testing.fuzz targets for a coverage-guided fuzzer.
Be aware that zig build test --fuzz does not work on Zig 0.16.0. The
compiler's own compiler/test_runner.zig fails to build in fuzz mode with a
StackTrace type mismatch, which reproduces on a pristine zig init project,
so it is not something this package can work around. Until that is fixed
upstream the sweep is what actually exercises the fuzz properties.