// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! Encoding side of the classic Unix `uuencode` format. //! //! A uuencoded stream is a sequence of lines. Each line begins with a single //! character giving the number of payload bytes it carries, followed by that //! payload written as groups of three bytes expanded into four printable //! characters. Every six-bit group is emitted as `group + 0x20`, which keeps //! the whole encoding inside the printable ASCII range. The body ends with a //! line whose length character is zero. //! //! A complete uuencoded file additionally carries a `begin ` //! header line and a trailing `end` line, which is what `Encoder.begin` and //! `Encoder.end` write. const std = @import("std"); const Io = std.Io; const Allocator = std.mem.Allocator; const assert = std.debug.assert; const simd = @import("simd.zig"); /// Maximum number of payload bytes a single uuencoded line can carry. This is /// the value every historical implementation uses: 45 bytes expand to exactly /// 60 characters, which together with the length character makes a 61 column /// line. pub const bytes_per_line: usize = 45; /// Number of characters in a full data line, excluding the line terminator. pub const chars_per_line: usize = lineLen(bytes_per_line); /// Which character represents a six-bit group whose value is zero. /// /// The two choices decode identically, because decoders mask the character /// down to six bits and `' ' & 0o77 == '`' & 0o77 == 0`. pub const Charset = enum { /// Emit a backtick for zero, as BSD `uuencode` does. This is the default /// because it survives transports that strip trailing whitespace. backtick, /// Emit a space for zero, as the original 1980 implementation does. space, /// The character this charset uses for a six-bit group of zero. pub fn zeroChar(charset: Charset) u8 { return switch (charset) { .backtick => '`', .space => ' ', }; } fn encodeSix(charset: Charset, six: u6) u8 { return if (six == 0) charset.zeroChar() else @as(u8, six) + ' '; } }; /// Line terminator written after each encoded line. pub const LineEnding = enum { lf, crlf, pub fn bytes(line_ending: LineEnding) []const u8 { return switch (line_ending) { .lf => "\n", .crlf => "\r\n", }; } }; pub const Options = struct { charset: Charset = .backtick, line_ending: LineEnding = .lf, }; /// The metadata carried by a `begin` line. pub const Header = struct { /// File name. May contain spaces; it runs to the end of the `begin` line. name: []const u8, /// Unix permission bits, written in octal. mode: u32 = 0o644, }; /// Number of characters, excluding the line terminator, produced for a data /// line carrying `payload_len` bytes. pub fn lineLen(payload_len: usize) usize { assert(payload_len <= bytes_per_line); return 1 + 4 * ((payload_len + 2) / 3); } /// Number of bytes `encode` writes for a payload of `source_len` bytes, /// including line terminators and the zero-length terminator line, but /// excluding any `begin` and `end` lines. pub fn encodedLen(source_len: usize, options: Options) usize { const eol = options.line_ending.bytes().len; const full_lines = source_len / bytes_per_line; const rest = source_len % bytes_per_line; var total = full_lines * (chars_per_line + eol); if (rest != 0) total += lineLen(rest) + eol; return total + 1 + eol; } /// Encodes up to `bytes_per_line` payload bytes as one data line, without a /// line terminator. Returns the portion of `dest` that was written. /// /// A full line is handed to the vector kernel in `simd.zig` on targets that /// have the registers for it; everything else, including every short final /// line, takes the scalar path. Both produce the same bytes. /// /// Asserts `source.len <= bytes_per_line` and that `dest` has room for /// `lineLen(source.len)` bytes. pub fn encodeLine(dest: []u8, source: []const u8, options: Options) []u8 { if (simd.available and source.len == bytes_per_line) { assert(dest.len >= chars_per_line); dest[0] = options.charset.encodeSix(@intCast(source.len)); switch (options.charset) { inline else => |charset| simd.encodeFullLine( dest[1..][0..simd.chars_per_full_line], source[0..bytes_per_line], charset, ), } return dest[0..chars_per_line]; } return encodeLineScalar(dest, source, options); } /// The scalar reference implementation of `encodeLine`, always available and /// always taken for short lines. Kept separate so the vector kernel has /// something to be checked and benchmarked against. pub fn encodeLineScalar(dest: []u8, source: []const u8, options: Options) []u8 { const len = lineLen(source.len); assert(dest.len >= len); const charset = options.charset; dest[0] = charset.encodeSix(@intCast(source.len)); var read: usize = 0; var written: usize = 1; while (read < source.len) : (read += 3) { // Bytes past the end of `source` are encoded as zero, which is what // makes the last line of a payload round-trip: the length character // tells the decoder how much of the final group to keep. const b0: u32 = source[read]; const b1: u32 = if (read + 1 < source.len) source[read + 1] else 0; const b2: u32 = if (read + 2 < source.len) source[read + 2] else 0; const group = (b0 << 16) | (b1 << 8) | b2; dest[written + 0] = charset.encodeSix(@truncate(group >> 18)); dest[written + 1] = charset.encodeSix(@truncate(group >> 12)); dest[written + 2] = charset.encodeSix(@truncate(group >> 6)); dest[written + 3] = charset.encodeSix(@truncate(group)); written += 4; } assert(written == len); return dest[0..len]; } /// Encodes `source` as a uuencode body: data lines followed by the /// zero-length terminator line. No `begin` or `end` line is written. /// /// Asserts `dest` has room for `encodedLen(source.len, options)` bytes. pub fn encode(dest: []u8, source: []const u8, options: Options) []u8 { const total = encodedLen(source.len, options); assert(dest.len >= total); const eol = options.line_ending.bytes(); var read: usize = 0; var written: usize = 0; while (read < source.len) : (read += bytes_per_line) { const chunk = source[read..@min(read + bytes_per_line, source.len)]; written += encodeLine(dest[written..], chunk, options).len; @memcpy(dest[written..][0..eol.len], eol); written += eol.len; } dest[written] = options.charset.zeroChar(); written += 1; @memcpy(dest[written..][0..eol.len], eol); written += eol.len; assert(written == total); return dest[0..written]; } /// Same as `encode`, allocating the result. Caller owns the returned memory. pub fn encodeAlloc(gpa: Allocator, source: []const u8, options: Options) Allocator.Error![]u8 { const dest = try gpa.alloc(u8, encodedLen(source.len, options)); return encode(dest, source, options); } /// Encodes `source` as a uuencode body directly to `writer`. pub fn encodeStream(writer: *Io.Writer, source: []const u8, options: Options) Io.Writer.Error!void { var encoder: Encoder = .init(writer, options); try encoder.write(source); try encoder.flush(); } /// Encodes `source` as a complete uuencoded file: a `begin` line, the body, /// and an `end` line. pub fn encodeFile( writer: *Io.Writer, header: Header, source: []const u8, options: Options, ) Io.Writer.Error!void { var encoder: Encoder = .init(writer, options); try encoder.begin(header); try encoder.write(source); try encoder.end(); } /// Same as `encodeFile`, allocating the result. Caller owns the returned /// memory. pub fn encodeFileAlloc( gpa: Allocator, header: Header, source: []const u8, options: Options, ) Allocator.Error![]u8 { var allocating: Io.Writer.Allocating = .init(gpa); defer allocating.deinit(); encodeFile(&allocating.writer, header, source, options) catch return error.OutOfMemory; return allocating.toOwnedSlice(); } /// Incremental encoder. Payload can be handed over in arbitrarily sized /// chunks; the encoder buffers whatever does not fill a complete line. /// /// To produce a bare body call `write` as many times as needed and then /// `flush`. To produce a complete file, call `begin` first and finish with /// `end` instead of `flush`. pub const Encoder = struct { writer: *Io.Writer, options: Options, /// Payload bytes left over from previous `write` calls, not yet enough to /// fill a line. pending: [bytes_per_line]u8, pending_len: usize, pub const Error = Io.Writer.Error; pub fn init(writer: *Io.Writer, options: Options) Encoder { return .{ .writer = writer, .options = options, .pending = undefined, .pending_len = 0, }; } /// Writes the `begin ` header line. pub fn begin(encoder: *Encoder, header: Header) Error!void { try encoder.writer.print("begin {o} {s}{s}", .{ header.mode, header.name, encoder.options.line_ending.bytes(), }); } /// Encodes more payload. Bytes that do not complete a line are buffered /// until a later `write`, `flush`, or `end` call. pub fn write(encoder: *Encoder, payload: []const u8) Error!void { var rest = payload; if (encoder.pending_len != 0) { const take = @min(bytes_per_line - encoder.pending_len, rest.len); @memcpy(encoder.pending[encoder.pending_len..][0..take], rest[0..take]); encoder.pending_len += take; rest = rest[take..]; if (encoder.pending_len < bytes_per_line) return; try encoder.writeLine(&encoder.pending); encoder.pending_len = 0; } while (rest.len >= bytes_per_line) { try encoder.writeLine(rest[0..bytes_per_line]); rest = rest[bytes_per_line..]; } @memcpy(encoder.pending[0..rest.len], rest); encoder.pending_len = rest.len; } /// Writes any buffered payload followed by the zero-length terminator /// line, completing the body. Call at most once per encoder; it does not /// flush the underlying `Io.Writer`. pub fn flush(encoder: *Encoder) Error!void { if (encoder.pending_len != 0) { try encoder.writeLine(encoder.pending[0..encoder.pending_len]); encoder.pending_len = 0; } try encoder.writer.writeByte(encoder.options.charset.zeroChar()); try encoder.writer.writeAll(encoder.options.line_ending.bytes()); } /// `flush` followed by the `end` trailer line. pub fn end(encoder: *Encoder) Error!void { try encoder.flush(); try encoder.writer.writeAll("end"); try encoder.writer.writeAll(encoder.options.line_ending.bytes()); } fn writeLine(encoder: *Encoder, chunk: []const u8) Error!void { var buf: [chars_per_line]u8 = undefined; try encoder.writer.writeAll(encodeLine(&buf, chunk, encoder.options)); try encoder.writer.writeAll(encoder.options.line_ending.bytes()); } };