Something went wrong. Try again.
A Zig library and command line tool for the classic Unix uuencode format.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! Decoding side of the classic Unix `uuencode` format.//!//! Decoding is deliberately more permissive than encoding. Both the space and//! the backtick spelling of a zero six-bit group are accepted, `begin` lines//! before the body are skipped, and by default a data line that lost its//! trailing spaces in transit is padded back out rather than rejected.//!//! Note that `begin` and `end` lines can never be confused with data lines://! a data line starts with a character in the range `0x20...0x60`, and both//! `'b'` and `'e'` are above that range.
const std = @import("std");const Io = std.Io;const Allocator = std.mem.Allocator;const assert = std.debug.assert;
const uuencode = @import("uuencode.zig");const simd = @import("simd.zig");
pub const bytes_per_line = uuencode.bytes_per_line;pub const Header = uuencode.Header;
/// Longest file name `decodeFileStream` records. Names longer than this are/// truncated, because a streaming decoder has nowhere to put them.pub const max_name_len = 255;
pub const Error = error{ /// A data line contained a character outside the uuencode alphabet, /// which is `0x20...0x60`. InvalidCharacter, /// A length character claimed more than `bytes_per_line` payload bytes. InvalidLength, /// A data line ended before supplying every character its length /// character requires, and `Options.pad_truncated_lines` was false. TruncatedLine, /// The body ended without a zero-length terminator line, and /// `Options.require_terminator` was true. MissingTerminator, /// No `begin` line was found. MissingBeginLine, /// The destination buffer was too small for the decoded payload. NoSpaceLeft,};
pub const Options = struct { /// Some mail and news transports strip trailing spaces from lines, which /// truncates data lines encoded with `Charset.space`. When true, missing /// trailing characters are treated as spaces instead of being rejected. pad_truncated_lines: bool = true, /// Require the body to be closed by a zero-length line (or an `end` /// line). Turn this on to notice a payload that was cut short. require_terminator: bool = false,};
/// Decodes one character of the uuencode alphabet into its six-bit value./// Both `' '` and `'`'` decode to zero.pub fn decodeChar(char: u8) Error!u6 { if (char < ' ' or char > '`') return error.InvalidCharacter; return @truncate(char - ' ');}
/// Upper bound on the payload `text` can decode to, for sizing a buffer/// without inspecting the input. `decodedLen` gives the exact answer.pub fn decodedLenUpperBound(text_len: usize) usize { return (text_len / 4) * 3 + 3;}
/// Exact number of payload bytes `decode` will produce for `text`. Validates/// length characters but not the payload characters that follow them.pub fn decodedLen(text: []const u8, options: Options) Error!usize { _ = options; var total: usize = 0; var lines: LineIterator = .init(text); while (lines.next()) |line| { if (line.len == 0 or isEndLine(line)) break; if (parseBeginLine(line) != null) continue; const count: usize = try decodeChar(line[0]); if (count > bytes_per_line) return error.InvalidLength; if (count == 0) break; total += count; } return total;}
/// Walks the lines of a uuencoded text, stripping the carriage return of a/// CRLF ending. A trailing newline at the very end of `text` does not produce/// a final empty line, so an empty line is always a real one: with the space/// charset that is what a terminator looks like after a transport has/// stripped its trailing space.const LineIterator = struct { text: []const u8, index: usize,
fn init(text: []const u8) LineIterator { return .{ .text = text, .index = 0 }; }
fn next(it: *LineIterator) ?[]const u8 { if (it.index >= it.text.len) return null; const end = std.mem.indexOfScalarPos(u8, it.text, it.index, '\n') orelse it.text.len; const line = it.text[it.index..end]; it.index = end + 1; return std.mem.trimEnd(u8, line, "\r"); }};
/// Decodes one data line, which must not include its line terminator, into/// `dest`. Returns the number of payload bytes written; zero means the line/// was the terminator that closes a body.////// Characters after the ones the length character accounts for are ignored,/// so a line padded out to a fixed width decodes correctly.pub fn decodeLine(dest: []u8, line: []const u8, options: Options) Error!usize { if (simd.available and line.len >= 1 + simd.chars_per_full_line) { // Only a full line is worth vectorising, and only if the destination // can take all of it. A kernel that rejects the line falls through to // the scalar path, which is what turns a bad character into an error. const count: usize = try decodeChar(line[0]); if (count == bytes_per_line and dest.len >= bytes_per_line) { if (simd.decodeFullLine( dest[0..bytes_per_line], line[1..][0..simd.chars_per_full_line], )) return bytes_per_line; } } return decodeLineScalar(dest, line, options);}
/// The scalar reference implementation of `decodeLine`, always available and/// always taken for short or malformed lines. Kept separate so the vector/// kernel has something to be checked and benchmarked against.pub fn decodeLineScalar(dest: []u8, line: []const u8, options: Options) Error!usize { if (line.len == 0) return 0;
const count: usize = try decodeChar(line[0]); if (count > bytes_per_line) return error.InvalidLength; if (count == 0) return 0; if (dest.len < count) return error.NoSpaceLeft;
const body = line[1..]; if (body.len < 4 * ((count + 2) / 3) and !options.pad_truncated_lines) { return error.TruncatedLine; }
var read: usize = 0; var written: usize = 0; while (written < count) : (read += 4) { var group: u24 = 0; for (0..4) |i| { const six: u24 = if (read + i < body.len) try decodeChar(body[read + i]) else 0; group = (group << 6) | six; }
const bytes: [3]u8 = .{ @truncate(group >> 16), @truncate(group >> 8), @truncate(group), }; const take = @min(bytes.len, count - written); @memcpy(dest[written..][0..take], bytes[0..take]); written += take; }
assert(written == count); return written;}
/// Decodes a uuencode body into `dest`, stopping at the terminator line, an/// `end` line, or the end of `text`. Any `begin` line is skipped. Returns the/// portion of `dest` that was written.pub fn decode(dest: []u8, text: []const u8, options: Options) Error![]u8 { var written: usize = 0; var terminated = false;
// Note that only the carriage return of a CRLF ending is stripped: with // `Charset.space` a data line's trailing spaces are payload, not padding. var lines: LineIterator = .init(text); while (lines.next()) |line| { if (line.len == 0 or isEndLine(line)) { terminated = true; break; } if (parseBeginLine(line) != null) continue;
const count = try decodeLine(dest[written..], line, options); if (count == 0) { terminated = true; break; } written += count; }
if (options.require_terminator and !terminated) return error.MissingTerminator; return dest[0..written];}
/// Same as `decode`, allocating a result of exactly the right size. Caller/// owns the returned memory.pub fn decodeAlloc( gpa: Allocator, text: []const u8, options: Options,) (Error || Allocator.Error)![]u8 { const dest = try gpa.alloc(u8, try decodedLen(text, options)); errdefer gpa.free(dest); const decoded = try decode(dest, text, options); assert(decoded.len == dest.len); return dest;}
/// A payload together with the metadata from its `begin` line.pub const File = struct { /// Borrowed from the `begin` line of the text that was decoded; it does /// not need to be freed, but it does not outlive that text either. name: []const u8, mode: u32, data: []u8,};
/// Finds the `begin` line in `text` and decodes the body that follows it into/// `dest`.pub fn decodeFile(dest: []u8, text: []const u8, options: Options) Error!File { const found = findBegin(text) orelse return error.MissingBeginLine; return .{ .name = found.header.name, .mode = found.header.mode, .data = try decode(dest, text[found.body_start..], options), };}
/// Same as `decodeFile`, allocating the payload. Caller owns `File.data`.pub fn decodeFileAlloc( gpa: Allocator, text: []const u8, options: Options,) (Error || Allocator.Error)!File { const found = findBegin(text) orelse return error.MissingBeginLine; return .{ .name = found.header.name, .mode = found.header.mode, .data = try decodeAlloc(gpa, text[found.body_start..], options), };}
/// Parses a `begin <mode> <name>` line, which must not include its line/// terminator. Returns null if `line` is not a `begin` line.pub fn parseBeginLine(line: []const u8) ?Header { const trimmed = std.mem.trimEnd(u8, line, " \t\r"); if (!std.mem.startsWith(u8, trimmed, "begin")) return null;
var rest = trimmed["begin".len..]; if (rest.len == 0 or (rest[0] != ' ' and rest[0] != '\t')) return null; rest = std.mem.trimStart(u8, rest, " \t");
const mode_end = std.mem.indexOfAny(u8, rest, " \t") orelse return null; const mode = std.fmt.parseInt(u32, rest[0..mode_end], 8) catch return null;
// The name runs to the end of the line, so it may contain spaces. const name = std.mem.trimStart(u8, rest[mode_end..], " \t"); if (name.len == 0) return null; return .{ .name = name, .mode = mode };}
/// Whether `line` is the `end` trailer of a uuencoded file.pub fn isEndLine(line: []const u8) bool { return std.mem.eql(u8, std.mem.trim(u8, line, " \t\r"), "end");}
fn findBegin(text: []const u8) ?struct { header: Header, body_start: usize } { var lines: LineIterator = .init(text); while (lines.next()) |line| { if (parseBeginLine(line)) |header| { return .{ .header = header, .body_start = @min(lines.index, text.len) }; } } return null;}
pub const StreamError = Error || Io.Writer.Error || error{ /// A line was longer than the reader's buffer. StreamTooLong, /// The underlying reader failed. ReadFailed,};
/// Decodes a uuencode body from `reader`, writing the payload to `writer`./// Stops after the terminator line, after an `end` line, or at end of stream.////// `reader`'s buffer must be large enough to hold the longest input line.pub fn decodeStream(reader: *Io.Reader, writer: *Io.Writer, options: Options) StreamError!void { var terminated = false; var buf: [bytes_per_line]u8 = undefined;
while (try reader.takeDelimiter('\n')) |raw| { const line = std.mem.trimEnd(u8, raw, "\r"); if (line.len == 0 or isEndLine(line)) { terminated = true; break; } if (parseBeginLine(line) != null) continue;
const count = try decodeLine(&buf, line, options); if (count == 0) { terminated = true; break; } try writer.writeAll(buf[0..count]); }
if (options.require_terminator and !terminated) return error.MissingTerminator;}
/// The `begin` line metadata recovered by `decodeFileStream`. The name is/// stored inline because the reader's buffer is reused as decoding proceeds.pub const StreamHeader = struct { mode: u32, name_buffer: [max_name_len]u8, name_len: usize,
pub fn name(header: *const StreamHeader) []const u8 { return header.name_buffer[0..header.name_len]; }};
/// Skips to the `begin` line of a uuencoded file on `reader`, decodes the/// body that follows it to `writer`, and returns the header metadata.////// `reader`'s buffer must be large enough to hold the longest input line.pub fn decodeFileStream( reader: *Io.Reader, writer: *Io.Writer, options: Options,) StreamError!StreamHeader { var stream_header: StreamHeader = .{ .mode = 0, .name_buffer = undefined, .name_len = 0 };
while (true) { const raw = (try reader.takeDelimiter('\n')) orelse return error.MissingBeginLine; const header = parseBeginLine(std.mem.trimEnd(u8, raw, "\r")) orelse continue; stream_header.mode = header.mode; stream_header.name_len = @min(header.name.len, max_name_len); @memcpy( stream_header.name_buffer[0..stream_header.name_len], header.name[0..stream_header.name_len], ); break; }
try decodeStream(reader, writer, options); return stream_header;}