// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // 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 ` 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; }