// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! zuucode: encoding and decoding of the classic Unix `uuencode` format. //! //! The two halves live in `uuencode` and `uudecode`; the names that matter are //! re-exported here, so `@import("zuucode")` is normally all a consumer needs. //! //! Encoding a complete file, `begin` and `end` lines included: //! //! ``` //! const text = try zuucode.encodeFileAlloc(gpa, .{ .name = "cat.bin" }, payload, .{}); //! defer gpa.free(text); //! ``` //! //! Decoding it again: //! //! ``` //! const file = try zuucode.decodeFileAlloc(gpa, text, .{}); //! defer gpa.free(file.data); //! ``` //! //! Neither direction allocates unless asked to: `encode` and `decode` write //! into a caller-provided buffer, `Encoder` streams to an `Io.Writer`, and //! `decodeStream` streams from an `Io.Reader`. const std = @import("std"); pub const uuencode = @import("uuencode.zig"); pub const uudecode = @import("uudecode.zig"); pub const simd = @import("simd.zig"); /// Maximum number of payload bytes carried by one uuencoded line. pub const bytes_per_line = uuencode.bytes_per_line; /// Number of characters in a full data line, excluding the line terminator. pub const chars_per_line = uuencode.chars_per_line; /// Whether the vectorised kernels are in use on this target. pub const simd_available = simd.available; pub const Charset = uuencode.Charset; pub const LineEnding = uuencode.LineEnding; pub const Header = uuencode.Header; pub const Encoder = uuencode.Encoder; pub const EncodeOptions = uuencode.Options; pub const lineLen = uuencode.lineLen; pub const encodedLen = uuencode.encodedLen; pub const encodeLine = uuencode.encodeLine; pub const encodeLineScalar = uuencode.encodeLineScalar; pub const encode = uuencode.encode; pub const encodeAlloc = uuencode.encodeAlloc; pub const encodeStream = uuencode.encodeStream; pub const encodeFile = uuencode.encodeFile; pub const encodeFileAlloc = uuencode.encodeFileAlloc; pub const DecodeError = uudecode.Error; pub const DecodeOptions = uudecode.Options; pub const File = uudecode.File; pub const StreamHeader = uudecode.StreamHeader; pub const decodeChar = uudecode.decodeChar; pub const decodedLen = uudecode.decodedLen; pub const decodedLenUpperBound = uudecode.decodedLenUpperBound; pub const decodeLine = uudecode.decodeLine; pub const decodeLineScalar = uudecode.decodeLineScalar; pub const decode = uudecode.decode; pub const decodeAlloc = uudecode.decodeAlloc; pub const decodeFile = uudecode.decodeFile; pub const decodeFileAlloc = uudecode.decodeFileAlloc; pub const decodeStream = uudecode.decodeStream; pub const decodeFileStream = uudecode.decodeFileStream; pub const parseBeginLine = uudecode.parseBeginLine; pub const isEndLine = uudecode.isEndLine; const testing = std.testing; const Io = std.Io; test { _ = uuencode; _ = uudecode; _ = simd; _ = @import("fuzz.zig"); } // Expected values below come from Python's `binascii.b2a_uu`, which uses the // historic space charset. test "encodeLine matches the reference encoder" { var buf: [chars_per_line]u8 = undefined; const options: EncodeOptions = .{ .charset = .space }; try testing.expectEqualStrings("#0V%T", encodeLine(&buf, "Cat", options)); try testing.expectEqualStrings(" ", encodeLine(&buf, "", options)); try testing.expectEqualStrings("!80 ", encodeLine(&buf, "a", options)); try testing.expectEqualStrings("\"86( ", encodeLine(&buf, "ab", options)); try testing.expectEqualStrings("#86)C", encodeLine(&buf, "abc", options)); try testing.expectEqualStrings("# ", encodeLine(&buf, "\x00\x00\x00", options)); try testing.expectEqualStrings( "::'1T<#HO+W=W=RYW:6MI<&5D:6$N;W)G#0H ", encodeLine(&buf, "http://www.wikipedia.org\r\n", options), ); var full: [bytes_per_line]u8 = undefined; for (&full, 0..) |*byte, i| byte.* = @intCast(i); try testing.expectEqualStrings( "M $\" P0%!@<(\"0H+# T.#Q 1$A,4%187&!D:&QP='A\\@(2(C)\"4F)R@I*BLL", encodeLine(&buf, &full, options), ); } test "backtick charset replaces every encoded zero" { var buf: [chars_per_line]u8 = undefined; try testing.expectEqualStrings("!80``", encodeLine(&buf, "a", .{})); try testing.expectEqualStrings("#````", encodeLine(&buf, "\x00\x00\x00", .{})); // A space in the payload is not an encoded zero, so it is left alone. try testing.expectEqualStrings("!(```", encodeLine(&buf, " ", .{})); } test "lineLen and encodedLen agree with what is written" { for (0..bytes_per_line + 1) |n| { var buf: [chars_per_line]u8 = undefined; const source: [bytes_per_line]u8 = @splat('x'); try testing.expectEqual(lineLen(n), encodeLine(&buf, source[0..n], .{}).len); } const gpa = testing.allocator; for ([_]usize{ 0, 1, 44, 45, 46, 90, 91, 1000 }) |n| { for ([_]LineEnding{ .lf, .crlf }) |line_ending| { const options: EncodeOptions = .{ .line_ending = line_ending }; const source = try gpa.alloc(u8, n); defer gpa.free(source); @memset(source, 'z'); const text = try encodeAlloc(gpa, source, options); defer gpa.free(text); try testing.expectEqual(encodedLen(n, options), text.len); } } } test "encodeFile produces a complete uuencoded file" { const gpa = testing.allocator; const text = try encodeFileAlloc(gpa, .{ .name = "cat.txt", .mode = 0o644 }, "Cat", .{}); defer gpa.free(text); try testing.expectEqualStrings("begin 644 cat.txt\n#0V%T\n`\nend\n", text); } test "encodeFile honours mode, name, charset, and line ending" { const gpa = testing.allocator; const text = try encodeFileAlloc( gpa, .{ .name = "my file.bin", .mode = 0o600 }, "a", .{ .charset = .space, .line_ending = .crlf }, ); defer gpa.free(text); try testing.expectEqualStrings("begin 600 my file.bin\r\n!80 \r\n \r\nend\r\n", text); } test "round trip across every payload length up to several lines" { const gpa = testing.allocator; var source: [200]u8 = undefined; for (&source, 0..) |*byte, i| byte.* = @truncate(i *% 7 +% 13); for (0..source.len + 1) |n| { for ([_]Charset{ .backtick, .space }) |charset| { for ([_]LineEnding{ .lf, .crlf }) |line_ending| { const options: EncodeOptions = .{ .charset = charset, .line_ending = line_ending }; const text = try encodeAlloc(gpa, source[0..n], options); defer gpa.free(text); const decoded = try decodeAlloc(gpa, text, .{ .require_terminator = true }); defer gpa.free(decoded); try testing.expectEqualSlices(u8, source[0..n], decoded); } } } } test "round trip of a payload covering every byte value" { const gpa = testing.allocator; var source: [256]u8 = undefined; for (&source, 0..) |*byte, i| byte.* = @intCast(i); const text = try encodeFileAlloc(gpa, .{ .name = "all-bytes" }, &source, .{}); defer gpa.free(text); const file = try decodeFileAlloc(gpa, text, .{ .require_terminator = true }); defer gpa.free(file.data); try testing.expectEqualStrings("all-bytes", file.name); try testing.expectEqual(@as(u32, 0o644), file.mode); try testing.expectEqualSlices(u8, &source, file.data); } test "Encoder buffers payload handed over in uneven chunks" { const gpa = testing.allocator; var source: [512]u8 = undefined; for (&source, 0..) |*byte, i| byte.* = @truncate(i *% 31); for ([_]usize{ 1, 2, 7, 44, 45, 46, 128 }) |chunk_len| { var allocating: Io.Writer.Allocating = .init(gpa); defer allocating.deinit(); var encoder: Encoder = .init(&allocating.writer, .{}); try encoder.begin(.{ .name = "chunks" }); var offset: usize = 0; while (offset < source.len) : (offset += chunk_len) { try encoder.write(source[offset..@min(offset + chunk_len, source.len)]); } try encoder.end(); const expected = try encodeFileAlloc(gpa, .{ .name = "chunks" }, &source, .{}); defer gpa.free(expected); try testing.expectEqualStrings(expected, allocating.written()); } } test "decode into a caller buffer reports an exact length" { const gpa = testing.allocator; const payload = "the quick brown fox jumps over the lazy dog, twice over in fact"; const text = try encodeAlloc(gpa, payload, .{}); defer gpa.free(text); try testing.expectEqual(payload.len, try decodedLen(text, .{})); try testing.expect(decodedLenUpperBound(text.len) >= payload.len); var buf: [128]u8 = undefined; try testing.expectEqualStrings(payload, try decode(&buf, text, .{})); var too_small: [10]u8 = undefined; try testing.expectError(error.NoSpaceLeft, decode(&too_small, text, .{})); } test "decoder accepts both charsets and both line endings" { var buf: [64]u8 = undefined; try testing.expectEqualStrings("Cat", try decode(&buf, "#0V%T\n \n", .{})); try testing.expectEqualStrings("Cat", try decode(&buf, "#0V%T\r\n`\r\n", .{})); try testing.expectEqualStrings("a", try decode(&buf, "!80 \n \n", .{})); try testing.expectEqualStrings("a", try decode(&buf, "!80``\n`\n", .{})); } test "decoder tolerates lines whose trailing spaces were stripped" { var buf: [64]u8 = undefined; // "!80 " lost both padding spaces, and the terminator lost its space. try testing.expectEqualStrings("a", try decode(&buf, "!80\n\n", .{})); try testing.expectError( error.TruncatedLine, decode(&buf, "!80\n\n", .{ .pad_truncated_lines = false }), ); } test "strict mode pins how many characters a line must carry" { var buf: [64]u8 = undefined; const strict: DecodeOptions = .{ .pad_truncated_lines = false }; // "a" encodes to a length character plus one full group of four. try testing.expectEqualStrings("a", try decode(&buf, "!80``\n`\n", strict)); // Exactly one character short must be rejected rather than quietly padded, // which is the boundary the padding option turns on and off. try testing.expectError(error.TruncatedLine, decode(&buf, "!80`\n`\n", strict)); try testing.expectEqualStrings("a", try decode(&buf, "!80`\n`\n", .{})); } test "decoder ignores characters past the declared length" { var buf: [64]u8 = undefined; // A line padded out to the full 61 columns still decodes to three bytes. try testing.expectEqualStrings("Cat", try decode(&buf, "#0V%T" ++ &@as([56]u8, @splat(' ')) ++ "\n`\n", .{})); try testing.expectEqual(@as(usize, 3), try decodeLine(&buf, "#0V%Textra", .{})); try testing.expectEqualStrings("Cat", buf[0..3]); } test "decode stops at the terminator and ignores what follows" { var buf: [64]u8 = undefined; try testing.expectEqualStrings("Cat", try decode(&buf, "#0V%T\n`\n#86)C\n`\n", .{})); try testing.expectEqualStrings("Cat", try decode(&buf, "#0V%T\nend\ntrailing junk\n", .{})); } test "decode rejects malformed input" { var buf: [64]u8 = undefined; // 0x7f is outside the 0x20...0x60 alphabet. try testing.expectError(error.InvalidCharacter, decode(&buf, "#0V%\x7f\n`\n", .{})); // '`' + 1 would be a length character of 65, which no line can carry. try testing.expectError(error.InvalidCharacter, decode(&buf, "a0V%T\n`\n", .{})); // 'X' is a length character of 56, above the 45 byte maximum. try testing.expectError(error.InvalidLength, decode(&buf, "X0V%T\n`\n", .{})); try testing.expectError( error.MissingTerminator, decode(&buf, "#0V%T\n", .{ .require_terminator = true }), ); } test "parseBeginLine and isEndLine" { try testing.expect(parseBeginLine("not a header") == null); try testing.expect(parseBeginLine("begin") == null); try testing.expect(parseBeginLine("begin 644") == null); try testing.expect(parseBeginLine("begin 9x9 name") == null); try testing.expect(parseBeginLine("beginning 644 name") == null); const simple = parseBeginLine("begin 644 cat.txt").?; try testing.expectEqual(@as(u32, 0o644), simple.mode); try testing.expectEqualStrings("cat.txt", simple.name); // Names may contain spaces, and stray whitespace is forgiven. const spaced = parseBeginLine("begin 0600 my file.bin \r").?; try testing.expectEqual(@as(u32, 0o600), spaced.mode); try testing.expectEqualStrings("my file.bin", spaced.name); try testing.expect(isEndLine("end")); try testing.expect(isEndLine("end\r")); try testing.expect(isEndLine(" end ")); try testing.expect(!isEndLine("ending")); } test "decodeFile skips whatever precedes the begin line" { const gpa = testing.allocator; const text = \\Here is the file you asked for. \\ \\begin 755 script.sh \\#0V%T \\` \\end \\ ; const file = try decodeFileAlloc(gpa, text, .{ .require_terminator = true }); defer gpa.free(file.data); try testing.expectEqualStrings("script.sh", file.name); try testing.expectEqual(@as(u32, 0o755), file.mode); try testing.expectEqualStrings("Cat", file.data); try testing.expectError(error.MissingBeginLine, decodeFileAlloc(gpa, "#0V%T\n`\n", .{})); } test "streaming decode from a reader" { const gpa = testing.allocator; var source: [1000]u8 = undefined; for (&source, 0..) |*byte, i| byte.* = @truncate(i *% 11 +% 3); const text = try encodeFileAlloc(gpa, .{ .name = "streamed", .mode = 0o600 }, &source, .{}); defer gpa.free(text); var reader: Io.Reader = .fixed(text); var allocating: Io.Writer.Allocating = .init(gpa); defer allocating.deinit(); const header = try decodeFileStream(&reader, &allocating.writer, .{ .require_terminator = true }); try testing.expectEqualStrings("streamed", header.name()); try testing.expectEqual(@as(u32, 0o600), header.mode); try testing.expectEqualSlices(u8, &source, allocating.written()); }