use std::fmt;
use zeroize::Zeroizing;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SecretKind {
Text,
Binary,
}
#[derive(Clone)]
pub struct Secret {
raw: Zeroizing<Vec<u8>>,
kind: SecretKind,
}
impl Secret {
#[must_use]
pub fn new(raw: impl Into<String>) -> Self {
Self {
raw: Zeroizing::new(raw.into().into_bytes()),
kind: SecretKind::Text,
}
}
#[must_use]
pub fn from_bytes(raw: Vec<u8>, kind: SecretKind) -> Self {
Self {
raw: Zeroizing::new(raw),
kind,
}
}
#[must_use]
pub const fn kind(&self) -> SecretKind {
self.kind
}
#[must_use]
pub const fn is_binary(&self) -> bool {
matches!(self.kind, SecretKind::Binary)
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self.kind {
SecretKind::Text => std::str::from_utf8(&self.raw).ok(),
SecretKind::Binary => None,
}
}
#[must_use]
pub fn password(&self) -> &str {
self.as_str()
.unwrap_or("")
.split('\n')
.next()
.unwrap_or("")
.trim_end_matches('\r')
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&str> {
self.fields().find(|(k, _)| *k == key).map(|(_, v)| v)
}
pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
self.as_str()
.into_iter()
.flat_map(|t| t.lines().skip(1).filter_map(parse_field))
}
#[must_use]
pub fn keys(&self) -> Vec<&str> {
self.fields().map(|(k, _)| k).collect()
}
#[must_use]
pub fn otp(&self) -> Option<&str> {
for (k, v) in self.fields() {
if matches!(k, "otpauth" | "otp" | "totp") {
return Some(v.trim());
}
}
let pw = self.password();
(!pw.is_empty()).then_some(pw)
}
#[must_use]
pub fn expose(&self) -> &str {
self.as_str().unwrap_or("")
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.raw
}
}
impl fmt::Debug for Secret {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Secret")
.field("raw", &"<redacted>")
.finish()
}
}
fn parse_field(line: &str) -> Option<(&str, &str)> {
let (key, value) = line.split_once(": ")?;
if key.is_empty() || key.contains(char::is_whitespace) {
return None;
}
Some((key, value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_is_first_line() {
let s = Secret::new("hunter2\nuser: alice\n");
assert_eq!(s.password(), "hunter2");
}
#[test]
fn password_strips_carriage_return() {
let s = Secret::new("hunter2\r\nuser: alice");
assert_eq!(s.password(), "hunter2");
}
#[test]
fn fields_are_parsed_and_queryable() {
let s = Secret::new("pw\nuser: alice\nurl: https://x.test\n");
assert_eq!(s.get("user"), Some("alice"));
assert_eq!(s.get("url"), Some("https://x.test"));
assert_eq!(s.get("missing"), None);
assert_eq!(s.keys(), vec!["user", "url"]);
}
#[test]
fn prose_with_colon_is_not_a_field() {
let s = Secret::new("pw\nNote that: this is prose\n");
assert!(s.keys().is_empty());
}
#[test]
fn first_line_is_never_a_field() {
let s = Secret::new("user: alice\n");
assert!(s.keys().is_empty());
assert_eq!(s.password(), "user: alice");
}
#[test]
fn otp_prefers_explicit_field() {
let s = Secret::new("pw\notpauth: otpauth://totp/x?secret=ABC\n");
assert_eq!(s.otp(), Some("otpauth://totp/x?secret=ABC"));
}
#[test]
fn otp_falls_back_to_password() {
let s = Secret::new("JBSWY3DPEHPK3PXP\n");
assert_eq!(s.otp(), Some("JBSWY3DPEHPK3PXP"));
}
#[test]
fn debug_redacts_contents() {
let s = Secret::new("topsecret\n");
assert!(!format!("{s:?}").contains("topsecret"));
}
#[test]
fn text_secret_kind_and_bytes() {
let s = Secret::new("hunter2\nuser: alice\n");
assert_eq!(s.kind(), SecretKind::Text);
assert!(!s.is_binary());
assert_eq!(s.as_str(), Some("hunter2\nuser: alice\n"));
}
#[test]
fn binary_secret_has_no_text_view() {
let bytes = vec![0u8, 159, 146, 150, b'\n', 0xff];
let s = Secret::from_bytes(bytes.clone(), SecretKind::Binary);
assert!(s.is_binary());
assert_eq!(s.as_bytes(), &bytes[..]);
assert_eq!(s.as_str(), None);
assert_eq!(s.password(), "");
assert!(s.keys().is_empty());
assert_eq!(s.otp(), None);
assert_eq!(s.expose(), "");
}
}