No description
  • Zig 98.8%
  • Nix 1.2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jeffrey C. Ollie 4125bb6469
All checks were successful
test / test (push) Successful in 4m34s
test / docs (push) Successful in 2m13s
Pin the checkout action to a commit
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
2026-08-30 20:40:11 -05:00
.forgejo/workflows Pin the checkout action to a commit 2026-08-30 20:40:11 -05:00
LICENSES Add SPDX headers and REUSE metadata 2026-08-30 14:02:20 -05:00
src Add a -Dsimd build flag 2026-08-30 20:35:29 -05:00
.gitignore Add SPDX headers and REUSE metadata 2026-08-30 14:02:20 -05:00
build.zig Add a -Dsimd build flag 2026-08-30 20:35:29 -05:00
build.zig.zon Add SPDX headers and REUSE metadata 2026-08-30 14:02:20 -05:00
flake.lock Add uuencode encoding and decoding library 2026-08-30 13:56:21 -05:00
flake.nix Add CI workflow, API docs step, and flake metadata 2026-08-30 14:25:35 -05:00
README.md Add a -Dsimd build flag 2026-08-30 20:35:29 -05:00
REUSE.toml Add SPDX headers and REUSE metadata 2026-08-30 14:02:20 -05:00

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.