bashkit 0.20.1

Awesomely fast virtual sandbox with bash and file system
Documentation
//! base32 and basenc builtins - RFC 4648 encodings
//!
//! Decision: base32/base16 are a few lines each, so they are implemented here
//! rather than pulling in an encoding crate; base64 variants reuse the
//! `base64` crate the `base64` builtin already depends on.

use async_trait::async_trait;
use base64::Engine;

use super::{Builtin, Context};
use crate::error::Result;
use crate::interpreter::ExecResult;

const B32: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
const B32HEX: &[u8; 32] = b"0123456789ABCDEFGHIJKLMNOPQRSTUV";

#[derive(Clone, Copy, PartialEq, Eq)]
enum Encoding {
    Base64,
    Base64Url,
    Base32,
    Base32Hex,
    Base16,
}

fn encode(enc: Encoding, data: &[u8]) -> String {
    match enc {
        Encoding::Base64 => base64::engine::general_purpose::STANDARD.encode(data),
        Encoding::Base64Url => base64::engine::general_purpose::URL_SAFE.encode(data),
        Encoding::Base32 => base32_encode(data, B32),
        Encoding::Base32Hex => base32_encode(data, B32HEX),
        Encoding::Base16 => data.iter().map(|b| format!("{b:02X}")).collect(),
    }
}

fn decode(enc: Encoding, text: &[u8]) -> Option<Vec<u8>> {
    match enc {
        Encoding::Base64 => base64::engine::general_purpose::STANDARD.decode(text).ok(),
        Encoding::Base64Url => base64::engine::general_purpose::URL_SAFE.decode(text).ok(),
        Encoding::Base32 => base32_decode(text, B32),
        Encoding::Base32Hex => base32_decode(text, B32HEX),
        Encoding::Base16 => {
            if !text.len().is_multiple_of(2) {
                return None;
            }
            text.chunks(2)
                .map(|p| u8::from_str_radix(std::str::from_utf8(p).ok()?, 16).ok())
                .collect()
        }
    }
}

fn base32_encode(data: &[u8], alphabet: &[u8; 32]) -> String {
    let mut out = String::with_capacity(data.len().div_ceil(5) * 8);
    for chunk in data.chunks(5) {
        let mut buf = [0u8; 5];
        buf[..chunk.len()].copy_from_slice(chunk);
        let bits = buf.iter().fold(0u64, |acc, &b| (acc << 8) | u64::from(b));
        let chars = (chunk.len() * 8).div_ceil(5);
        for i in 0..8 {
            if i < chars {
                let idx = (bits >> (35 - i * 5)) & 0x1f;
                out.push(alphabet[idx as usize] as char);
            } else {
                out.push('=');
            }
        }
    }
    out
}

fn base32_decode(text: &[u8], alphabet: &[u8; 32]) -> Option<Vec<u8>> {
    if !text.len().is_multiple_of(8) {
        return None;
    }
    let mut out = Vec::with_capacity(text.len() / 8 * 5);
    for chunk in text.chunks(8) {
        let pad = chunk.iter().rev().take_while(|&&c| c == b'=').count();
        let bytes = match pad {
            0 => 5,
            1 => 4,
            3 => 3,
            4 => 2,
            6 => 1,
            _ => return None,
        };
        let mut bits = 0u64;
        for &c in &chunk[..8 - pad] {
            let v = alphabet.iter().position(|&a| a == c.to_ascii_uppercase())?;
            bits = (bits << 5) | v as u64;
        }
        bits <<= 5 * pad as u64;
        for i in 0..bytes {
            out.push((bits >> (32 - i * 8)) as u8);
        }
    }
    Some(out)
}

fn wrap_lines(encoded: &str, wrap: usize) -> String {
    let mut out = String::with_capacity(encoded.len() + encoded.len() / wrap.max(1) + 1);
    if wrap == 0 {
        out.push_str(encoded);
    } else {
        for (i, chunk) in encoded.as_bytes().chunks(wrap).enumerate() {
            if i > 0 {
                out.push('\n');
            }
            out.push_str(std::str::from_utf8(chunk).unwrap_or_default());
        }
    }
    if !encoded.is_empty() {
        out.push('\n');
    }
    out
}

async fn run(ctx: Context<'_>, name: &str, fixed: Option<Encoding>) -> Result<ExecResult> {
    let mut enc = fixed;
    let mut decode_mode = false;
    let mut ignore_garbage = false;
    let mut wrap = 76usize;
    let mut file: Option<&str> = None;
    let mut args = ctx.args.iter();
    while let Some(arg) = args.next() {
        let a = arg.as_str();
        match a {
            "-d" | "--decode" => decode_mode = true,
            "-i" | "--ignore-garbage" => ignore_garbage = true,
            "-w" | "--wrap" => match args.next().and_then(|v| v.parse().ok()) {
                Some(w) => wrap = w,
                None => {
                    return Ok(ExecResult::err(format!("{name}: invalid wrap size\n"), 1));
                }
            },
            _ if a.starts_with("--wrap=") => match a[7..].parse() {
                Ok(w) => wrap = w,
                Err(_) => {
                    return Ok(ExecResult::err(format!("{name}: invalid wrap size\n"), 1));
                }
            },
            _ if a.starts_with("-w") && a.len() > 2 => match a[2..].parse() {
                Ok(w) => wrap = w,
                Err(_) => {
                    return Ok(ExecResult::err(format!("{name}: invalid wrap size\n"), 1));
                }
            },
            "--base64" if fixed.is_none() => enc = Some(Encoding::Base64),
            "--base64url" if fixed.is_none() => enc = Some(Encoding::Base64Url),
            "--base32" if fixed.is_none() => enc = Some(Encoding::Base32),
            "--base32hex" if fixed.is_none() => enc = Some(Encoding::Base32Hex),
            "--base16" if fixed.is_none() => enc = Some(Encoding::Base16),
            "-" => file = Some(a),
            _ if a.starts_with('-') => {
                return Ok(ExecResult::err(
                    format!("{name}: unrecognized option '{a}'\n"),
                    1,
                ));
            }
            _ => {
                if file.is_some() {
                    return Ok(ExecResult::err(format!("{name}: extra operand '{a}'\n"), 1));
                }
                file = Some(a);
            }
        }
    }
    let Some(enc) = enc else {
        return Ok(ExecResult::err(
            format!("{name}: missing encoding type\nTry '{name} --help' for more information.\n"),
            1,
        ));
    };

    let input = match file {
        None | Some("-") => ctx.stdin_bytes().unwrap_or_default().to_vec(),
        Some(path) => {
            let resolved = super::resolve_path(ctx.cwd, path);
            match ctx.fs.read_file(&resolved).await {
                Ok(bytes) => bytes,
                Err(_) => {
                    return Ok(ExecResult::err(
                        format!("{name}: {path}: No such file or directory\n"),
                        1,
                    ));
                }
            }
        }
    };

    if decode_mode {
        let alphabet_ok = |b: &u8| match enc {
            Encoding::Base16 => b.is_ascii_hexdigit(),
            Encoding::Base32 => B32.contains(&b.to_ascii_uppercase()) || *b == b'=',
            Encoding::Base32Hex => B32HEX.contains(&b.to_ascii_uppercase()) || *b == b'=',
            Encoding::Base64 => b.is_ascii_alphanumeric() || b"+/=".contains(b),
            Encoding::Base64Url => b.is_ascii_alphanumeric() || b"-_=".contains(b),
        };
        let cleaned: Vec<u8> = input
            .into_iter()
            .filter(|b| !b.is_ascii_whitespace() && (!ignore_garbage || alphabet_ok(b)))
            .collect();
        return Ok(match decode(enc, &cleaned) {
            Some(bytes) => ExecResult::ok_bytes(bytes),
            None => ExecResult::err(format!("{name}: invalid input\n"), 1),
        });
    }
    Ok(ExecResult::ok(wrap_lines(&encode(enc, &input), wrap)))
}

const BASE32_HELP: &str = "Usage: base32 [OPTION]... [FILE]\nBase32 encode or decode FILE, or standard input.\n\n  -d, --decode\tdecode data\n  -i, --ignore-garbage\twhen decoding, ignore non-alphabet characters\n  -w, --wrap=COLS\twrap encoded lines after COLS characters (default 76, 0 disables)\n  --help\tdisplay this help and exit\n  --version\toutput version information and exit\n";

const BASENC_HELP: &str = "Usage: basenc [OPTION]... [FILE]\nbasenc encode or decode FILE, or standard input.\n\n  --base64\tsame as 'base64'\n  --base64url\tfile- and url-safe base64\n  --base32\tsame as 'base32'\n  --base32hex\textended hex alphabet base32\n  --base16\thex encoding\n  -d, --decode\tdecode data\n  -i, --ignore-garbage\twhen decoding, ignore non-alphabet characters\n  -w, --wrap=COLS\twrap encoded lines after COLS characters (default 76, 0 disables)\n  --help\tdisplay this help and exit\n  --version\toutput version information and exit\n";

/// `base32` builtin - RFC 4648 base32 encode/decode.
pub struct Base32;

#[async_trait]
impl Builtin for Base32 {
    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
        if let Some(r) =
            super::check_help_version(ctx.args, BASE32_HELP, Some("base32 (bashkit) 0.1"))
        {
            return Ok(r);
        }
        run(ctx, "base32", Some(Encoding::Base32)).await
    }
}

/// `basenc` builtin - choose an RFC 4648 encoding by flag.
pub struct Basenc;

#[async_trait]
impl Builtin for Basenc {
    async fn execute(&self, ctx: Context<'_>) -> Result<ExecResult> {
        if let Some(r) =
            super::check_help_version(ctx.args, BASENC_HELP, Some("basenc (bashkit) 0.1"))
        {
            return Ok(r);
        }
        run(ctx, "basenc", None).await
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn base32_rfc4648_vectors() {
        let cases = [
            ("", ""),
            ("f", "MY======"),
            ("fo", "MZXQ===="),
            ("foo", "MZXW6==="),
            ("foob", "MZXW6YQ="),
            ("fooba", "MZXW6YTB"),
            ("foobar", "MZXW6YTBOI======"),
        ];
        for (plain, enc) in cases {
            assert_eq!(base32_encode(plain.as_bytes(), B32), enc);
            assert_eq!(
                base32_decode(enc.as_bytes(), B32).unwrap(),
                plain.as_bytes()
            );
        }
        assert_eq!(base32_encode(b"foobar", B32HEX), "CPNMUOJ1E8======");
    }

    #[test]
    fn base32_rejects_bad_input() {
        assert!(base32_decode(b"MY=====", B32).is_none());
        assert!(base32_decode(b"M1======", B32).is_none());
        assert!(base32_decode(b"MY==M===", B32).is_none());
    }

    #[test]
    fn base16_round_trip() {
        assert_eq!(encode(Encoding::Base16, b"\x00\xffA"), "00FF41");
        assert_eq!(decode(Encoding::Base16, b"00ff41").unwrap(), b"\x00\xffA");
        assert!(decode(Encoding::Base16, b"0").is_none());
    }
}