tightbeam-rs 0.9.0

A secure, high-performance messaging protocol library
Documentation
#[cfg(not(feature = "std"))]
extern crate alloc;

#[cfg(not(feature = "std"))]
use alloc::vec::Vec;

use crate::builder::error::{BuildError, MetadataError};
use crate::cms::enveloped_data::EncryptedContentInfo;
use crate::matrix::MatrixDyn;
use crate::{Asn1Matrix, CompressedData, DigestInfo, MessagePriority, Metadata, Version};

/// A fluent builder for TightBeam metadata.
pub struct MetadataBuilder {
	version: Version,
	id: Option<Vec<u8>>,
	order: Option<u64>,
	integrity: Option<DigestInfo>,
	compactness: Option<CompressedData>,
	confidentiality: Option<EncryptedContentInfo>,
	priority: Option<MessagePriority>,
	lifetime: Option<u64>,
	previous_frame: Option<DigestInfo>,
	matrix: Option<MatrixDyn>,
}

impl From<Version> for MetadataBuilder {
	fn from(version: Version) -> Self {
		Self {
			version,
			id: None,
			order: None,
			integrity: None,
			compactness: None,
			confidentiality: None,
			priority: None,
			lifetime: None,
			previous_frame: None,
			matrix: None,
		}
	}
}

impl MetadataBuilder {
	/// Set the message ID from bytes.
	/// This is useful for idempotence.
	pub fn with_id(mut self, id: impl AsRef<[u8]>) -> Self {
		self.id = Some(id.as_ref().to_vec());
		self
	}

	/// Set the order
	/// Commonly a Unix timestamp
	pub fn with_order(mut self, seconds: u64) -> Self {
		self.order = Some(seconds);
		self
	}

	/// Set the integrity information
	pub fn with_integrity_info(mut self, hash: DigestInfo) -> Self {
		self.integrity = Some(hash);
		self
	}

	/// Set the compression information
	pub fn with_compactness_info(mut self, compression: CompressedData) -> Self {
		self.compactness = Some(compression);
		self
	}

	/// Set the encryption information
	pub fn with_confidentiality_info(mut self, encryption: EncryptedContentInfo) -> Self {
		self.confidentiality = Some(encryption);
		self
	}

	/// Set the message priority (V2+ only)
	pub fn with_priority(mut self, priority: MessagePriority) -> Self {
		self.priority = Some(priority);
		self
	}

	/// Set the time-to-live in seconds (V2+ only)
	pub fn with_lifetime(mut self, seconds: u64) -> Self {
		self.lifetime = Some(seconds);
		self
	}

	/// Set the previous hash reference (V2+ only)
	pub fn previous_frame(mut self, previous: DigestInfo) -> Self {
		self.previous_frame = Some(previous);
		self
	}

	/// Set the routing matrix (V3+ only)
	pub fn with_matrix(mut self, matrix: MatrixDyn) -> Self {
		self.matrix = Some(matrix);
		self
	}

	/// Build the metadata based on the protocol version
	///
	/// # Errors
	/// Returns an error if required fields are missing, or if a set field is
	/// not permitted by the specified version
	pub fn build(self) -> Result<Metadata, BuildError> {
		let Self {
			version,
			id,
			order,
			integrity,
			compactness,
			confidentiality,
			priority,
			lifetime,
			previous_frame,
			matrix,
		} = self;

		let id = id.ok_or(BuildError::InvalidMetadata(MetadataError::MissingId))?;
		let order = order.ok_or(BuildError::InvalidMetadata(MetadataError::MissingTimestamp))?;

		macro_rules! reject_unsupported {
			($value:ident, $allows:ident) => {
				if $value.is_some() && !version.$allows() {
					return Err(BuildError::InvalidMetadata(MetadataError::UnsupportedField {
						field: stringify!($value),
						version,
					}));
				}
			};
		}

		reject_unsupported!(integrity, allows_integrity);
		reject_unsupported!(confidentiality, allows_confidentiality);
		reject_unsupported!(priority, allows_priority);
		reject_unsupported!(lifetime, allows_lifetime);
		reject_unsupported!(previous_frame, allows_previous_frame);
		reject_unsupported!(matrix, allows_matrix);

		let matrix = if let Some(m) = matrix {
			Some(Asn1Matrix::try_from(m)?)
		} else {
			None
		};

		Ok(Metadata {
			id,
			order,
			compactness,
			integrity,
			confidentiality,
			priority,
			lifetime,
			previous_frame,
			matrix,
		})
	}

	/// Check if ID is set
	pub fn has_id(&self) -> bool {
		self.id.is_some()
	}

	/// Check if priority is set
	pub fn has_priority(&self) -> bool {
		self.priority.is_some()
	}

	// Check if order is set
	pub fn has_order(&self) -> bool {
		self.order.is_some()
	}

	/// Check if lifetime is set
	pub fn has_lifetime(&self) -> bool {
		self.lifetime.is_some()
	}

	/// Check if previous hash is set
	pub fn has_previous(&self) -> bool {
		self.previous_frame.is_some()
	}

	/// Check if matrix is set
	pub fn has_matrix(&self) -> bool {
		self.matrix.is_some()
	}

	/// Check if compression is set
	pub fn has_compression(&self) -> bool {
		self.compactness.is_some()
	}

	/// Check if integrity info is set
	pub fn has_integrity(&self) -> bool {
		self.integrity.is_some()
	}
}

#[cfg(test)]
mod tests {
	use super::*;
	use crate::testing::create_test_hash_info;

	macro_rules! test_metadata_builder {
		($test_name:ident, $version:expr, $builder:expr) => {
			#[test]
			fn $test_name() {
				let metadata = $builder
					.build()
					.expect(concat!("Failed to build ", stringify!($version), " metadata"));

				// X.509-style: single structure, validate based on version
				match $version {
					Version::V0 => {
						assert!(metadata.priority.is_none());
						assert!(metadata.lifetime.is_none());
						assert!(metadata.previous_frame.is_none());
						assert!(metadata.matrix.is_none());
					}
					Version::V1 => {
						assert!(metadata.priority.is_none());
						assert!(metadata.lifetime.is_none());
						assert!(metadata.previous_frame.is_none());
						assert!(metadata.matrix.is_none());
					}
					Version::V2 => {
						assert!(metadata.priority.is_some());
						assert!(metadata.matrix.is_none());
					}
					Version::V3 => {
						assert!(metadata.priority.is_some());
						assert!(metadata.matrix.is_some());
					}
				}
			}
		};
	}

	// V0 permits no integrity info; setting one is covered by the rejection
	// tests below.
	test_metadata_builder!(
		test_metadata_builder_v0,
		Version::V0,
		MetadataBuilder::from(Version::V0)
			.with_id("test-id-v0")
			.with_order(1696521600u64)
	);

	test_metadata_builder!(
		test_metadata_builder_v1,
		Version::V1,
		MetadataBuilder::from(Version::V1)
			.with_id("test-id-v1")
			.with_order(1696521600u64)
			.with_integrity_info(create_test_hash_info())
	);

	test_metadata_builder!(
		test_metadata_builder_v2,
		Version::V2,
		MetadataBuilder::from(Version::V2)
			.with_id("test-id-v2")
			.with_order(1696521600u64)
			.with_integrity_info(create_test_hash_info())
			.with_priority(MessagePriority::LowLatency)
			.with_lifetime(3600)
	);

	test_metadata_builder!(
		test_metadata_builder_v3,
		Version::V3,
		MetadataBuilder::from(Version::V3)
			.with_id("test-id-v3")
			.with_order(1696521600u64)
			.with_integrity_info(create_test_hash_info())
			.with_priority(MessagePriority::LowLatency)
			.with_lifetime(3600)
			.with_matrix(MatrixDyn::try_from(2u8).unwrap())
	);

	#[test]
	fn test_metadata_builder_missing_required_fields() {
		let result = MetadataBuilder::from(Version::V0).with_id("test-id").build();

		assert!(result.is_err());
		assert!(matches!(
			result.unwrap_err(),
			BuildError::InvalidMetadata(MetadataError::MissingTimestamp)
		));
	}

	mod errors {
		use super::*;

		struct ErrorTestCase {
			name: &'static str,
			builder: fn() -> MetadataBuilder,
			expected_error: MetadataError,
		}

		#[test]
		fn test_metadata_validation_errors() {
			let test_cases = [
				ErrorTestCase {
					name: "V0 missing id",
					builder: || MetadataBuilder::from(Version::V0).with_order(1696521600),
					expected_error: MetadataError::MissingId,
				},
				ErrorTestCase {
					name: "V0 missing order",
					builder: || MetadataBuilder::from(Version::V0).with_id("test-id"),
					expected_error: MetadataError::MissingTimestamp,
				},
				ErrorTestCase {
					name: "V1 missing id",
					builder: || MetadataBuilder::from(Version::V1).with_order(1696521600),
					expected_error: MetadataError::MissingId,
				},
				ErrorTestCase {
					name: "V1 missing order",
					builder: || MetadataBuilder::from(Version::V1).with_id("test-id"),
					expected_error: MetadataError::MissingTimestamp,
				},
				ErrorTestCase {
					name: "V2 missing id",
					builder: || MetadataBuilder::from(Version::V2).with_order(1696521600),
					expected_error: MetadataError::MissingId,
				},
				ErrorTestCase {
					name: "V2 missing order",
					builder: || MetadataBuilder::from(Version::V2).with_id("test-id"),
					expected_error: MetadataError::MissingTimestamp,
				},
				ErrorTestCase {
					name: "V0 rejects integrity",
					builder: || {
						MetadataBuilder::from(Version::V0)
							.with_id("test-id")
							.with_order(1696521600)
							.with_integrity_info(create_test_hash_info())
					},
					expected_error: MetadataError::UnsupportedField { field: "integrity", version: Version::V0 },
				},
				ErrorTestCase {
					name: "V0 rejects priority",
					builder: || {
						MetadataBuilder::from(Version::V0)
							.with_id("test-id")
							.with_order(1696521600)
							.with_priority(MessagePriority::LowLatency)
					},
					expected_error: MetadataError::UnsupportedField { field: "priority", version: Version::V0 },
				},
				ErrorTestCase {
					name: "V1 rejects lifetime",
					builder: || {
						MetadataBuilder::from(Version::V1)
							.with_id("test-id")
							.with_order(1696521600)
							.with_lifetime(3600)
					},
					expected_error: MetadataError::UnsupportedField { field: "lifetime", version: Version::V1 },
				},
				ErrorTestCase {
					name: "V0 rejects previous_frame",
					builder: || {
						MetadataBuilder::from(Version::V0)
							.with_id("test-id")
							.with_order(1696521600)
							.previous_frame(create_test_hash_info())
					},
					expected_error: MetadataError::UnsupportedField { field: "previous_frame", version: Version::V0 },
				},
				ErrorTestCase {
					name: "V2 rejects matrix",
					builder: || {
						MetadataBuilder::from(Version::V2)
							.with_id("test-id")
							.with_order(1696521600)
							.with_matrix(MatrixDyn::default())
					},
					expected_error: MetadataError::UnsupportedField { field: "matrix", version: Version::V2 },
				},
			];

			for case in test_cases {
				let result = (case.builder)().build();
				assert!(
					matches!(result, Err(BuildError::InvalidMetadata(ref err)) if *err == case.expected_error),
					"{}",
					case.name
				);
			}
		}
	}
}