feat(base64-rs): add GNU-compatible base64 command for Windows

Windows has no base64 command; certutil is file-only, adds PEM markers
and cannot be piped. This is a real base64.exe matching GNU coreutils:
same flags (-d, -i, -w), stdin/stdout streaming, exit codes and error
messages, so existing muscle memory and scripts carry over.

Verified against coreutils 9.7 with a 153-case differential test. The
subtle behaviours are reproduced: bytes are emitted incrementally so a
stream that goes bad still writes its valid prefix, unpadded tails are
accepted only when canonically encoded, concatenated streams decode as
one, and -w0 suppresses the trailing newline.

Deliberate deviation: '\r' is skipped when decoding alongside '\n', as
CRLF base64 files are routine on Windows and GNU rejects them.

Zero dependencies, so the build works offline.
This commit is contained in:
2026-07-27 21:36:01 +01:00
parent 3ee7d7a3ac
commit 5e6b4428bb
6 changed files with 556 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
+7
View File
@@ -0,0 +1,7 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "base64"
version = "1.0.0"
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "base64"
version = "1.0.0"
edition = "2021"
[dependencies]
[[bin]]
name = "base64"
path = "src/main.rs"
[profile.release]
opt-level = 3
lto = true
strip = true
panic = "abort"
+72
View File
@@ -0,0 +1,72 @@
# base64 for Windows
Windows has no `base64` command. `certutil -encode` is the usual substitute, but it only works on files, wraps output in `-----BEGIN CERTIFICATE-----` markers, and can't be piped. This is a real `base64.exe` that behaves like the one on a Linux box, so the commands and scripts you already know carry over.
```powershell
base64 image.png > image.b64
base64 -d image.b64 > image.png
type secrets.json | base64 -w0
base64 -di messy.txt > payload.bin
```
## Build and install
```powershell
cargo build --release
```
Copy `target\release\base64.exe` somewhere on your PATH — e.g. `%LOCALAPPDATA%\Microsoft\WindowsApps`, or a `C:\tools\bin` folder you've added yourself:
```powershell
mkdir C:\tools\bin
copy target\release\base64.exe C:\tools\bin\
[Environment]::SetEnvironmentVariable(
'Path',
[Environment]::GetEnvironmentVariable('Path','User') + ';C:\tools\bin',
'User')
```
Open a new terminal afterwards for the PATH change to take effect. No runtime dependencies — it's a single static binary, and the crate itself has zero dependencies, so the build works offline.
## Usage
```
Usage: base64 [OPTION]... [FILE]
Base64 encode or decode FILE, or standard input, to standard output.
With no FILE, or when FILE is -, read standard input.
-d, --decode decode data
-i, --ignore-garbage when decoding, ignore non-alphabet characters
-w, --wrap=COLS wrap encoded lines after COLS character (default 76).
Use 0 to disable line wrapping
--help display this help and exit
--version output version information and exit
```
Long options accept unambiguous abbreviations (`--dec`), short options cluster (`-di`), `-w` takes its value joined or separate (`-w0`, `-w 0`, `--wrap=0`), and `--` ends option parsing.
## Behaviour
Verified against GNU coreutils 9.7 with a 153-case differential test covering encoding at every wrap width, decode round-trips, malformed padding, truncated input, garbage handling, all the usage errors and file operands. Output, exit codes and error messages match, including the fiddly parts:
- **Partial output on failure.** Bytes are emitted as soon as enough bits are available, so a stream that goes bad halfway still writes the valid prefix before the error — same as coreutils.
- **Unpadded input is accepted** when the encoding is canonical, i.e. the leftover bits are zero. `aGVsbG8` decodes to `hello`; `aGV` is rejected because `V` carries stray bits.
- **Concatenated streams** decode as one (`aGVsbG8=aGVsbG8=``hellohello`).
- **`-w0` emits no trailing newline**; wrapped output always ends with one. Empty input produces no output at all.
- **Exit codes**: `0` success, `1` on any error.
### Deliberate differences
| | Why |
|---|---|
| `\r` is skipped when decoding, as `\n` already is | GNU rejects CRLF input as invalid. On Windows a base64 file will routinely have CRLF line endings, and erroring on those would make the tool useless for its main job. Everything else is still rejected unless you pass `-i`. |
| `--help` omits GNU's support URLs | This isn't GNU coreutils; pointing at their bug tracker would be wrong. |
| Error messages quote with `'ASCII'` | GNU uses `'…'` in a UTF-8 locale. Windows consoles are frequently cp437/cp1252, where those bytes render as mojibake. |
## Notes
- Roughly 2× slower than GNU on large inputs (100 MB encodes in ~0.35s vs ~0.18s). GNU uses hand-tuned routines; this is plain portable Rust and fast enough that the difference is invisible for normal use.
- Input is streamed, not buffered whole, so encoding a multi-gigabyte file doesn't blow up memory.
- Piping to a program that exits early (`base64 big.bin | head`) is handled quietly rather than reporting a broken pipe.
- Writing raw binary to a Windows console fails with a clear message telling you to redirect, because consoles take UTF-16 text and would corrupt the bytes. Redirecting to a file or piping is unaffected.
+450
View File
@@ -0,0 +1,450 @@
//! A drop-in `base64` for Windows, matching GNU coreutils behaviour.
//!
//! Same flags, same output, same error messages and exit codes as the
//! `base64` on a Linux box, so muscle memory and scripts carry over.
use std::env;
use std::fs::File;
use std::io::{self, BufWriter, ErrorKind, Read, Write};
use std::process::ExitCode;
const PROGRAM: &str = "base64";
const VERSION: &str = concat!("base64 (jwtf tools) ", env!("CARGO_PKG_VERSION"));
const DEFAULT_WRAP: usize = 76;
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
const PAD: u8 = b'=';
/// Reverse lookup for the alphabet; -1 marks a byte that is not a base64 digit.
const DECODE: [i8; 256] = {
let mut table = [-1i8; 256];
let mut i = 0;
while i < 64 {
table[ALPHABET[i] as usize] = i as i8;
i += 1;
}
table
};
const HELP: &str = "\
Usage: base64 [OPTION]... [FILE]
Base64 encode or decode FILE, or standard input, to standard output.
With no FILE, or when FILE is -, read standard input.
Mandatory arguments to long options are mandatory for short options too.
-d, --decode decode data
-i, --ignore-garbage when decoding, ignore non-alphabet characters
-w, --wrap=COLS wrap encoded lines after COLS character (default 76).
Use 0 to disable line wrapping
--help display this help and exit
--version output version information and exit
The data are encoded as described for the base64 alphabet in RFC 4648.
When decoding, the input may contain newlines in addition to the bytes of
the formal base64 alphabet. Use --ignore-garbage to attempt to recover
from any other non-alphabet bytes in the encoded stream.";
const LONG_OPTS: [&str; 5] = ["decode", "ignore-garbage", "wrap", "help", "version"];
fn main() -> ExitCode {
match run() {
Ok(()) => ExitCode::SUCCESS,
Err(Fail::Quiet) => ExitCode::SUCCESS,
Err(Fail::Usage(msg)) => {
eprintln!("{PROGRAM}: {msg}");
eprintln!("Try '{PROGRAM} --help' for more information.");
ExitCode::FAILURE
}
Err(Fail::Error(msg)) => {
eprintln!("{PROGRAM}: {msg}");
ExitCode::FAILURE
}
}
}
enum Fail {
/// Usage problem: prints the "Try --help" hint after the message.
Usage(String),
/// Runtime problem: message only.
Error(String),
/// Downstream closed the pipe (`base64 f | head`); nothing to report.
Quiet,
}
struct Options {
decode: bool,
ignore_garbage: bool,
wrap: usize,
file: Option<String>,
}
fn run() -> Result<(), Fail> {
let opts = match parse_args(env::args().skip(1).collect())? {
Parsed::Run(o) => o,
Parsed::Exit(text) => {
println!("{text}");
return Ok(());
}
};
// Reading is buffered by the encode/decode loops, which pull in large
// chunks; stdout is wrapped so small writes do not hit the OS each time.
let stdout = io::stdout();
let mut out = BufWriter::with_capacity(64 * 1024, stdout.lock());
let result = match opts.file.as_deref() {
None | Some("-") => {
let stdin = io::stdin();
process(stdin.lock(), &mut out, &opts)
}
Some(path) => match File::open(path) {
Ok(f) => process(f, &mut out, &opts),
Err(e) => return Err(Fail::Error(format!("{path}: {}", describe(&e)))),
},
};
// Flush even on failure: a partial decode still writes its valid prefix,
// and it must land before main() prints the error to stderr.
let flushed = out.flush().map_err(write_failure);
result.and(flushed)
}
fn process<R: Read, W: Write>(input: R, out: &mut W, opts: &Options) -> Result<(), Fail> {
if opts.decode {
decode(input, out, opts.ignore_garbage)
} else {
encode(input, out, opts.wrap)
}
}
/* ── Argument parsing ────────────────────────────────────────────────── */
enum Parsed {
Run(Options),
/// --help / --version: print this and stop.
Exit(&'static str),
}
fn parse_args(args: Vec<String>) -> Result<Parsed, Fail> {
let mut opts = Options {
decode: false,
ignore_garbage: false,
wrap: DEFAULT_WRAP,
file: None,
};
let mut operands: Vec<String> = Vec::new();
let mut only_operands = false;
let mut it = args.into_iter().peekable();
while let Some(arg) = it.next() {
if only_operands {
operands.push(arg);
} else if arg == "--" {
only_operands = true;
} else if let Some(long) = arg.strip_prefix("--") {
// Split --wrap=76 into name and inline value.
let (name, inline) = match long.split_once('=') {
Some((n, v)) => (n, Some(v.to_string())),
None => (long, None),
};
match resolve_long(name)? {
"help" => return Ok(Parsed::Exit(HELP)),
"version" => return Ok(Parsed::Exit(VERSION)),
"decode" => opts.decode = true,
"ignore-garbage" => opts.ignore_garbage = true,
"wrap" => {
let value = match inline.or_else(|| it.next()) {
Some(v) => v,
None => {
return Err(Fail::Usage(format!("option '--{name}' requires an argument")))
}
};
opts.wrap = parse_wrap(&value)?;
}
_ => unreachable!(),
}
} else if arg.len() > 1 && arg.starts_with('-') {
// Short flags, clusterable: -di, -dw0, -w76
let chars: Vec<char> = arg.chars().collect();
let mut idx = 1;
while idx < chars.len() {
match chars[idx] {
'd' => opts.decode = true,
'i' => opts.ignore_garbage = true,
'w' => {
// Rest of this argument is the value, else the next one.
let rest: String = chars[idx + 1..].iter().collect();
let value = if rest.is_empty() {
match it.next() {
Some(v) => v,
None => {
return Err(Fail::Usage(
"option requires an argument -- 'w'".to_string(),
))
}
}
} else {
rest
};
opts.wrap = parse_wrap(&value)?;
idx = chars.len();
continue;
}
c => return Err(Fail::Usage(format!("invalid option -- '{c}'"))),
}
idx += 1;
}
} else {
operands.push(arg);
}
}
if operands.len() > 1 {
return Err(Fail::Usage(format!("extra operand '{}'", operands[1])));
}
opts.file = operands.into_iter().next();
Ok(Parsed::Run(opts))
}
/// GNU accepts any unambiguous abbreviation of a long option.
fn resolve_long(name: &str) -> Result<&'static str, Fail> {
let hits: Vec<&'static str> = LONG_OPTS
.iter()
.copied()
.filter(|o| o.starts_with(name))
.collect();
match hits.len() {
1 => Ok(hits[0]),
0 => Err(Fail::Usage(format!("unrecognized option '--{name}'"))),
_ => Err(Fail::Usage(format!(
"option '--{name}' is ambiguous; possibilities: {}",
hits.iter()
.map(|o| format!("'--{o}'"))
.collect::<Vec<_>>()
.join(" ")
))),
}
}
/// Unlike the other usage errors, coreutils prints this one without the
/// "Try --help" hint.
fn parse_wrap(value: &str) -> Result<usize, Fail> {
value
.parse::<usize>()
.map_err(|_| Fail::Error(format!("invalid wrap size: '{value}'")))
}
/* ── Encode ──────────────────────────────────────────────────────────── */
fn encode<R: Read, W: Write>(mut input: R, out: &mut W, wrap: usize) -> Result<(), Fail> {
let mut inbuf = vec![0u8; 48 * 1024];
let mut pending: Vec<u8> = Vec::with_capacity(3);
let mut line: Vec<u8> = Vec::with_capacity(64 * 1024);
let mut col = 0usize;
let mut any = false;
loop {
let n = input.read(&mut inbuf).map_err(read_failure)?;
if n == 0 {
break;
}
any = true;
pending.extend_from_slice(&inbuf[..n]);
let whole = pending.len() - pending.len() % 3;
for group in pending[..whole].chunks_exact(3) {
emit(&mut line, encode_triple(group[0], group[1], group[2]), wrap, &mut col);
}
pending.drain(..whole);
if line.len() >= 32 * 1024 {
out.write_all(&line).map_err(write_failure)?;
line.clear();
}
}
// Tail of 1 or 2 bytes gets padded out to a full quad.
match pending.len() {
1 => {
let q = encode_triple(pending[0], 0, 0);
emit(&mut line, [q[0], q[1], PAD, PAD], wrap, &mut col);
}
2 => {
let q = encode_triple(pending[0], pending[1], 0);
emit(&mut line, [q[0], q[1], q[2], PAD], wrap, &mut col);
}
_ => {}
}
// The trailing newline is a line terminator, so it appears only when
// wrapping is on and something was actually written.
if any && col != 0 && wrap != 0 {
line.push(b'\n');
}
out.write_all(&line).map_err(write_failure)?;
Ok(())
}
fn encode_triple(a: u8, b: u8, c: u8) -> [u8; 4] {
let n = ((a as u32) << 16) | ((b as u32) << 8) | c as u32;
[
ALPHABET[(n >> 18) as usize & 63],
ALPHABET[(n >> 12) as usize & 63],
ALPHABET[(n >> 6) as usize & 63],
ALPHABET[n as usize & 63],
]
}
fn emit(line: &mut Vec<u8>, quad: [u8; 4], wrap: usize, col: &mut usize) {
for ch in quad {
if wrap != 0 && *col == wrap {
line.push(b'\n');
*col = 0;
}
line.push(ch);
*col += 1;
}
}
/* ── Decode ──────────────────────────────────────────────────────────── */
fn decode<R: Read, W: Write>(mut input: R, out: &mut W, ignore_garbage: bool) -> Result<(), Fail> {
let mut obuf: Vec<u8> = Vec::with_capacity(48 * 1024);
let outcome = decode_stream(&mut input, out, &mut obuf, ignore_garbage);
// Whatever decoded cleanly before the failure still gets written, which
// is what coreutils does — the error only stops further output.
let flushed = out.write_all(&obuf).map_err(write_failure);
outcome.and(flushed)
}
/// Bytes are emitted as soon as enough bits are in hand (one byte after two
/// digits, another after three, the last on a full quad), so a stream that
/// turns bad part-way still produces the valid prefix.
fn decode_stream<R: Read, W: Write>(
input: &mut R,
out: &mut W,
obuf: &mut Vec<u8>,
ignore_garbage: bool,
) -> Result<(), Fail> {
let mut inbuf = vec![0u8; 64 * 1024];
let mut d = [0u8; 3];
let mut have = 0usize;
// A quad holding two digits needs '=' twice; this tracks the second.
let mut need_pad = false;
loop {
let n = input.read(&mut inbuf).map_err(read_failure)?;
if n == 0 {
break;
}
for &byte in &inbuf[..n] {
if byte == PAD {
if need_pad {
need_pad = false;
have = 0;
continue;
}
match have {
2 if d[1] & 0x0F == 0 => need_pad = true,
3 if d[2] & 0x03 == 0 => have = 0,
_ => return Err(invalid()),
}
continue;
}
let digit = DECODE[byte as usize];
if digit < 0 {
// Line endings are always skipped so CRLF files work; other
// junk is only tolerated under --ignore-garbage.
if byte == b'\n' || byte == b'\r' || ignore_garbage {
continue;
}
return Err(invalid());
}
if need_pad {
return Err(invalid());
}
let v = digit as u8;
match have {
0 => {
d[0] = v;
have = 1;
}
1 => {
d[1] = v;
have = 2;
obuf.push((d[0] << 2) | (d[1] >> 4));
}
2 => {
d[2] = v;
have = 3;
obuf.push(((d[1] & 0x0F) << 4) | (d[2] >> 2));
}
_ => {
obuf.push(((d[2] & 0x03) << 6) | v);
have = 0;
}
}
}
if obuf.len() >= 32 * 1024 {
out.write_all(obuf).map_err(write_failure)?;
obuf.clear();
}
}
if need_pad {
return Err(invalid());
}
// An unpadded tail is only legal if the bits that would have been
// dropped are zero, i.e. the input was canonically encoded.
match have {
0 => Ok(()),
2 if d[1] & 0x0F == 0 => Ok(()),
3 if d[2] & 0x03 == 0 => Ok(()),
_ => Err(invalid()),
}
}
fn invalid() -> Fail {
Fail::Error("invalid input".to_string())
}
/* ── I/O error mapping ───────────────────────────────────────────────── */
fn read_failure(e: io::Error) -> Fail {
Fail::Error(format!("read error: {}", describe(&e)))
}
fn write_failure(e: io::Error) -> Fail {
match e.kind() {
// `base64 big.bin | head` closes the pipe under us; exit quietly.
ErrorKind::BrokenPipe => Fail::Quiet,
// Windows consoles take UTF-16 text, so raw bytes cannot be printed
// to one. Redirecting to a file or piping sidesteps it entirely.
ErrorKind::InvalidData => Fail::Error(
"cannot write binary output to the console; redirect to a file (base64 -d in.txt > out.bin)"
.to_string(),
),
_ => Fail::Error(format!("write error: {}", describe(&e))),
}
}
/// Trims Rust's "(os error N)" suffix so messages read like coreutils'.
fn describe(e: &io::Error) -> String {
match e.kind() {
ErrorKind::NotFound => "No such file or directory".to_string(),
ErrorKind::PermissionDenied => "Permission denied".to_string(),
ErrorKind::IsADirectory => "Is a directory".to_string(),
_ => {
let s = e.to_string();
match s.find(" (os error ") {
Some(i) => s[..i].to_string(),
None => s,
}
}
}
}