qraft-core 0.1.2

Core type system, query model, decoding, and SQL lowering primitives for qraft.
Documentation
use crate::{Compatible, DefaultMeta, Text, quex};

/// Bcrypt-backed password wrapper stored as text in the database.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Hashed(String);

impl Hashed {
    /// Hashes a plaintext password with bcrypt's non-truncating default-cost variant.
    pub async fn make(plain: impl Into<String>) -> Result<Self, HashError> {
        Self::make_with_cost(plain, bcrypt::DEFAULT_COST).await
    }

    /// Hashes a plaintext password with bcrypt's non-truncating variant and the provided cost.
    pub async fn make_with_cost(plain: impl Into<String>, cost: u32) -> Result<Self, HashError> {
        let plain = plain.into();
        let hashed = tokio::task::spawn_blocking(move || bcrypt::non_truncating_hash(plain, cost))
            .await
            .map_err(HashError::Join)?
            .map_err(HashError::Bcrypt)?;
        Ok(Self(hashed))
    }

    /// Verifies a plaintext password against the stored bcrypt hash.
    pub async fn verify(&self, plain: impl AsRef<str>) -> Result<bool, HashError> {
        let plain = plain.as_ref().to_owned();
        let hash = self.0.clone();
        tokio::task::spawn_blocking(move || bcrypt::verify(plain, &hash))
            .await
            .map_err(HashError::Join)?
            .map_err(HashError::Bcrypt)
    }

    /// Returns the stored hash string.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

impl DefaultMeta for Hashed {
    type Meta = Text;
}

impl Compatible<Text> for Hashed {}

impl quex::Encode for Hashed {
    fn encode(&self, out: quex::Encoder<'_>) {
        self.0.encode(out);
    }
}

impl quex::Decode for Hashed {
    fn decode(value: &mut quex::Decoder<'_>) -> quex::Result<Self> {
        Ok(Self(<String as quex::Decode>::decode(value)?))
    }
}

/// Errors returned while hashing or verifying passwords.
#[derive(Debug)]
pub enum HashError {
    Bcrypt(bcrypt::BcryptError),
    Join(tokio::task::JoinError),
}

impl std::fmt::Display for HashError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Bcrypt(error) => error.fmt(f),
            Self::Join(error) => error.fmt(f),
        }
    }
}

impl std::error::Error for HashError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Bcrypt(error) => Some(error),
            Self::Join(error) => Some(error),
        }
    }
}

impl From<bcrypt::BcryptError> for HashError {
    fn from(value: bcrypt::BcryptError) -> Self {
        Self::Bcrypt(value)
    }
}

impl From<tokio::task::JoinError> for HashError {
    fn from(value: tokio::task::JoinError) -> Self {
        Self::Join(value)
    }
}

#[cfg(test)]
mod tests {
    use std::error::Error;

    use super::HashError;

    #[test]
    fn hash_error_preserves_join_source() {
        let runtime = tokio::runtime::Builder::new_current_thread()
            .build()
            .expect("runtime should build");
        let error = runtime.block_on(async {
            let handle = tokio::spawn(async {
                panic!("boom");
            });
            HashError::from(handle.await.expect_err("join should fail"))
        });
        let source = error.source().expect("hash error should expose source");
        assert!(!source.to_string().is_empty());
    }
}