use crate::migrate::OnlineSafetyClassification;
#[cfg(feature = "aes-codec")]
mod error_types {
#[derive(Debug)]
pub enum CodecError {
MissingKey { index: u8, kind: MissingKeyKind },
RingEmpty,
CiphertextTooShort,
UnknownVersion(u8),
UnknownKeyIndex { index: u8, ring_len: u8 },
AeadError(aes_gcm::Error),
RngFailure(getrandom::Error),
Utf8Error(std::string::FromUtf8Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MissingKeyKind {
Gap,
Malformed,
}
impl std::fmt::Display for CodecError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CodecError::MissingKey { index, kind } => match kind {
MissingKeyKind::Gap => write!(
f,
"ring gap: DJOGI_FIELD_CODEC_KEY_{index} is not set but a higher index is present"
),
MissingKeyKind::Malformed => write!(
f,
"malformed key: DJOGI_FIELD_CODEC_KEY_{index} must be 64 lowercase hex characters"
),
},
CodecError::RingEmpty => f.write_str(
"no field codec key configured: set DJOGI_FIELD_CODEC_KEY_0 (64 lowercase hex characters)",
),
CodecError::CiphertextTooShort => {
f.write_str("ciphertext too short: minimum valid ciphertext is 30 bytes")
}
CodecError::UnknownVersion(v) => write!(f, "unknown ciphertext version byte: {v}"),
CodecError::UnknownKeyIndex { index, ring_len } => {
write!(f, "key index {index} not in ring of length {ring_len}")
}
CodecError::AeadError(_) => f.write_str("generic AEAD error"),
CodecError::RngFailure(_) => {
f.write_str("OS CSPRNG failed while generating nonce")
}
CodecError::Utf8Error(e) => write!(f, "decoded bytes are not valid UTF-8: {e}"),
}
}
}
impl std::error::Error for CodecError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
CodecError::AeadError(_) => None,
CodecError::RngFailure(e) => Some(e),
CodecError::Utf8Error(e) => Some(e),
CodecError::MissingKey { .. }
| CodecError::RingEmpty
| CodecError::CiphertextTooShort
| CodecError::UnknownVersion(_)
| CodecError::UnknownKeyIndex { .. } => None,
}
}
}
impl From<aes_gcm::Error> for CodecError {
fn from(e: aes_gcm::Error) -> Self {
CodecError::AeadError(e)
}
}
impl From<getrandom::Error> for CodecError {
fn from(e: getrandom::Error) -> Self {
CodecError::RngFailure(e)
}
}
impl From<std::string::FromUtf8Error> for CodecError {
fn from(e: std::string::FromUtf8Error) -> Self {
CodecError::Utf8Error(e)
}
}
#[derive(Debug)]
pub struct CodecStartupError {
pub codec_id: &'static str,
pub env_var: &'static str,
pub error: String,
}
impl std::fmt::Display for CodecStartupError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} requires {}: {}",
self.codec_id, self.env_var, self.error
)
}
}
impl std::error::Error for CodecStartupError {}
}
#[cfg(feature = "aes-codec")]
pub use error_types::{CodecError, CodecStartupError, MissingKeyKind};
#[cfg(feature = "aes-codec")]
pub(crate) mod aes;
pub trait FieldCodec: Send + Sync + 'static {
const ID: &'static str;
type Decoded;
type Encoded;
type Error: std::error::Error + Send + Sync + 'static;
fn encode(
model: &'static str,
field: &'static str,
value: &Self::Decoded,
) -> Result<Self::Encoded, Self::Error>;
fn decode(
model: &'static str,
field: &'static str,
stored: &Self::Encoded,
) -> Result<Self::Decoded, Self::Error>;
fn classify_transition<Other: FieldCodec>() -> OnlineSafetyClassification;
}
#[cfg(feature = "aes-codec")]
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
#[doc(hidden)]
pub struct FieldCodecStartupRequirement {
pub codec_id: &'static str,
pub env_var: &'static str,
pub validate: fn() -> Result<(), CodecError>,
}
#[cfg(feature = "aes-codec")]
impl FieldCodecStartupRequirement {
#[doc(hidden)]
pub const fn const_new(
codec_id: &'static str,
env_var: &'static str,
validate: fn() -> Result<(), CodecError>,
) -> Self {
Self {
codec_id,
env_var,
validate,
}
}
}
#[cfg(feature = "aes-codec")]
inventory::collect!(FieldCodecStartupRequirement);
#[cfg(feature = "aes-codec")]
pub fn validate_codec_startup_inventory() -> Result<(), Vec<CodecStartupError>> {
let mut errors: Vec<CodecStartupError> = Vec::new();
for req in ::inventory::iter::<FieldCodecStartupRequirement> {
if let Err(e) = (req.validate)() {
errors.push(CodecStartupError {
codec_id: req.codec_id,
env_var: req.env_var,
error: e.to_string(),
});
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(feature = "aes-codec")]
pub(crate) static REGISTRY: phf::Set<&'static str> = phf::phf_set! {
"aes256_gcm_v1",
};
#[cfg(not(feature = "aes-codec"))]
pub(crate) static REGISTRY: phf::Set<&'static str> = phf::phf_set! {};
#[doc(hidden)]
pub fn is_registered(id: &str) -> bool {
REGISTRY.contains(id)
}
#[cfg(test)]
mod tests {
use super::{FieldCodec, is_registered};
use crate::migrate::OnlineSafetyClassification;
use std::fmt;
#[derive(Debug)]
struct Utf8RoundtripError;
impl fmt::Display for Utf8RoundtripError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("utf-8 round-trip failed")
}
}
impl std::error::Error for Utf8RoundtripError {}
struct Utf8Roundtrip;
impl FieldCodec for Utf8Roundtrip {
const ID: &'static str = "_djogi_test_utf8_roundtrip";
type Decoded = String;
type Encoded = Vec<u8>;
type Error = Utf8RoundtripError;
fn encode(
_model: &'static str,
_field: &'static str,
value: &Self::Decoded,
) -> Result<Self::Encoded, Self::Error> {
Ok(value.as_bytes().to_vec())
}
fn decode(
_model: &'static str,
_field: &'static str,
stored: &Self::Encoded,
) -> Result<Self::Decoded, Self::Error> {
std::str::from_utf8(stored)
.map(|s| s.to_owned())
.map_err(|_| Utf8RoundtripError)
}
fn classify_transition<Other: FieldCodec>() -> OnlineSafetyClassification {
if Self::ID == Other::ID {
OnlineSafetyClassification::OnlineSafe
} else {
OnlineSafetyClassification::OfflineOnly
}
}
}
struct OtherTestCodec;
impl FieldCodec for OtherTestCodec {
const ID: &'static str = "_djogi_test_other";
type Decoded = String;
type Encoded = Vec<u8>;
type Error = Utf8RoundtripError;
fn encode(
_model: &'static str,
_field: &'static str,
value: &Self::Decoded,
) -> Result<Self::Encoded, Self::Error> {
Ok(value.as_bytes().to_vec())
}
fn decode(
_model: &'static str,
_field: &'static str,
stored: &Self::Encoded,
) -> Result<Self::Decoded, Self::Error> {
std::str::from_utf8(stored)
.map(|s| s.to_owned())
.map_err(|_| Utf8RoundtripError)
}
fn classify_transition<Other: FieldCodec>() -> OnlineSafetyClassification {
if Self::ID == Other::ID {
OnlineSafetyClassification::OnlineSafe
} else {
OnlineSafetyClassification::ExpandContract
}
}
}
#[test]
fn encode_decode_round_trips() {
let original = String::from("djogi field codec round-trip");
let encoded = Utf8Roundtrip::encode("TestModel", "name", &original).expect("encode");
let decoded = Utf8Roundtrip::decode("TestModel", "name", &encoded).expect("decode");
assert_eq!(decoded, original);
}
#[cfg(not(feature = "aes-codec"))]
#[test]
fn registry_empty_without_aes_codec_feature() {
assert!(!is_registered("aes256_gcm_v1"));
}
#[cfg(feature = "aes-codec")]
#[test]
fn registry_contains_aes_codec_when_feature_enabled() {
assert!(is_registered("aes256_gcm_v1"));
}
#[test]
fn registry_does_not_contain_test_codec_id() {
assert!(!is_registered(Utf8Roundtrip::ID));
assert!(!is_registered(OtherTestCodec::ID));
}
#[test]
fn classify_transition_to_self_is_online_safe() {
let classification = Utf8Roundtrip::classify_transition::<Utf8Roundtrip>();
assert_eq!(classification, OnlineSafetyClassification::OnlineSafe);
}
#[test]
fn classify_transition_across_codecs_is_callable() {
let to_other = Utf8Roundtrip::classify_transition::<OtherTestCodec>();
assert_eq!(to_other, OnlineSafetyClassification::OfflineOnly);
let from_other = OtherTestCodec::classify_transition::<Utf8Roundtrip>();
assert_eq!(from_other, OnlineSafetyClassification::ExpandContract);
}
}