From 1a088d2722f7fa781bb5ea4fdac17868b7e96067 Mon Sep 17 00:00:00 2001 From: Vincent Gao Date: Sat, 25 Jul 2026 02:11:39 +0200 Subject: [PATCH] fix: base45 Encode drops trailing zero char on even-length 2-byte pairs In Encode, the condition if i + 1 == len(pairs) && res[2] == 0 was intended to detect a trailing single byte (odd input length), for which the 3rd base45 digit (e) is always 0 and only 2 chars should be emitted. However, it also matches 2-byte trailing pairs whose value n = (a*256) + b happens to satisfy e = n / (45*45) == 0 (i.e. n < 2025, in particular any pair [0x00, x]). For those pairs the library emits 2 chars instead of 3, producing output whose length is no longer divisible by 3 and breaking the round-trip: Decode(Encode([0x00, 0x10])) returns [0x10], silently dropping the leading 0x00 byte. Per RFC 9285 Section 4, an even-length input MUST produce an output whose length is divisible by 3 (3 chars per pair); 2 chars are emitted ONLY for a trailing single byte (odd input length). Fix: discriminate by the input length parity instead of by e == 0: if i + 1 == len(pairs) && len(bytes) % 2 != 0 This always emits 3 chars for a 2-byte pair and 2 chars only for a genuine trailing single byte, restoring the round-trip. Add regression tests covering: - Encode of even-length inputs whose last pair has e == 0 ([00 10] -> G00, [00 00] -> 000, [00 ff] -> U50, [01 00 00 10] -> V50G00, [00 00 00 00] -> 000000). - Round-trip Decode(Encode(x)) == x for the previously broken inputs. - Controls: AB -> BB8, ! -> X0, [00] -> 00. - RFC 9285 vectors: AB -> BB8, Hello!! -> %69 VD92EX0, ietf! -> QED8WEX0. --- base45/base45.go | 2 +- base45/base45_test.go | 65 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/base45/base45.go b/base45/base45.go index e305a98..6893489 100644 --- a/base45/base45.go +++ b/base45/base45.go @@ -147,7 +147,7 @@ func (enc *Encoding) Encode(bytes []byte) []byte { var builder strings.Builder for i, pair := range pairs { res := encodeBase45(pair) - if i + 1 == len(pairs) && res[2] == 0 { + if i + 1 == len(pairs) && len(bytes) % 2 != 0 { for _, b := range res[:2] { if len(enc.encode) > int(b) { builder.WriteByte(enc.encode[b]) diff --git a/base45/base45_test.go b/base45/base45_test.go index ff6c67d..9d0de39 100644 --- a/base45/base45_test.go +++ b/base45/base45_test.go @@ -1,6 +1,7 @@ package base45 import ( + "bytes" "testing" ) @@ -103,3 +104,67 @@ func TestDecodeMalformed(t *testing.T) { } } } + +// TestEncodeEvenLengthTrailingZeroChar is a regression test for the bug where +// Encode dropped the 3rd base45 digit (e) of a 2-byte trailing pair whenever +// e == 0, emitting 2 chars instead of 3 and breaking the round-trip +// (Decode(Encode([00 10])) returned [10], silently dropping the 0x00 byte). +// +// Per RFC 9285 §4, an even-length input MUST produce an output whose length +// is divisible by 3 (3 chars per pair); 2 chars are emitted ONLY for a +// trailing single byte (odd input length). +func TestEncodeEvenLengthTrailingZeroChar(t *testing.T) { + cases := []testPair{ + // 2-byte pairs whose 3rd base45 digit (e) is 0 — previously dropped. + {decoded: "\x00\x10", encoded: "G00"}, + {decoded: "\x00\x00", encoded: "000"}, + {decoded: "\x00\xff", encoded: "U50"}, + // 4-byte inputs whose last pair has e == 0. + {decoded: "\x01\x00\x00\x10", encoded: "V50G00"}, + {decoded: "\x00\x00\x00\x00", encoded: "000000"}, + // Controls: last pair has e != 0 (already correct). + {decoded: "AB", encoded: "BB8"}, + // Controls: odd-length inputs (trailing single byte) — 2 chars correct. + {decoded: "!", encoded: "X0"}, + {decoded: "\x00", encoded: "00"}, + } + for _, p := range cases { + got := StdEncoding.EncodeToString([]byte(p.decoded)) + testEqual(t, "Encode(%q) = %q, want %q", p.decoded, got, p.encoded) + } +} + +// TestEncodeRoundTripEvenLengthTrailingZeroChar verifies that Decode(Encode(x)) +// == x for inputs that previously broke the round-trip. +func TestEncodeRoundTripEvenLengthTrailingZeroChar(t *testing.T) { + inputs := [][]byte{ + {0x00, 0x10}, + {0x00, 0x00}, + {0x01, 0x00, 0x00, 0x10}, + {0x00, 0x00, 0x00, 0x00}, + } + for _, in := range inputs { + enc := StdEncoding.EncodeToString(in) + dec, err := StdEncoding.DecodeString(enc) + if err != nil { + t.Errorf("Decode(Encode(% x)) returned error: %v", in, err) + continue + } + if !bytes.Equal(dec, in) { + t.Errorf("Decode(Encode(% x)) = % x, want % x", in, dec, in) + } + } +} + +// TestEncodeRFC9285Vectors re-asserts the RFC 9285 test vectors. +func TestEncodeRFC9285Vectors(t *testing.T) { + cases := []testPair{ + {decoded: "AB", encoded: "BB8"}, + {decoded: "Hello!!", encoded: "%69 VD92EX0"}, + {decoded: "ietf!", encoded: "QED8WEX0"}, + } + for _, p := range cases { + got := StdEncoding.EncodeToString([]byte(p.decoded)) + testEqual(t, "Encode(%q) = %q, want %q", p.decoded, got, p.encoded) + } +}