revault_lockbox_api 0.0.4

reVault lockbox API to create and manage lockboxes
Documentation
use std::sync::Arc;

use crate::{Error, LockboxPath, Result, SecretString};

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct FormTypeId(String);

impl FormTypeId {
    pub fn new(value: impl AsRef<str>) -> Result<Self> {
        let value = value.as_ref();
        if value.len() != 36 || !value.chars().all(|ch| ch == '-' || ch.is_ascii_hexdigit()) {
            return Err(Error::InvalidInput(format!(
                "invalid form type id: {value}"
            )));
        }
        Ok(Self(value.to_ascii_lowercase()))
    }

    pub(crate) fn new_random() -> Result<Self> {
        Ok(Self(crate::LockboxId::new_random()?.to_string()))
    }

    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl std::fmt::Display for FormTypeId {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(&self.0)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormDefinition {
    pub type_id: FormTypeId,
    pub alias: String,
    pub revision: u32,
    pub name: String,
    pub description: String,
    pub fields: Vec<FormFieldDefinition>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FormFieldDefinition {
    pub id: String,
    pub label: String,
    pub kind: FormFieldKind,
    pub required: bool,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormFieldKind {
    Text,
    Secret,
    Url,
    Email,
    Date,
    Month,
    Notes,
    Number,
}

impl FormFieldKind {
    pub fn is_secret(self) -> bool {
        matches!(self, Self::Secret)
    }

    pub(crate) fn code(self) -> u8 {
        match self {
            Self::Text => 1,
            Self::Secret => 2,
            Self::Url => 3,
            Self::Email => 4,
            Self::Date => 5,
            Self::Month => 6,
            Self::Notes => 7,
            Self::Number => 8,
        }
    }

    pub(crate) fn from_code(code: u8) -> Result<Self> {
        match code {
            1 => Ok(Self::Text),
            2 => Ok(Self::Secret),
            3 => Ok(Self::Url),
            4 => Ok(Self::Email),
            5 => Ok(Self::Date),
            6 => Ok(Self::Month),
            7 => Ok(Self::Notes),
            8 => Ok(Self::Number),
            _ => Err(Error::CorruptRecord),
        }
    }
}

#[derive(Debug, Clone)]
pub struct FormRecord {
    pub path: LockboxPath,
    pub name: String,
    pub type_id: FormTypeId,
    pub definition_alias: String,
    pub definition_revision: u32,
    pub values: Vec<FormFieldValue>,
}

#[derive(Debug, Clone)]
pub struct FormFieldValue {
    pub field_id: String,
    pub captured_label: String,
    pub kind: FormFieldKind,
    pub value: FormValue,
}

#[derive(Debug, Clone)]
pub enum FormValue {
    Normal(String),
    Secret(Arc<SecretString>),
}

impl FormValue {
    pub fn normal(value: impl Into<String>) -> Self {
        Self::Normal(value.into())
    }

    pub fn secret(value: SecretString) -> Self {
        Self::Secret(Arc::new(value))
    }

    pub fn is_secret(&self) -> bool {
        matches!(self, Self::Secret(_))
    }
}

pub(crate) fn validate_form_alias(value: &str) -> Result<String> {
    validate_identifier(value, "form alias")
}

pub(crate) fn validate_form_field_id(value: &str) -> Result<String> {
    validate_identifier(value, "form field id")
}

pub(crate) fn validate_form_label(value: &str, description: &str) -> Result<String> {
    validate_text(value, description)?;
    Ok(value.to_string())
}

pub(crate) fn validate_form_description(value: &str) -> Result<String> {
    validate_text(value, "form description")?;
    Ok(value.to_string())
}

pub(crate) fn validate_form_record_name(value: &str) -> Result<String> {
    validate_text(value, "form record name")?;
    if value.trim().is_empty() {
        return Err(Error::InvalidInput(
            "form record name cannot be empty".to_string(),
        ));
    }
    Ok(value.to_string())
}

pub(crate) fn validate_form_value(kind: FormFieldKind, value: &FormValue) -> Result<()> {
    if kind.is_secret() != value.is_secret() {
        return Err(Error::InvalidOperation(
            "form field value sensitivity does not match the field definition".to_string(),
        ));
    }
    match value {
        FormValue::Normal(value) => validate_kind_text(kind, value),
        FormValue::Secret(value) => value.with_str(|value| validate_kind_text(kind, value))?,
    }
}

fn validate_identifier(value: &str, description: &str) -> Result<String> {
    if value.is_empty()
        || value.len() > 128
        || !value
            .chars()
            .next()
            .is_some_and(|ch| ch == '_' || ch.is_ascii_alphabetic())
        || !value
            .chars()
            .all(|ch| ch == '_' || ch == '-' || ch.is_ascii_alphanumeric())
    {
        return Err(Error::InvalidInput(format!(
            "invalid {description}: {value}"
        )));
    }
    Ok(value.to_string())
}

fn validate_text(value: &str, description: &str) -> Result<()> {
    if value.len() > crate::constants::MAX_VARIABLE_VALUE_BYTES {
        return Err(Error::SecurityLimitExceeded(format!(
            "{description} exceeds {} bytes",
            crate::constants::MAX_VARIABLE_VALUE_BYTES
        )));
    }
    if value.contains('\0')
        || value.chars().any(|ch| {
            matches!(ch, '\u{0001}'..='\u{0008}' | '\u{000b}' | '\u{000c}' | '\u{000e}'..='\u{001f}' | '\u{007f}'..='\u{009f}')
        })
    {
        return Err(Error::InvalidInput(format!(
            "{description} contains unsupported control characters"
        )));
    }
    Ok(())
}

fn validate_kind_text(kind: FormFieldKind, value: &str) -> Result<()> {
    validate_text(value, "form field value")?;
    match kind {
        FormFieldKind::Url
            if !(value.is_empty()
                || value.starts_with("https://")
                || value.starts_with("http://")) =>
        {
            return Err(Error::InvalidInput(
                "url form field values must start with http:// or https://".to_string(),
            ));
        }
        FormFieldKind::Email
            if !value.is_empty()
                && (value.contains(char::is_whitespace)
                    || !value.contains('@')
                    || value.starts_with('@')
                    || value.ends_with('@')) =>
        {
            return Err(Error::InvalidInput(
                "email form field value is not a valid email address".to_string(),
            ));
        }
        FormFieldKind::Date if !value.is_empty() => validate_fixed_date(value, DateField::Date)?,
        FormFieldKind::Month if !value.is_empty() => validate_fixed_date(value, DateField::Month)?,
        FormFieldKind::Number if !value.is_empty() => {
            value.parse::<f64>().map_err(|_| {
                Error::InvalidInput("number form field value is not numeric".to_string())
            })?;
        }
        _ => {}
    }
    Ok(())
}

#[derive(Clone, Copy)]
enum DateField {
    Date,
    Month,
}

impl DateField {
    fn expected_len(self) -> usize {
        match self {
            Self::Date => 10,
            Self::Month => 7,
        }
    }

    fn format_description(self) -> &'static str {
        match self {
            Self::Date => "date form field value must use YYYY-MM-DD",
            Self::Month => "month form field value must use YYYY-MM",
        }
    }

    fn field_name(self) -> &'static str {
        match self {
            Self::Date => "date",
            Self::Month => "month",
        }
    }

    fn has_day(self) -> bool {
        matches!(self, Self::Date)
    }
}

fn validate_fixed_date(value: &str, field: DateField) -> Result<()> {
    let expected_len = field.expected_len();
    if value.len() != expected_len {
        return Err(Error::InvalidInput(field.format_description().to_string()));
    }
    let bytes = value.as_bytes();
    if bytes[4] != b'-'
        || (field.has_day() && bytes[7] != b'-')
        || !bytes
            .iter()
            .enumerate()
            .all(|(idx, byte)| idx == 4 || (field.has_day() && idx == 7) || byte.is_ascii_digit())
    {
        return Err(Error::InvalidInput(field.format_description().to_string()));
    }
    let year = value[0..4].parse::<u16>().map_err(|_| {
        Error::InvalidInput(format!(
            "{} form field value year is invalid",
            field.field_name()
        ))
    })?;
    let month = value[5..7].parse::<u8>().map_err(|_| {
        Error::InvalidInput(format!(
            "{} form field value month is invalid",
            field.field_name()
        ))
    })?;
    if !(1..=12).contains(&month) {
        return Err(Error::InvalidInput(format!(
            "{} form field value month is invalid",
            field.field_name()
        )));
    }
    if field.has_day() {
        let day = value[8..10]
            .parse::<u8>()
            .map_err(|_| Error::InvalidInput("date form field value day is invalid".to_string()))?;
        if !(1..=days_in_month(year, month)).contains(&day) {
            return Err(Error::InvalidInput(
                "date form field value day is invalid".to_string(),
            ));
        }
    }
    Ok(())
}

fn days_in_month(year: u16, month: u8) -> u8 {
    match month {
        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
        4 | 6 | 9 | 11 => 30,
        2 if is_leap_year(year) => 29,
        2 => 28,
        _ => 0,
    }
}

fn is_leap_year(year: u16) -> bool {
    year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400))
}

#[cfg(test)]
mod tests {
    use super::{validate_form_value, FormFieldKind, FormValue};

    fn invalid_message(kind: FormFieldKind, value: &str) -> String {
        validate_form_value(kind, &FormValue::normal(value))
            .unwrap_err()
            .to_string()
    }

    #[test]
    fn date_fields_require_calendar_date_format() {
        validate_form_value(FormFieldKind::Date, &FormValue::normal("2026-06-14")).unwrap();

        assert!(invalid_message(FormFieldKind::Date, "2026-06").contains("YYYY-MM-DD"));
        assert!(invalid_message(FormFieldKind::Date, "2026/06/14").contains("YYYY-MM-DD"));
        assert!(invalid_message(FormFieldKind::Date, "2026-13-14").contains("month is invalid"));
        assert!(invalid_message(FormFieldKind::Date, "2026-06-00").contains("day is invalid"));
        assert!(invalid_message(FormFieldKind::Date, "2026-02-29").contains("day is invalid"));
        validate_form_value(FormFieldKind::Date, &FormValue::normal("2024-02-29")).unwrap();
    }

    #[test]
    fn month_fields_require_year_month_format() {
        validate_form_value(FormFieldKind::Month, &FormValue::normal("2026-06")).unwrap();

        assert!(invalid_message(FormFieldKind::Month, "2026-06-14").contains("YYYY-MM"));
        assert!(invalid_message(FormFieldKind::Month, "2026/06").contains("YYYY-MM"));
        assert!(invalid_message(FormFieldKind::Month, "2026-13").contains("month is invalid"));
    }
}