use crate::WireError;
use base64::Engine as _;
use zeroize::Zeroizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LeafType {
Str,
Int,
Float,
Bool,
Bytes,
Time,
Comment,
}
impl LeafType {
#[must_use]
pub fn tag(self) -> &'static str {
match self {
Self::Str => "str",
Self::Int => "int",
Self::Float => "float",
Self::Bool => "bool",
Self::Bytes => "bytes",
Self::Time => "time",
Self::Comment => "comment",
}
}
fn parse(tag: &str) -> Result<Self, WireError> {
match tag {
"str" => Ok(Self::Str),
"int" => Ok(Self::Int),
"float" => Ok(Self::Float),
"bool" => Ok(Self::Bool),
"bytes" => Ok(Self::Bytes),
"time" => Ok(Self::Time),
"comment" => Ok(Self::Comment),
other => Err(WireError::UnknownDatatype(other.to_string())),
}
}
}
#[derive(Clone)]
pub struct Plaintext {
bytes: Zeroizing<Vec<u8>>,
ty: LeafType,
}
impl Plaintext {
#[must_use]
pub fn from_wire(bytes: Vec<u8>, ty: LeafType) -> Self {
Self {
bytes: Zeroizing::new(bytes),
ty,
}
}
#[must_use]
pub fn string(s: impl Into<String>) -> Self {
Self {
bytes: Zeroizing::new(s.into().into_bytes()),
ty: LeafType::Str,
}
}
#[must_use]
pub fn integer(v: i64) -> Self {
Self {
bytes: Zeroizing::new(v.to_string().into_bytes()),
ty: LeafType::Int,
}
}
#[must_use]
pub fn float(v: f64) -> Self {
Self {
bytes: Zeroizing::new(format_go_float_f(v).into_bytes()),
ty: LeafType::Float,
}
}
#[must_use]
pub fn boolean(v: bool) -> Self {
let s: &[u8] = if v { b"True" } else { b"False" };
Self {
bytes: Zeroizing::new(s.to_vec()),
ty: LeafType::Bool,
}
}
#[must_use]
pub fn comment(body: impl Into<String>) -> Self {
Self {
bytes: Zeroizing::new(body.into().into_bytes()),
ty: LeafType::Comment,
}
}
#[must_use]
pub fn leaf_type(&self) -> LeafType {
self.ty
}
#[must_use]
pub fn len(&self) -> usize {
self.bytes.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.bytes.is_empty()
}
#[must_use]
pub fn expose(&self) -> &[u8] {
&self.bytes
}
#[must_use]
pub fn mac_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn validate(&self) -> Result<(), WireError> {
let s = || String::from_utf8_lossy(&self.bytes);
match self.ty {
LeafType::Str | LeafType::Bytes | LeafType::Comment => Ok(()),
LeafType::Int => s()
.parse::<i64>()
.map(|_| ())
.map_err(|_| WireError::DatatypeMismatch { ty: "int" }),
LeafType::Float => s()
.parse::<f64>()
.map(|_| ())
.map_err(|_| WireError::DatatypeMismatch { ty: "float" }),
LeafType::Bool => match self.bytes.as_slice() {
b"True" | b"False" => Ok(()),
_ => Err(WireError::DatatypeMismatch { ty: "bool" }),
},
LeafType::Time => Ok(()),
}
}
}
impl std::fmt::Debug for Plaintext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"Plaintext(*** {} bytes, {})",
self.bytes.len(),
self.ty.tag()
)
}
}
impl PartialEq for Plaintext {
fn eq(&self, other: &Self) -> bool {
self.ty == other.ty
&& self.bytes.len() == other.bytes.len()
&& bool::from(subtle::ConstantTimeEq::ct_eq(
self.bytes.as_slice(),
other.bytes.as_slice(),
))
}
}
impl Eq for Plaintext {}
pub fn format_go_float_f(v: f64) -> String {
let shortest = format!("{v}");
if !shortest.contains(['e', 'E']) {
return shortest;
}
let (mantissa, exp) = shortest
.split_once(['e', 'E'])
.unwrap_or((shortest.as_str(), "0"));
let exp: i32 = exp.parse().unwrap_or(0);
let (sign, mantissa) = match mantissa.strip_prefix('-') {
Some(rest) => ("-", rest),
None => ("", mantissa),
};
let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
let digits: String = format!("{int_part}{frac_part}");
let point = i32::try_from(int_part.len()).unwrap_or(0) + exp;
let out = if point <= 0 {
format!(
"0.{}{}",
"0".repeat(usize::try_from(-point).unwrap_or(0)),
digits
)
} else if usize::try_from(point).unwrap_or(0) >= digits.len() {
let pad = usize::try_from(point).unwrap_or(0) - digits.len();
format!("{digits}{}", "0".repeat(pad))
} else {
let at = usize::try_from(point).unwrap_or(0);
format!("{}.{}", &digits[..at], &digits[at..])
};
format!("{sign}{out}")
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncryptedLeaf {
pub(crate) data: Vec<u8>,
pub(crate) iv: Vec<u8>,
pub(crate) tag: Vec<u8>,
pub(crate) ty: LeafType,
}
impl EncryptedLeaf {
#[must_use]
pub fn looks_encrypted(s: &str) -> bool {
s.starts_with("ENC[AES256_GCM,data:")
}
pub fn parse(s: &str) -> Result<Self, WireError> {
let rest = s
.strip_prefix("ENC[AES256_GCM,data:")
.ok_or(WireError::NotAnEncryptedLeaf)?;
let (data, rest) = rest
.split_once(",iv:")
.ok_or(WireError::NotAnEncryptedLeaf)?;
let (iv, rest) = rest
.split_once(",tag:")
.ok_or(WireError::NotAnEncryptedLeaf)?;
let (tag, rest) = rest
.split_once(",type:")
.ok_or(WireError::NotAnEncryptedLeaf)?;
let ty = rest.split_once(']').map_or(rest, |(t, _)| t);
Ok(Self {
data: b64(data, "data")?,
iv: b64(iv, "iv")?,
tag: b64(tag, "tag")?,
ty: LeafType::parse(ty)?,
})
}
#[must_use]
pub fn render(&self) -> String {
let e = base64::engine::general_purpose::STANDARD;
let mut out = String::with_capacity(
32 + (self.data.len() + self.iv.len() + self.tag.len()) * 4 / 3 + 8,
);
out.push_str("ENC[AES256_GCM,data:");
out.push_str(&e.encode(&self.data));
out.push_str(",iv:");
out.push_str(&e.encode(&self.iv));
out.push_str(",tag:");
out.push_str(&e.encode(&self.tag));
out.push_str(",type:");
out.push_str(self.ty.tag());
out.push(']');
out
}
#[must_use]
pub fn leaf_type(&self) -> LeafType {
self.ty
}
#[must_use]
pub fn iv_len(&self) -> usize {
self.iv.len()
}
}
fn b64(s: &str, field: &'static str) -> Result<Vec<u8>, WireError> {
base64::engine::general_purpose::STANDARD
.decode(s)
.map_err(|_| WireError::Base64 { field })
}
#[cfg(test)]
mod tests {
use super::*;
const SPECIMEN: &str = "ENC[AES256_GCM,data:+s0vLJR7FqRk1dW3+LymL5aTHh4=,iv:irJYGNHV08Ey6RyO5YfqeaNCjLg8vWcdxoQvtnYCR40=,tag:Ax+kskUPjI/gXKq6WEPTxA==,type:str]";
#[test]
fn parses_a_real_specimen() {
let leaf = EncryptedLeaf::parse(SPECIMEN).expect("parse");
assert_eq!(leaf.leaf_type(), LeafType::Str);
assert_eq!(leaf.iv_len(), 32, "sops nonces are 32 bytes, not 12");
assert_eq!(leaf.tag.len(), 16);
}
#[test]
fn render_round_trips_byte_exactly() {
let leaf = EncryptedLeaf::parse(SPECIMEN).expect("parse");
assert_eq!(leaf.render(), SPECIMEN);
}
#[test]
fn trailing_bytes_after_the_bracket_are_ignored_like_upstream() {
let with_junk = format!("{SPECIMEN} and then some");
let a = EncryptedLeaf::parse(SPECIMEN).expect("parse");
let b = EncryptedLeaf::parse(&with_junk).expect("parse with junk");
assert_eq!(a, b);
}
#[test]
fn a_plain_value_is_not_mistaken_for_ciphertext() {
assert!(!EncryptedLeaf::looks_encrypted("hello"));
assert!(!EncryptedLeaf::looks_encrypted(
"ENC[SOMETHING_ELSE,data:x]"
));
assert!(EncryptedLeaf::looks_encrypted(SPECIMEN));
assert_eq!(
EncryptedLeaf::parse("hello"),
Err(WireError::NotAnEncryptedLeaf)
);
}
#[test]
fn unknown_datatype_is_named_not_swallowed() {
let bad = SPECIMEN.replace("type:str", "type:quaternion");
assert_eq!(
EncryptedLeaf::parse(&bad),
Err(WireError::UnknownDatatype("quaternion".into()))
);
}
#[test]
fn bad_base64_names_its_field() {
let bad = SPECIMEN.replace("iv:irJY", "iv:!!!!");
assert_eq!(
EncryptedLeaf::parse(&bad),
Err(WireError::Base64 { field: "iv" })
);
}
#[test]
fn booleans_use_python_titlecase() {
assert_eq!(Plaintext::boolean(true).expose(), b"True");
assert_eq!(Plaintext::boolean(false).expose(), b"False");
}
#[test]
fn floats_match_go_formatfloat_f_minus_one() {
assert_eq!(Plaintext::float(1.5).expose(), b"1.5");
assert_eq!(Plaintext::float(1.0).expose(), b"1");
assert_eq!(Plaintext::float(-0.25).expose(), b"-0.25");
assert_eq!(Plaintext::float(1e21).expose(), b"1000000000000000000000");
assert_eq!(Plaintext::float(1e-7).expose(), b"0.0000001");
assert_eq!(Plaintext::float(-1.5e-7).expose(), b"-0.00000015");
}
#[test]
fn debug_never_shows_the_value() {
let p = Plaintext::string("hunter2");
let shown = format!("{p:?}");
assert!(
!shown.contains("hunter2"),
"Debug leaked the plaintext: {shown}"
);
assert_eq!(shown, "Plaintext(*** 7 bytes, str)");
}
#[test]
fn validate_catches_a_mislabelled_leaf() {
let lying = Plaintext::from_wire(b"not-a-number".to_vec(), LeafType::Int);
assert_eq!(
lying.validate(),
Err(WireError::DatatypeMismatch { ty: "int" })
);
let rusty = Plaintext::from_wire(b"true".to_vec(), LeafType::Bool);
assert_eq!(
rusty.validate(),
Err(WireError::DatatypeMismatch { ty: "bool" })
);
assert_eq!(Plaintext::boolean(true).validate(), Ok(()));
}
}