Skip to content

fix: base45 Encode drops trailing zero char on even-length 2-byte pairs (round-trip broken) - #2

Open
gaoflow wants to merge 1 commit into
deatil:mainfrom
gaoflow:fix/base45-even-length-drop
Open

fix: base45 Encode drops trailing zero char on even-length 2-byte pairs (round-trip broken)#2
gaoflow wants to merge 1 commit into
deatil:mainfrom
gaoflow:fix/base45-even-length-drop

Conversation

@gaoflow

@gaoflow gaoflow commented Jul 25, 2026

Copy link
Copy Markdown

Summary

base45.Encode drops the 3rd base45 character for any trailing 2-byte pair whose 3rd base-45 digit e is 0, emitting 2 chars instead of 3. This breaks the round-trip: Decode(Encode([0x00, 0x10])) returns [0x10], silently dropping the leading 0x00 byte. The library fails its own Encode → Decode invariant.

Smoking gun (round-trip broken)

enc := base45.StdEncoding
out := enc.EncodeToString([]byte{0x00, 0x10})   // "G0"  (RFC 9285: "G00")
dec, _ := enc.DecodeString(out)                  // [0x10]
// original [0x00, 0x10] is NOT recovered -- the 0x00 byte is gone

7 inputs are affected (all even-length inputs whose last 2-byte pair has value n < 2025, i.e. e = n / (45*45) == 0):

input bytes library output RFC 9285 output
00 00 00 000
00 10 G0 G00
00 ff U5 U50
00 7f $2 $20
00 2d 01 010
01 00 00 10 V50G0 V50G00
00 00 00 00 00000 000000

Root cause

In base45.go Encode:

res := encodeBase45(pair)
if i + 1 == len(pairs) && res[2] == 0 {
    // emit only res[:2]  -> 2 chars
} else {
    // emit res[:3]       -> 3 chars
}

res[2] is the 3rd base-45 digit e = n / (45*45). The branch was intended to detect a trailing single byte (odd input length), where encodePairs produces a pair [0, a] so e is always 0 and only 2 chars should be emitted.

But the condition res[2] == 0 is also true for any 2-byte trailing pair [a, b] with n = (a*256) + b < 2025 (in particular any pair [0x00, x]). For those pairs the library emits 2 chars instead of 3, so the output length is no longer divisible by 3 and Decode reconstructs a single byte, dropping the leading 0x00.

Spec

Per RFC 9285 §4:

A byte string [a b c d ... x y z] with arbitrary content and arbitrary length MUST be encoded as follows: From left to right pairs of bytes are encoded as described above. If the number of bytes is even, then the encoded form is a string with a length which is evenly divisible by 3. If the number of bytes is odd, then the last (rightmost) byte is encoded on two characters as described above.

So 2 chars are emitted only for a trailing single byte (odd input length) — never because e == 0.

Fix (one line)

Discriminate by the input length parity instead of by e == 0:

res := encodeBase45(pair)
if i + 1 == len(pairs) && len(bytes) % 2 != 0 {
    // trailing single byte (odd input) -> 2 chars
} else {
    // 2-byte pair -> always 3 chars
}

This always emits 3 chars for a 2-byte pair and 2 chars only for a genuine trailing single byte, restoring the round-trip and RFC compliance.

Tests

Added regression tests in base45/base45_test.go (existing tests preserved):

  • TestEncodeEvenLengthTrailingZeroCharEncode of the previously broken inputs:
    [00 10] → "G00", [00 00] → "000", [00 ff] → "U50",
    [01 00 00 10] → "V50G00", [00 00 00 00] → "000000",
    plus controls AB → "BB8", ! → "X0", [00] → "00".
  • TestEncodeRoundTripEvenLengthTrailingZeroCharDecode(Encode(x)) == x for the previously broken inputs.
  • TestEncodeRFC9285Vectors — RFC 9285 test vectors: AB → "BB8", Hello!! → "%69 VD92EX0", ietf! → "QED8WEX0".

Verification

$ go test ./base45/...
ok  	github.com/deatil/go-encoding/base45	0.268s

$ gofmt -l base45/base45.go base45/base45_test.go
# (clean -- no files listed)

$ go vet ./base45/...
# The only vet finding (base45.go:247 possible misuse of reflect.SliceHeader)
# is PRE-EXISTING on pristine main (commit 788583a) in DecodeString and is
# unrelated to this change.

Controls preserved: Encode("AB") == "BB8", Encode([0xff, 0x00]) == "UAW", Encode([0x21]) == "X0", Encode([0x00]) == "00", and all RFC 9285 test vectors pass.

The diff is minimal: 1 line changed in base45/base45.go, plus regression tests.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant