1use crate::WireError;
20use base64::Engine as _;
21use zeroize::Zeroizing;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum LeafType {
29 Str,
30 Int,
31 Float,
32 Bool,
33 Bytes,
38 Time,
39 Comment,
40}
41
42impl LeafType {
43 #[must_use]
45 pub fn tag(self) -> &'static str {
46 match self {
47 Self::Str => "str",
48 Self::Int => "int",
49 Self::Float => "float",
50 Self::Bool => "bool",
51 Self::Bytes => "bytes",
52 Self::Time => "time",
53 Self::Comment => "comment",
54 }
55 }
56
57 fn parse(tag: &str) -> Result<Self, WireError> {
58 match tag {
59 "str" => Ok(Self::Str),
60 "int" => Ok(Self::Int),
61 "float" => Ok(Self::Float),
62 "bool" => Ok(Self::Bool),
63 "bytes" => Ok(Self::Bytes),
64 "time" => Ok(Self::Time),
65 "comment" => Ok(Self::Comment),
66 other => Err(WireError::UnknownDatatype(other.to_string())),
67 }
68 }
69}
70
71#[derive(Clone)]
80pub struct Plaintext {
81 bytes: Zeroizing<Vec<u8>>,
82 ty: LeafType,
83}
84
85impl Plaintext {
86 #[must_use]
91 pub fn from_wire(bytes: Vec<u8>, ty: LeafType) -> Self {
92 Self {
93 bytes: Zeroizing::new(bytes),
94 ty,
95 }
96 }
97
98 #[must_use]
100 pub fn string(s: impl Into<String>) -> Self {
101 Self {
102 bytes: Zeroizing::new(s.into().into_bytes()),
103 ty: LeafType::Str,
104 }
105 }
106
107 #[must_use]
110 pub fn integer(v: i64) -> Self {
111 Self {
112 bytes: Zeroizing::new(v.to_string().into_bytes()),
113 ty: LeafType::Int,
114 }
115 }
116
117 #[must_use]
126 pub fn float(v: f64) -> Self {
127 Self {
128 bytes: Zeroizing::new(format_go_float(v).into_bytes()),
129 ty: LeafType::Float,
130 }
131 }
132
133 #[must_use]
136 pub fn boolean(v: bool) -> Self {
137 let s: &[u8] = if v { b"True" } else { b"False" };
138 Self {
139 bytes: Zeroizing::new(s.to_vec()),
140 ty: LeafType::Bool,
141 }
142 }
143
144 #[must_use]
147 pub fn comment(body: impl Into<String>) -> Self {
148 Self {
149 bytes: Zeroizing::new(body.into().into_bytes()),
150 ty: LeafType::Comment,
151 }
152 }
153
154 #[must_use]
156 pub fn leaf_type(&self) -> LeafType {
157 self.ty
158 }
159
160 #[must_use]
162 pub fn len(&self) -> usize {
163 self.bytes.len()
164 }
165
166 #[must_use]
169 pub fn is_empty(&self) -> bool {
170 self.bytes.is_empty()
171 }
172
173 #[must_use]
175 pub fn expose(&self) -> &[u8] {
176 &self.bytes
177 }
178
179 #[must_use]
187 pub fn mac_bytes(&self) -> &[u8] {
188 &self.bytes
189 }
190
191 pub fn validate(&self) -> Result<(), WireError> {
197 let s = || String::from_utf8_lossy(&self.bytes);
198 match self.ty {
199 LeafType::Str | LeafType::Bytes | LeafType::Comment => Ok(()),
200 LeafType::Int => s()
201 .parse::<i64>()
202 .map(|_| ())
203 .map_err(|_| WireError::DatatypeMismatch { ty: "int" }),
204 LeafType::Float => s()
205 .parse::<f64>()
206 .map(|_| ())
207 .map_err(|_| WireError::DatatypeMismatch { ty: "float" }),
208 LeafType::Bool => match self.bytes.as_slice() {
209 b"True" | b"False" => Ok(()),
210 _ => Err(WireError::DatatypeMismatch { ty: "bool" }),
211 },
212 LeafType::Time => Ok(()),
213 }
214 }
215}
216
217impl std::fmt::Debug for Plaintext {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 write!(
220 f,
221 "Plaintext(*** {} bytes, {})",
222 self.bytes.len(),
223 self.ty.tag()
224 )
225 }
226}
227
228impl PartialEq for Plaintext {
229 fn eq(&self, other: &Self) -> bool {
237 self.ty == other.ty
238 && self.bytes.len() == other.bytes.len()
239 && bool::from(subtle::ConstantTimeEq::ct_eq(
240 self.bytes.as_slice(),
241 other.bytes.as_slice(),
242 ))
243 }
244}
245
246impl Eq for Plaintext {}
247
248fn format_go_float(v: f64) -> String {
256 let shortest = format!("{v}");
257 if !shortest.contains(['e', 'E']) {
258 return shortest;
259 }
260 let (mantissa, exp) = shortest
261 .split_once(['e', 'E'])
262 .unwrap_or((shortest.as_str(), "0"));
263 let exp: i32 = exp.parse().unwrap_or(0);
264 let (sign, mantissa) = match mantissa.strip_prefix('-') {
265 Some(rest) => ("-", rest),
266 None => ("", mantissa),
267 };
268 let (int_part, frac_part) = mantissa.split_once('.').unwrap_or((mantissa, ""));
269 let digits: String = format!("{int_part}{frac_part}");
270 let point = i32::try_from(int_part.len()).unwrap_or(0) + exp;
272 let out = if point <= 0 {
273 format!(
274 "0.{}{}",
275 "0".repeat(usize::try_from(-point).unwrap_or(0)),
276 digits
277 )
278 } else if usize::try_from(point).unwrap_or(0) >= digits.len() {
279 let pad = usize::try_from(point).unwrap_or(0) - digits.len();
280 format!("{digits}{}", "0".repeat(pad))
281 } else {
282 let at = usize::try_from(point).unwrap_or(0);
283 format!("{}.{}", &digits[..at], &digits[at..])
284 };
285 format!("{sign}{out}")
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct EncryptedLeaf {
291 pub(crate) data: Vec<u8>,
292 pub(crate) iv: Vec<u8>,
293 pub(crate) tag: Vec<u8>,
294 pub(crate) ty: LeafType,
295}
296
297impl EncryptedLeaf {
298 #[must_use]
303 pub fn looks_encrypted(s: &str) -> bool {
304 s.starts_with("ENC[AES256_GCM,data:")
305 }
306
307 pub fn parse(s: &str) -> Result<Self, WireError> {
315 let rest = s
316 .strip_prefix("ENC[AES256_GCM,data:")
317 .ok_or(WireError::NotAnEncryptedLeaf)?;
318 let (data, rest) = rest
319 .split_once(",iv:")
320 .ok_or(WireError::NotAnEncryptedLeaf)?;
321 let (iv, rest) = rest
322 .split_once(",tag:")
323 .ok_or(WireError::NotAnEncryptedLeaf)?;
324 let (tag, rest) = rest
325 .split_once(",type:")
326 .ok_or(WireError::NotAnEncryptedLeaf)?;
327 let ty = rest.split_once(']').map_or(rest, |(t, _)| t);
330 Ok(Self {
331 data: b64(data, "data")?,
332 iv: b64(iv, "iv")?,
333 tag: b64(tag, "tag")?,
334 ty: LeafType::parse(ty)?,
335 })
336 }
337
338 #[must_use]
340 pub fn render(&self) -> String {
341 let e = base64::engine::general_purpose::STANDARD;
342 let mut out = String::with_capacity(
343 32 + (self.data.len() + self.iv.len() + self.tag.len()) * 4 / 3 + 8,
344 );
345 out.push_str("ENC[AES256_GCM,data:");
346 out.push_str(&e.encode(&self.data));
347 out.push_str(",iv:");
348 out.push_str(&e.encode(&self.iv));
349 out.push_str(",tag:");
350 out.push_str(&e.encode(&self.tag));
351 out.push_str(",type:");
352 out.push_str(self.ty.tag());
353 out.push(']');
354 out
355 }
356
357 #[must_use]
359 pub fn leaf_type(&self) -> LeafType {
360 self.ty
361 }
362
363 #[must_use]
369 pub fn iv_len(&self) -> usize {
370 self.iv.len()
371 }
372}
373
374fn b64(s: &str, field: &'static str) -> Result<Vec<u8>, WireError> {
375 base64::engine::general_purpose::STANDARD
376 .decode(s)
377 .map_err(|_| WireError::Base64 { field })
378}
379
380#[cfg(test)]
381mod tests {
382 use super::*;
383
384 const SPECIMEN: &str = "ENC[AES256_GCM,data:+s0vLJR7FqRk1dW3+LymL5aTHh4=,iv:irJYGNHV08Ey6RyO5YfqeaNCjLg8vWcdxoQvtnYCR40=,tag:Ax+kskUPjI/gXKq6WEPTxA==,type:str]";
389
390 #[test]
391 fn parses_a_real_specimen() {
392 let leaf = EncryptedLeaf::parse(SPECIMEN).expect("parse");
393 assert_eq!(leaf.leaf_type(), LeafType::Str);
394 assert_eq!(leaf.iv_len(), 32, "sops nonces are 32 bytes, not 12");
395 assert_eq!(leaf.tag.len(), 16);
396 }
397
398 #[test]
399 fn render_round_trips_byte_exactly() {
400 let leaf = EncryptedLeaf::parse(SPECIMEN).expect("parse");
401 assert_eq!(leaf.render(), SPECIMEN);
402 }
403
404 #[test]
405 fn trailing_bytes_after_the_bracket_are_ignored_like_upstream() {
406 let with_junk = format!("{SPECIMEN} and then some");
407 let a = EncryptedLeaf::parse(SPECIMEN).expect("parse");
408 let b = EncryptedLeaf::parse(&with_junk).expect("parse with junk");
409 assert_eq!(a, b);
410 }
411
412 #[test]
413 fn a_plain_value_is_not_mistaken_for_ciphertext() {
414 assert!(!EncryptedLeaf::looks_encrypted("hello"));
415 assert!(!EncryptedLeaf::looks_encrypted(
416 "ENC[SOMETHING_ELSE,data:x]"
417 ));
418 assert!(EncryptedLeaf::looks_encrypted(SPECIMEN));
419 assert_eq!(
420 EncryptedLeaf::parse("hello"),
421 Err(WireError::NotAnEncryptedLeaf)
422 );
423 }
424
425 #[test]
426 fn unknown_datatype_is_named_not_swallowed() {
427 let bad = SPECIMEN.replace("type:str", "type:quaternion");
428 assert_eq!(
429 EncryptedLeaf::parse(&bad),
430 Err(WireError::UnknownDatatype("quaternion".into()))
431 );
432 }
433
434 #[test]
435 fn bad_base64_names_its_field() {
436 let bad = SPECIMEN.replace("iv:irJY", "iv:!!!!");
437 assert_eq!(
438 EncryptedLeaf::parse(&bad),
439 Err(WireError::Base64 { field: "iv" })
440 );
441 }
442
443 #[test]
444 fn booleans_use_python_titlecase() {
445 assert_eq!(Plaintext::boolean(true).expose(), b"True");
446 assert_eq!(Plaintext::boolean(false).expose(), b"False");
447 }
448
449 #[test]
450 fn floats_match_go_formatfloat_f_minus_one() {
451 assert_eq!(Plaintext::float(1.5).expose(), b"1.5");
453 assert_eq!(Plaintext::float(1.0).expose(), b"1");
454 assert_eq!(Plaintext::float(-0.25).expose(), b"-0.25");
455 assert_eq!(Plaintext::float(1e21).expose(), b"1000000000000000000000");
457 assert_eq!(Plaintext::float(1e-7).expose(), b"0.0000001");
458 assert_eq!(Plaintext::float(-1.5e-7).expose(), b"-0.00000015");
459 }
460
461 #[test]
462 fn debug_never_shows_the_value() {
463 let p = Plaintext::string("hunter2");
464 let shown = format!("{p:?}");
465 assert!(
466 !shown.contains("hunter2"),
467 "Debug leaked the plaintext: {shown}"
468 );
469 assert_eq!(shown, "Plaintext(*** 7 bytes, str)");
470 }
471
472 #[test]
473 fn validate_catches_a_mislabelled_leaf() {
474 let lying = Plaintext::from_wire(b"not-a-number".to_vec(), LeafType::Int);
475 assert_eq!(
476 lying.validate(),
477 Err(WireError::DatatypeMismatch { ty: "int" })
478 );
479 let rusty = Plaintext::from_wire(b"true".to_vec(), LeafType::Bool);
481 assert_eq!(
482 rusty.validate(),
483 Err(WireError::DatatypeMismatch { ty: "bool" })
484 );
485 assert_eq!(Plaintext::boolean(true).validate(), Ok(()));
486 }
487}