Decoding Base64 in the Nix language

I came to a problem where I decided it might actually be a good idea to be able to decode a Base64 string containing ASCII inside of a Nix expression. I’m not going to bother explaining how I got to that point, as it’s mostly irrelevant, but I will say that it’s not a result of wanting to use Nix as a general-purpose programming language, just an issue I was having plumbing data between multiple things.

I was kind of surprised to find, then, that there was, as far as I can tell, no existing implementation of this anywhere. I’m pretty bad at functional programming and the Nix language in general, but I figured it would be a decent learning experience to try to make such an expression anyways. So I did. Here is my attempt, as it stands right now:

let
  # Helpers
  charAt = index: builtins.substring index 1;
  chunkBase64 = base64:
    (builtins.genList
      (index: builtins.substring (index * 4) 4 base64)
      (((builtins.stringLength base64) + 3) / 4)
    );
  concatStrings = builtins.concatStringsSep "";

  # Bitwise math
  truncateToByte = builtins.bitAnd 255;

  # ASCII decoding
  asciiTable = [
    ""  ""  ""   ""  ""  ""  ""  ""  ""  "\t" "\n" ""  ""  "\r" ""  ""  ""  ""  ""  ""  ""  ""  ""  ""  ""  ""  ""  ""  ""   ""  ""  ""
    " " "!" "\"" "#" "$" "%" "&" "'" "(" ")"  "*"  "+" "," "-"  "." "/" "0" "1" "2" "3" "4" "5" "6" "7" "8" "9" ":" ";" "<"  "=" ">" "?"
    "@" "A" "B"  "C" "D" "E" "F" "G" "H" "I"  "J"  "K" "L" "M"  "N" "O" "P" "Q" "R" "S" "T" "U" "V" "W" "X" "Y" "Z" "[" "\\" "]" "^" "_"
    "`" "a" "b"  "c" "d" "e" "f" "g" "h" "i"  "j"  "k" "l" "m"  "n" "o" "p" "q" "r" "s" "t" "u" "v" "w" "x" "y" "z" "{" "|"  "}" "~" ""
  ];
  byteToAscii = value:
    if value < 128
    then
      let
        asciiValue = builtins.elemAt asciiTable value;
      in
        if asciiValue != ""
        then
          asciiValue
        else
          builtins.abort "unsupported character code ${toString value}"
    else
      builtins.abort "unsupported non-ascii byte ${toString value}";

  # Base64 encoding
  base64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  base64Table = builtins.listToAttrs
    (map
      (character: { name = character.value; value = character.index; })
      (builtins.genList
        (index: { inherit index; value = (charAt index base64Alphabet); })
        (builtins.stringLength base64Alphabet)
      )
    );
  base64CharToValue = character:
    if builtins.hasAttr character base64Table
    then
      base64Table.${character}
    else
      builtins.abort("invalid base64 character ${character}");
  base64CalcByte = left: right: offset: chunk:
    (builtins.bitOr
      (truncateToByte ((base64CharToValue (charAt (offset) chunk)) * left))
      (truncateToByte ((base64CharToValue (charAt (offset + 1) chunk)) / right))
    );
  base64ChunkToBytes = chunk:
    if (
      ((builtins.stringLength chunk) == 2) ||
      (
        ((builtins.stringLength chunk) == 4) &&
        ((builtins.substring 2 2 chunk) == "==")
      )
    )
    then
      [
        (base64CalcByte 4 16 0 chunk)
      ]
    else
      if (
        ((builtins.stringLength chunk) == 3) ||
        (
          ((builtins.stringLength chunk) == 4) &&
          ((builtins.substring 3 1 chunk) == "=")
        )
      )
      then
        [
          (base64CalcByte 4 16 0 chunk)
          (base64CalcByte 16 4 1 chunk)
        ]
      else
        if (builtins.stringLength chunk) == 4
        then
          [
            (base64CalcByte 4 16 0 chunk)
            (base64CalcByte 16 4 1 chunk)
            (base64CalcByte 64 1 2 chunk)
          ]
        else
          builtins.abort "invalid base64 chunk ${chunk}";
  base64ToAscii = base64:
    concatStrings
      (map
        byteToAscii
        (builtins.concatMap
          base64ChunkToBytes
          (chunkBase64 base64)
        )
      );
in {
  base64Decode = base64ToAscii;
}

I have to say, I am pretty sure this is awful. That said, it does basically work.

I have a few reasons I feel enticed to post this:

  1. I thought it was an interesting challenge, given that Nix isn’t particularly well-suited to the task (Or, maybe it is and I am just ignorant.)
  2. I suspect someone may find this useful some day, so it may as well be on the Internet instead of just sitting here locally.
  3. If I’m lucky, I figure someone will kindly give me hints as to how to better utilize Nix and the functional programming paradigm in this particular case.

In any case, here it is.

6 Likes

Gave it a go myself, though mine doesn’t handle padding!

let
  lib = import <nixpkgs/lib>;
  testString = "TWFueSBoYW5kcyBtYWtlIGxpZ2h0IHdvcmsu";

  base64Table = builtins.listToAttrs
    (lib.imap0 (i: c: lib.nameValuePair c i)
    (lib.stringToCharacters "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"));

  # Generated using python3:
  # print(''.join([ chr(n) for n in range(1, 256) ]), file=open('ascii', 'w'))
  ascii = builtins.readFile ./ascii;

  decode = str:
    let
      # List of base-64 numbers
      numbers64 = map (c: base64Table.${c}) (lib.stringToCharacters str);

      # List of base-256 numbers
      numbers256 = lib.concatLists (lib.genList (i:
        let
          v = lib.foldl'
            (acc: el: acc * 64 + el)
            0
            (lib.sublist (i * 4) 4 numbers64);
        in
        [
          (lib.mod (v / 256 / 256) 256)
          (lib.mod (v / 256) 256)
          (lib.mod v 256)
        ]
      ) (lib.length numbers64 / 4));

    in
    # Converts base-256 numbers to ascii
    lib.concatMapStrings (n:
      # Can't represent the null byte in Nix..
      lib.substring (n - 1) 1 ascii
    ) numbers256;

in
decode testString
5 Likes

Nice! That looks significantly better. Thanks for sharing.

Ah, that’s also quite clever.

There seems to be a couple of tiny issues in the posted code, though. Here’s my diff.

--- a/base64.nix
+++ b/base64.nix
@@ -21,21 +21,21 @@ let
           v = lib.foldl'
             (acc: el: acc * 64 + el)
             0
-            (lib.sublist (i * 4) 4 numbers);
+            (lib.sublist (i * 4) 4 numbers64);
         in
         [
           (lib.mod (v / 256 / 256) 256)
           (lib.mod (v / 256) 256)
           (lib.mod v 256)
         ]
-      ) (lib.length numbers / 4));
+      ) (lib.length numbers64 / 4));
 
     in
-    # Converts base-265 numbers to ascii
+    # Converts base-256 numbers to ascii
     lib.concatMapStrings (n:
       # Can't represent the null byte in Nix..
       lib.substring (n - 1) 1 ascii
-    ) grouped;
+    ) numbers256;
 
 in
 decode testString

And of course, this makes for a nice exercise, as I am now thinking of how to handle padding/the remainder with this approach to Base64 decoding.

1 Like

Ahh that was stupid, I changed the names the last second without testing it again haha, thanks!

Thank you! I was also looking to do something similar, to save some bits for filenames (a sha256 in hex is 64 bytes, in base64 it’s 43). Here is my, also awful but at least pkgs.lib independent, library for encoding ascii strings or hexa. It was generated using Claude 4, but I validated the outputs against python base64 results with a test (also included below). Just leving it here in case someone finds it useful.

let
  # Helper to get character at index
  charAt = index: str: builtins.substring index 1 str;
  
  # Convert string to list of byte values (ASCII only)
  stringToBytes = str:
    let
      charToInt = c:
        if c == "\x00" then 0 else if c == "\x01" then 1 else if c == "\x02" then 2 else if c == "\x03" then 3
        else if c == "\x04" then 4 else if c == "\x05" then 5 else if c == "\x06" then 6 else if c == "\x07" then 7
        else if c == "\x08" then 8 else if c == "\x09" then 9 else if c == "\x0a" then 10 else if c == "\x0b" then 11
        else if c == "\x0c" then 12 else if c == "\x0d" then 13 else if c == "\x0e" then 14 else if c == "\x0f" then 15
        else if c == "\x10" then 16 else if c == "\x11" then 17 else if c == "\x12" then 18 else if c == "\x13" then 19
        else if c == "\x14" then 20 else if c == "\x15" then 21 else if c == "\x16" then 22 else if c == "\x17" then 23
        else if c == "\x18" then 24 else if c == "\x19" then 25 else if c == "\x1a" then 26 else if c == "\x1b" then 27
        else if c == "\x1c" then 28 else if c == "\x1d" then 29 else if c == "\x1e" then 30 else if c == "\x1f" then 31
        else if c == " " then 32 else if c == "!" then 33 else if c == "\"" then 34 else if c == "#" then 35
        else if c == "$" then 36 else if c == "%" then 37 else if c == "&" then 38 else if c == "'" then 39
        else if c == "(" then 40 else if c == ")" then 41 else if c == "*" then 42 else if c == "+" then 43
        else if c == "," then 44 else if c == "-" then 45 else if c == "." then 46 else if c == "/" then 47
        else if c == "0" then 48 else if c == "1" then 49 else if c == "2" then 50 else if c == "3" then 51
        else if c == "4" then 52 else if c == "5" then 53 else if c == "6" then 54 else if c == "7" then 55
        else if c == "8" then 56 else if c == "9" then 57 else if c == ":" then 58 else if c == ";" then 59
        else if c == "<" then 60 else if c == "=" then 61 else if c == ">" then 62 else if c == "?" then 63
        else if c == "@" then 64 else if c == "A" then 65 else if c == "B" then 66 else if c == "C" then 67
        else if c == "D" then 68 else if c == "E" then 69 else if c == "F" then 70 else if c == "G" then 71
        else if c == "H" then 72 else if c == "I" then 73 else if c == "J" then 74 else if c == "K" then 75
        else if c == "L" then 76 else if c == "M" then 77 else if c == "N" then 78 else if c == "O" then 79
        else if c == "P" then 80 else if c == "Q" then 81 else if c == "R" then 82 else if c == "S" then 83
        else if c == "T" then 84 else if c == "U" then 85 else if c == "V" then 86 else if c == "W" then 87
        else if c == "X" then 88 else if c == "Y" then 89 else if c == "Z" then 90 else if c == "[" then 91
        else if c == "\\" then 92 else if c == "]" then 93 else if c == "^" then 94 else if c == "_" then 95
        else if c == "`" then 96 else if c == "a" then 97 else if c == "b" then 98 else if c == "c" then 99
        else if c == "d" then 100 else if c == "e" then 101 else if c == "f" then 102 else if c == "g" then 103
        else if c == "h" then 104 else if c == "i" then 105 else if c == "j" then 106 else if c == "k" then 107
        else if c == "l" then 108 else if c == "m" then 109 else if c == "n" then 110 else if c == "o" then 111
        else if c == "p" then 112 else if c == "q" then 113 else if c == "r" then 114 else if c == "s" then 115
        else if c == "t" then 116 else if c == "u" then 117 else if c == "v" then 118 else if c == "w" then 119
        else if c == "x" then 120 else if c == "y" then 121 else if c == "z" then 122 else if c == "{" then 123
        else if c == "|" then 124 else if c == "}" then 125 else if c == "~" then 126 else if c == "\x7f" then 127
        else if c == "\t" then 9 else if c == "\n" then 10 else if c == "\r" then 13
        else builtins.abort "Unsupported character: ${c}";
    in
      builtins.genList (i: charToInt (charAt i str)) (builtins.stringLength str);
  
  # Convert 3 bytes to 4 base64 characters (generic function)
  bytesToBase64Chunk = alphabet: usePadding: bytes:
    let
      # Pad to 3 bytes if needed
      paddedBytes = bytes ++ (builtins.genList (_: 0) (3 - (builtins.length bytes)));
      b1 = builtins.elemAt paddedBytes 0;
      b2 = builtins.elemAt paddedBytes 1;
      b3 = builtins.elemAt paddedBytes 2;
      
      # Convert to 4 6-bit values
      v1 = builtins.bitAnd (b1 / 4) 63;
      v2 = builtins.bitAnd ((b1 * 16) + (b2 / 16)) 63;
      v3 = builtins.bitAnd ((b2 * 4) + (b3 / 64)) 63;
      v4 = builtins.bitAnd b3 63;
      
      # Convert to base64 characters
      c1 = charAt v1 alphabet;
      c2 = charAt v2 alphabet;
      c3 = if (builtins.length bytes) > 1 then charAt v3 alphabet else (if usePadding then "=" else "");
      c4 = if (builtins.length bytes) > 2 then charAt v4 alphabet else (if usePadding then "=" else "");
    in
      c1 + c2 + c3 + c4;
  
  # Split list into chunks of size n
  chunksOf = n: list:
    if (builtins.length list) == 0 then []
    else
      let
        listLen = builtins.length list;
        chunkSize = if n < listLen then n else listLen;
        chunk = builtins.genList (i: builtins.elemAt list i) chunkSize;
        restLen = listLen - n;
        rest = if restLen > 0 then builtins.genList (i: builtins.elemAt list (n + i)) restLen else [];
      in
        [chunk] ++ (chunksOf n rest);
  
  # Generic base64 encode function
  base64EncodeGeneric = alphabet: usePadding: str:
    let
      bytes = stringToBytes str;
      chunks = chunksOf 3 bytes;
      base64Chunks = map (bytesToBase64Chunk alphabet usePadding) chunks;
    in
      builtins.concatStringsSep "" base64Chunks;

  # Standard base64 encoding (RFC 4648) with padding and +/
  standardBase64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
  base64Encode = base64EncodeGeneric standardBase64Alphabet true;

  # URL-safe base64 encoding (RFC 4648) without padding and with -_
  urlSafeBase64Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
  base64UrlSafeEncode = base64EncodeGeneric urlSafeBase64Alphabet false;

  # Convert hex string to base64 (generic function)
  hexStringToBase64Generic = alphabet: usePadding: hexStr:
    let
      # Ensure hex string has even length
      paddedHexStr = if (builtins.stringLength hexStr) - ((builtins.stringLength hexStr) / 2) * 2 == 1 then "0" + hexStr else hexStr;
      hexLength = builtins.stringLength paddedHexStr;
      
      # Convert hex string to bytes
      hexPairs = builtins.genList (i: builtins.substring (i * 2) 2 paddedHexStr) (hexLength / 2);
      hexToByte = hexPair:
        let
          c1 = builtins.substring 0 1 hexPair;
          c2 = builtins.substring 1 1 hexPair;
          hexCharToInt = c:
            if c == "0" then 0 else if c == "1" then 1 else if c == "2" then 2 else if c == "3" then 3
            else if c == "4" then 4 else if c == "5" then 5 else if c == "6" then 6 else if c == "7" then 7
            else if c == "8" then 8 else if c == "9" then 9 else if c == "a" then 10 else if c == "b" then 11
            else if c == "c" then 12 else if c == "d" then 13 else if c == "e" then 14 else if c == "f" then 15
            else builtins.abort "Invalid hex character: ${c}";
        in
          (hexCharToInt c1) * 16 + (hexCharToInt c2);
      bytes = map hexToByte hexPairs;
      chunks = chunksOf 3 bytes;
      base64Chunks = map (bytesToBase64Chunk alphabet usePadding) chunks;
    in
      builtins.concatStringsSep "" base64Chunks;

  # Standard hex to base64
  hexStringToBase64 = hexStringToBase64Generic standardBase64Alphabet true;
  
  # URL-safe hex to base64
  hexStringToBase64UrlSafe = hexStringToBase64Generic urlSafeBase64Alphabet false;

in {
  # Standard base64 functions (with padding, +/)
  inherit base64Encode hexStringToBase64;
  
  # URL-safe base64 functions (no padding, -_)
  inherit base64UrlSafeEncode hexStringToBase64UrlSafe;
  
  # Generic functions for custom alphabets
  inherit base64EncodeGeneric hexStringToBase64Generic;
}
{ base64Lib ? import ./base64.nix }:

let
  # Test helper functions
  testCase = name: expected: actual: {
    inherit name expected actual;
    passed = expected == actual;
  };
  
  # Standard base64 test vectors (with padding)
  standardTestVectors = [
    { input = ""; expected = ""; }
    { input = "f"; expected = "Zg=="; }
    { input = "fo"; expected = "Zm8="; }
    { input = "foo"; expected = "Zm9v"; }
    { input = "foob"; expected = "Zm9vYg=="; }
    { input = "fooba"; expected = "Zm9vYmE="; }
    { input = "foobar"; expected = "Zm9vYmFy"; }
    { input = "Hello"; expected = "SGVsbG8="; }
    { input = "Hello World"; expected = "SGVsbG8gV29ybGQ="; }
    { input = "The quick brown fox"; expected = "VGhlIHF1aWNrIGJyb3duIGZveA=="; }
    { input = "0123456789"; expected = "MDEyMzQ1Njc4OQ=="; }
    { input = "!@#$%^&*()"; expected = "IUAjJCVeJiooKQ=="; }
  ];
  
  # URL-safe base64 test vectors (no padding)
  urlSafeTestVectors = [
    { input = ""; expected = ""; }
    { input = "f"; expected = "Zg"; }
    { input = "fo"; expected = "Zm8"; }
    { input = "foo"; expected = "Zm9v"; }
    { input = "foob"; expected = "Zm9vYg"; }
    { input = "fooba"; expected = "Zm9vYmE"; }
    { input = "foobar"; expected = "Zm9vYmFy"; }
    { input = "Hello"; expected = "SGVsbG8"; }
    { input = "Hello World"; expected = "SGVsbG8gV29ybGQ"; }
    { input = "The quick brown fox"; expected = "VGhlIHF1aWNrIGJyb3duIGZveA"; }
    { input = "0123456789"; expected = "MDEyMzQ1Njc4OQ"; }
    { input = "!@#$%^&*()"; expected = "IUAjJCVeJiooKQ"; }
  ];
  
  # Special character test cases (both variants)
  specialCharTestVectors = [
    { input = "\n"; expectedStd = "Cg=="; expectedUrl = "Cg"; }
    { input = "\t"; expectedStd = "CQ=="; expectedUrl = "CQ"; }
    { input = "\r"; expectedStd = "DQ=="; expectedUrl = "DQ"; }
    { input = " "; expectedStd = "IA=="; expectedUrl = "IA"; }
    { input = "a\nb\tc\rd"; expectedStd = "YQpiCWMNZA=="; expectedUrl = "YQpiCWMNZA"; }
  ];
  
  # Binary data test cases (using hex input)
  hexTestVectors = [
    { input = ""; expectedStd = ""; expectedUrl = ""; }
    { input = "00"; expectedStd = "AA=="; expectedUrl = "AA"; }
    { input = "ff"; expectedStd = "/w=="; expectedUrl = "_w"; }
    { input = "0000"; expectedStd = "AAA="; expectedUrl = "AAA"; }
    { input = "ffff"; expectedStd = "//8="; expectedUrl = "__8"; }
    { input = "000000"; expectedStd = "AAAA"; expectedUrl = "AAAA"; }
    { input = "ffffff"; expectedStd = "////"; expectedUrl = "____"; }
    { input = "deadbeef"; expectedStd = "3q2+7w=="; expectedUrl = "3q2-7w"; }
    { input = "cafebabe"; expectedStd = "yv66vg=="; expectedUrl = "yv66vg"; }
    { input = "0123456789abcdef"; expectedStd = "ASNFZ4mrze8="; expectedUrl = "ASNFZ4mrze8"; }
  ];
  
  # Additional hex test cases for edge cases (binary data)
  edgeHexTestVectors = [
    { input = "00"; expectedStd = "AA=="; expectedUrl = "AA"; }
    { input = "01"; expectedStd = "AQ=="; expectedUrl = "AQ"; }
    { input = "7f"; expectedStd = "fw=="; expectedUrl = "fw"; }
    { input = "0000"; expectedStd = "AAA="; expectedUrl = "AAA"; }
    { input = "0001"; expectedStd = "AAE="; expectedUrl = "AAE"; }
    { input = "ffff"; expectedStd = "//8="; expectedUrl = "__8"; }
  ];
  
  # Run standard base64 tests
  runStandardTests = testVectors: description:
    let
      results = map (tv: testCase 
        "${description} (standard): '${tv.input}'" 
        tv.expected 
        (base64Lib.base64Encode tv.input)
      ) testVectors;
    in results;
  
  # Run URL-safe base64 tests
  runUrlSafeTests = testVectors: description:
    let
      results = map (tv: testCase 
        "${description} (URL-safe): '${tv.input}'" 
        tv.expected 
        (base64Lib.base64UrlSafeEncode tv.input)
      ) testVectors;
    in results;
  
  # Run special character tests (both variants)
  runSpecialTests = testVectors: description:
    let
      stdResults = map (tv: testCase 
        "${description} (standard): '${builtins.replaceStrings ["\n" "\t" "\r"] ["\\n" "\\t" "\\r"] tv.input}'" 
        tv.expectedStd 
        (base64Lib.base64Encode tv.input)
      ) testVectors;
      urlResults = map (tv: testCase 
        "${description} (URL-safe): '${builtins.replaceStrings ["\n" "\t" "\r"] ["\\n" "\\t" "\\r"] tv.input}'" 
        tv.expectedUrl 
        (base64Lib.base64UrlSafeEncode tv.input)
      ) testVectors;
    in stdResults ++ urlResults;
  
  # Run hex tests (both variants)
  runHexTests = testVectors: description:
    let
      stdResults = map (tv: testCase 
        "${description} (standard): '${tv.input}'" 
        tv.expectedStd 
        (base64Lib.hexStringToBase64 tv.input)
      ) testVectors;
      urlResults = map (tv: testCase 
        "${description} (URL-safe): '${tv.input}'" 
        tv.expectedUrl 
        (base64Lib.hexStringToBase64UrlSafe tv.input)
      ) testVectors;
    in stdResults ++ urlResults;
  
  # Run edge hex tests (both variants)
  runEdgeHexTests = testVectors: description:
    let
      stdResults = map (tv: testCase 
        "${description} (standard): '${tv.input}'" 
        tv.expectedStd 
        (base64Lib.hexStringToBase64 tv.input)
      ) testVectors;
      urlResults = map (tv: testCase 
        "${description} (URL-safe): '${tv.input}'" 
        tv.expectedUrl 
        (base64Lib.hexStringToBase64UrlSafe tv.input)
      ) testVectors;
    in stdResults ++ urlResults;
  
  # All test results
  allTests = 
    (runStandardTests standardTestVectors "Standard base64") ++
    (runUrlSafeTests urlSafeTestVectors "URL-safe base64") ++
    (runSpecialTests specialCharTestVectors "Special chars") ++
    (runHexTests hexTestVectors "Hex input") ++
    (runEdgeHexTests edgeHexTestVectors "Edge hex cases");
  
  # Test summary
  passedTests = builtins.filter (t: t.passed) allTests;
  failedTests = builtins.filter (t: !t.passed) allTests;
  
  testSummary = {
    total = builtins.length allTests;
    passed = builtins.length passedTests;
    failed = builtins.length failedTests;
    success = (builtins.length failedTests) == 0;
  };
  
  # Format test results for display
  formatTest = test: 
    if test.passed 
    then "✅ ${test.name}"
    else "❌ ${test.name}\n   Expected: ${test.expected}\n   Actual:   ${test.actual}";
  
  # Generate test report
  testReport = 
    let
      header = "Base64 Test Results\n" + 
               "==================\n" +
               "Total: ${toString testSummary.total}, " +
               "Passed: ${toString testSummary.passed}, " +
               "Failed: ${toString testSummary.failed}\n\n";
      
      testResults = builtins.concatStringsSep "\n" (map formatTest allTests);
      
      footer = if testSummary.success 
               then "\n🎉 All tests passed!"
               else "\n💥 Some tests failed!";
    in
      header + testResults + footer;

  # Performance test - encode increasingly large strings
  performanceTests = 
    let
      # Generate test strings of different sizes
      generateString = size: char: 
        builtins.concatStringsSep "" (builtins.genList (_: char) size);
      
      sizes = [1 10 100 1000];
      perfResults = map (size: 
        let
          testStr = generateString size "a";
          stdResult = base64Lib.base64Encode testStr;
          urlResult = base64Lib.base64UrlSafeEncode testStr;
        in {
          size = size;
          inputLength = builtins.stringLength testStr;
          stdOutputLength = builtins.stringLength stdResult;
          urlOutputLength = builtins.stringLength urlResult;
          # Basic validation - base64 output should be ~4/3 the input size
          expectedLength = ((size + 2) / 3) * 4;
          stdLengthCorrect = (builtins.stringLength stdResult) == (((size + 2) / 3) * 4);
          # URL-safe might be shorter due to no padding
          urlLengthValid = (builtins.stringLength urlResult) <= (((size + 2) / 3) * 4);
        }
      ) sizes;
    in perfResults;

in {
  # Main test results
  inherit testSummary allTests passedTests failedTests testReport;
  
  # Additional test data
  inherit performanceTests;
  
  # Test functions for reuse
  inherit testCase runStandardTests runUrlSafeTests;
  
  # Test vectors for external use
  testVectors = {
    inherit standardTestVectors urlSafeTestVectors specialCharTestVectors hexTestVectors edgeHexTestVectors;
  };
  
  # Assertion for flake check - pure evaluation
  assertion = 
    if testSummary.success 
    then true
    else builtins.abort "Base64 tests failed!\n${testReport}";
}
1 Like