ferrijs-std 0.2.0

Node and web standard library for the ferrijs QuickJS runtime: WHATWG Streams, Events, AbortController, Buffer, crypto, fs, os, url, zlib and the capability model they enforce (partly derived from awslabs/llrt, Apache-2.0).
Documentation
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
use std::borrow::Cow;

use hex_simd::AsciiCase;

#[derive(Clone, PartialEq)]
pub enum Encoder {
    Hex,
    Base64,
    /// WHATWG `windows-1252`: bytes 0x80-0x9F carry the punctuation
    /// block, everything else is Latin-1. What `TextDecoder` uses for
    /// this label family.
    Windows1252,
    /// Node's `latin1` / `binary` Buffer encoding: byte value IS the
    /// code point, and encoding truncates anything above U+00FF.
    Latin1,
    /// Node's `ascii` Buffer encoding, which MASKS the high bit rather
    /// than treating the byte as Latin-1.
    Ascii,
    Utf8,
    Utf16le,
    Utf16be,
}

/// Code points for bytes 0x80-0x9F under `windows-1252`. The five
/// unassigned slots map to the C1 control of the same value, per the
/// WHATWG index.
const WINDOWS_1252_HIGH: [char; 32] = [
    '\u{20AC}', '\u{0081}', '\u{201A}', '\u{0192}', '\u{201E}', '\u{2026}', '\u{2020}', '\u{2021}',
    '\u{02C6}', '\u{2030}', '\u{0160}', '\u{2039}', '\u{0152}', '\u{008D}', '\u{017D}', '\u{008F}',
    '\u{0090}', '\u{2018}', '\u{2019}', '\u{201C}', '\u{201D}', '\u{2022}', '\u{2013}', '\u{2014}',
    '\u{02DC}', '\u{2122}', '\u{0161}', '\u{203A}', '\u{0153}', '\u{009D}', '\u{017E}', '\u{0178}',
];

fn windows_1252_to_string(bytes: &[u8]) -> String {
    bytes
        .iter()
        .map(|b| match b {
            0x80..=0x9F => WINDOWS_1252_HIGH[usize::from(b - 0x80)],
            other => char::from(*other),
        })
        .collect()
}

fn string_to_windows_1252(string: &str) -> Vec<u8> {
    string
        .chars()
        .map(|c| {
            if let Some(index) = WINDOWS_1252_HIGH.iter().position(|high| *high == c) {
                #[allow(clippy::cast_possible_truncation)]
                return 0x80 + index as u8;
            }
            u8::try_from(u32::from(c)).unwrap_or(b'?')
        })
        .collect()
}

/// Node's Buffer encodings. Node and the WHATWG Encoding Standard
/// disagree on what several labels MEAN — `latin1` is ISO-8859-1 for a
/// Buffer but `windows-1252` for a `TextDecoder`, and `ascii` masks the
/// high bit for a Buffer while decoding as `windows-1252` for a
/// `TextDecoder` — so each consumer looks its label up in its own map.
const NODE_ENCODING_MAP: phf::Map<&'static str, Encoder> = phf::phf_map! {
    "buffer" => Encoder::Utf8,
    "hex" => Encoder::Hex,
    "base64" => Encoder::Base64,
    "utf-8" => Encoder::Utf8,
    "utf8" => Encoder::Utf8,
    "ucs-2" => Encoder::Utf16le,
    "ucs2" => Encoder::Utf16le,
    "utf-16le" => Encoder::Utf16le,
    "utf16le" => Encoder::Utf16le,
    "latin1" => Encoder::Latin1,
    "binary" => Encoder::Latin1,
    "ascii" => Encoder::Ascii,
};

/// The WHATWG Encoding Standard's label index, for `TextDecoder`.
const ENCODING_MAP: phf::Map<&'static str, Encoder> = phf::phf_map! {
    "ascii" => Encoder::Windows1252,
    "latin1" => Encoder::Windows1252,
    "buffer" => Encoder::Utf8,
    "hex" => Encoder::Hex,
    "base64" => Encoder::Base64,
    "unicode-1-1-utf-8" => Encoder::Utf8,
    "unicode11utf8" => Encoder::Utf8,
    "unicode20utf8" => Encoder::Utf8,
    "utf-8" => Encoder::Utf8,
    "utf8" => Encoder::Utf8,
    "x-unicode20utf8" => Encoder::Utf8,
    "csunicode" => Encoder::Utf16le,
    "iso-10646-ucs-2" => Encoder::Utf16le,
    "ucs-2" => Encoder::Utf16le,
    "ucs2" => Encoder::Utf16le,
    "unicode" => Encoder::Utf16le,
    "unicodefeff" => Encoder::Utf16le,
    "utf-16" => Encoder::Utf16le,
    "utf-16le" => Encoder::Utf16le,
    "utf16le" => Encoder::Utf16le,
    "unicodefffe" => Encoder::Utf16be,
    "utf-16be" => Encoder::Utf16be,
    "ansi_x3.4-1968" => Encoder::Windows1252,
    "cp1252" => Encoder::Windows1252,
    "cp819" => Encoder::Windows1252,
    "csisolatin1" => Encoder::Windows1252,
    "ibm819" => Encoder::Windows1252,
    "iso-8859-1" => Encoder::Windows1252,
    "iso-ir-100" => Encoder::Windows1252,
    "iso8859-1" => Encoder::Windows1252,
    "iso88591" => Encoder::Windows1252,
    "iso_8859-1" => Encoder::Windows1252,
    "iso_8859-1:1987" => Encoder::Windows1252,
    "l1" => Encoder::Windows1252,
    "us-ascii" => Encoder::Windows1252,
    "windows-1252" => Encoder::Windows1252,
    "x-cp1252" => Encoder::Windows1252,
};

impl Encoder {
    pub fn from_optional_str(encoding: Option<&str>) -> Result<Self, String> {
        match encoding {
            Some(label) if !label.is_empty() => Self::from_str(label),
            _ => Ok(Self::Utf8),
        }
    }

    /// A Node Buffer encoding name.
    #[allow(clippy::should_implement_trait)]
    pub fn from_str(encoding: &str) -> Result<Self, String> {
        NODE_ENCODING_MAP
            .get(encoding.trim_ascii().to_ascii_lowercase().as_str())
            .cloned()
            .ok_or_else(|| ["The \"", encoding, "\" encoding is not supported"].concat())
    }

    /// A WHATWG Encoding Standard label, as `TextDecoder` takes.
    pub fn from_web_label(label: &str) -> Result<Self, String> {
        ENCODING_MAP
            .get(label.trim_ascii().to_ascii_lowercase().as_str())
            .cloned()
            .ok_or_else(|| ["The \"", label, "\" encoding is not supported"].concat())
    }

    /// A WHATWG label, defaulting to UTF-8 when absent or empty.
    pub fn from_optional_web_label(label: Option<&str>) -> Result<Self, String> {
        match label {
            Some(label) if !label.is_empty() => Self::from_web_label(label),
            _ => Ok(Self::Utf8),
        }
    }

    pub fn encode_to_string(&self, bytes: &[u8], lossy: bool) -> Result<String, String> {
        match self {
            Self::Hex => Ok(bytes_to_hex_string(bytes)),
            Self::Base64 => Ok(bytes_to_b64_string(bytes)),
            Self::Utf8 => bytes_to_utf8_string(bytes, lossy),
            Self::Windows1252 => Ok(windows_1252_to_string(bytes)),
            Self::Latin1 => Ok(bytes.iter().map(|b| char::from(*b)).collect()),
            Self::Ascii => Ok(bytes.iter().map(|b| char::from(b & 0x7F)).collect()),
            Self::Utf16le => bytes_to_utf16_string(bytes, Endian::Little, lossy),
            Self::Utf16be => bytes_to_utf16_string(bytes, Endian::Big, lossy),
        }
    }

    #[allow(dead_code)]
    pub fn encode(&self, bytes: &[u8]) -> Result<Vec<u8>, String> {
        match self {
            Self::Hex => Ok(bytes_to_hex(bytes)),
            Self::Base64 => Ok(bytes_to_b64(bytes)),
            Self::Utf8 | Self::Windows1252 | Self::Latin1 | Self::Ascii | Self::Utf16le | Self::Utf16be => {
                Ok(bytes.to_vec())
            },
        }
    }

    pub fn decode<'a, T: Into<Cow<'a, [u8]>>>(&self, bytes: T) -> Result<Vec<u8>, String> {
        match self {
            Self::Hex => bytes_from_hex(bytes),
            Self::Base64 => bytes_from_b64(bytes),
            Self::Utf8 | Self::Windows1252 | Self::Latin1 | Self::Ascii | Self::Utf16le | Self::Utf16be => {
                Ok(bytes.into().into())
            },
        }
    }

    pub fn decode_from_string(&self, string: String) -> Result<Vec<u8>, String> {
        match self {
            Self::Hex => bytes_from_hex(string.into_bytes()),
            Self::Base64 => bytes_from_b64(string.into_bytes()),
            Self::Utf8 => Ok(string.into_bytes()),
            Self::Windows1252 => Ok(string_to_windows_1252(&string)),
            // Node truncates rather than refusing: `Buffer.from('€',
            // 'latin1')` is one byte, the low byte of the code point.
            #[allow(clippy::cast_possible_truncation)]
            Self::Latin1 => Ok(string.chars().map(|c| u32::from(c) as u8).collect()),
            #[allow(clippy::cast_possible_truncation)]
            Self::Ascii => Ok(string.chars().map(|c| (u32::from(c) as u8) & 0x7F).collect()),
            Self::Utf16le => Ok(string
                .encode_utf16()
                .flat_map(|utf16| utf16.to_le_bytes())
                .collect::<Vec<u8>>()),
            Self::Utf16be => Ok(string
                .encode_utf16()
                .flat_map(|utf16| utf16.to_be_bytes())
                .collect::<Vec<u8>>()),
        }
    }

    pub fn as_label(&self) -> &str {
        match self {
            Self::Hex => "hex",
            Self::Base64 => "base64",
            Self::Windows1252 => "windows-1252",
            Self::Latin1 => "latin1",
            Self::Ascii => "ascii",
            Self::Utf8 => "utf-8",
            Self::Utf16le => "utf-16le",
            Self::Utf16be => "utf-16be",
        }
    }
}

pub fn bytes_to_hex(bytes: &[u8]) -> Vec<u8> {
    hex_simd::encode_type(bytes, AsciiCase::Lower)
}

pub fn bytes_from_hex<'a, T: Into<Cow<'a, [u8]>>>(hex_bytes: T) -> Result<Vec<u8>, String> {
    hex_simd::decode_to_vec(hex_bytes.into()).map_err(|err| err.to_string())
}

pub fn bytes_from_b64<'a, T: Into<Cow<'a, [u8]>>>(base64_bytes: T) -> Result<Vec<u8>, String> {
    let bytes: Cow<'a, [u8]> = base64_bytes.into();

    //need to collect since memchr2_iter is borrowing bytes. This is fine since we're unlikely to contain url safe base64
    let url_safe_byte_positions: Vec<usize> = memchr::memchr2_iter(b'-', b'_', &bytes).collect();

    if url_safe_byte_positions.is_empty() {
        return base64_simd::forgiving_decode_to_vec(&bytes).map_err(|e| e.to_string());
    }

    //doesn't allocate for already owned data
    let mut bytes = bytes.into_owned();
    for pos in url_safe_byte_positions {
        bytes[pos] = match bytes[pos] {
            b'-' => b'+',
            b'_' => b'/',
            _ => unreachable!(),
        };
    }
    base64_simd::forgiving_decode_to_vec(&bytes).map_err(|e| e.to_string())
}

/// Strict standard-base64 decode (single SIMD pass): rejects url-safe chars,
/// whitespace and bad padding, matching @smithy/util-base64 semantics.
pub fn bytes_from_b64_strict(bytes: &[u8]) -> Result<Vec<u8>, String> {
    base64_simd::STANDARD
        .decode_to_vec(bytes)
        .map_err(|e| e.to_string())
}

pub fn bytes_to_b64_string(bytes: &[u8]) -> String {
    base64_simd::STANDARD.encode_to_string(bytes)
}

pub fn bytes_to_b64_url_safe_string(bytes: &[u8]) -> String {
    base64_simd::URL_SAFE_NO_PAD.encode_to_string(bytes)
}

pub fn bytes_from_b64_url_safe(bytes: &[u8]) -> Result<Vec<u8>, String> {
    base64_simd::URL_SAFE_NO_PAD
        .decode_to_vec(bytes)
        .map_err(|e| e.to_string())
}

pub fn bytes_to_b64(bytes: &[u8]) -> Vec<u8> {
    base64_simd::STANDARD.encode_type(bytes)
}

pub fn bytes_to_hex_string(bytes: &[u8]) -> String {
    hex_simd::encode_to_string(bytes, AsciiCase::Lower)
}

pub fn bytes_to_utf8_string(bytes: &[u8], lossy: bool) -> Result<String, String> {
    if lossy {
        Ok(String::from_utf8_lossy(bytes).to_string())
    } else {
        String::from_utf8(bytes.to_vec()).map_err(|e| e.to_string())
    }
}

#[derive(Clone, Copy)]
pub enum Endian {
    Little,
    Big,
}

pub fn bytes_to_utf16_string(bytes: &[u8], endian: Endian, lossy: bool) -> Result<String, String> {
    if !lossy && !bytes.len().is_multiple_of(2) {
        return Err("Input byte slice length must be even".to_string());
    }

    let data16: Vec<u16> = match endian {
        Endian::Little => bytes
            .as_chunks::<2>()
            .0
            .iter()
            .copied()
            .map(u16::from_le_bytes)
            .collect(),
        Endian::Big => bytes
            .as_chunks::<2>()
            .0
            .iter()
            .copied()
            .map(u16::from_be_bytes)
            .collect(),
    };

    let mut result = if lossy {
        String::from_utf16_lossy(&data16)
    } else {
        String::from_utf16(&data16).map_err(|e| e.to_string())?
    };

    // Odd trailing byte in lossy mode produces a replacement character
    if lossy && !bytes.len().is_multiple_of(2) {
        result.push('\u{FFFD}');
    }

    Ok(result)
}

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

    #[test]
    fn b64_strict_matches_smithy_semantics() {
        // canonical decodes
        assert_eq!(bytes_from_b64_strict(b"SGVsbG8=").unwrap(), b"Hello");
        // url-safe, whitespace, bad-padding are rejected (like @smithy/util-base64)
        assert!(bytes_from_b64_strict(b"-_8=").is_err());
        assert!(bytes_from_b64_strict(b"SGVs bG8=").is_err());
        assert!(bytes_from_b64_strict(b"SGVsbG8").is_err());
    }
}