use chrono::Utc;
use serde::{Deserialize, Serialize};
use super::{decode_tc_string, GlobalVendorList, Result, TCModel};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ValidationResult {
pub is_valid: bool,
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationError {
InvalidVersion(u8),
InvalidTimestamp {
field: String,
reason: String,
},
MissingDisclosedVendors,
UnknownVendor(u16),
InvalidPurposeId(u8),
InvalidSegmentType {
expected: u8,
found: u8,
},
MalformedTCString(String),
EmptyVendorSet {
segment: String,
},
SegmentInconsistency {
description: String,
},
InvalidCmpId(u16),
PolicyVersionMismatch {
core_version: u8,
expected: u8,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ValidationWarning {
OldVersion {
version: u8,
latest: u8,
},
UnknownVendorWarning(u16),
DeletedVendor {
id: u16,
deleted_date: String,
},
NoConsents,
NoPurposes,
OldVendorListVersion {
version: u16,
latest: u16,
},
SuspiciousTimestamp {
field: String,
reason: String,
},
VendorNotDisclosed(u16),
VendorDisclosedButNotConsented(u16),
}
#[must_use = "TC string validation result must be checked"]
pub fn validate_tc_string(tc_string: &str) -> Result<ValidationResult> {
let mut errors = Vec::new();
let mut warnings = Vec::new();
let tc_model = match decode_tc_string(tc_string) {
Ok(model) => model,
Err(e) => {
errors.push(ValidationError::MalformedTCString(e.to_string()));
return Ok(ValidationResult {
is_valid: false,
errors,
warnings,
});
}
};
validate_version(&tc_model, &mut errors, &mut warnings);
validate_timestamps(&tc_model, &mut errors, &mut warnings);
validate_mandatory_segments(&tc_model, &mut errors);
validate_purposes(&tc_model, &mut errors, &mut warnings);
validate_vendors(&tc_model, &mut errors, &mut warnings);
validate_segment_consistency(&tc_model, &mut errors, &mut warnings);
validate_policy_version(&tc_model, &mut errors);
Ok(ValidationResult {
is_valid: errors.is_empty(),
errors,
warnings,
})
}
#[must_use = "TC string validation result must be checked"]
pub fn validate_tc_string_with_gvl(
tc_string: &str,
gvl: &GlobalVendorList,
) -> Result<ValidationResult> {
let mut result = validate_tc_string(tc_string)?;
let tc_model = decode_tc_string(tc_string)?;
validate_vendors_against_gvl(&tc_model, gvl, &mut result.errors, &mut result.warnings);
validate_vendor_list_version(&tc_model, gvl, &mut result.warnings);
result.is_valid = result.errors.is_empty();
Ok(result)
}
#[allow(clippy::ptr_arg)] fn validate_version(
tc_model: &TCModel,
errors: &mut Vec<ValidationError>,
_warnings: &mut Vec<ValidationWarning>,
) {
let version = tc_model.core_string.version;
if version != 2 {
errors.push(ValidationError::InvalidVersion(version));
}
}
fn validate_timestamps(
tc_model: &TCModel,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) {
let now = Utc::now();
let created = tc_model.core_string.created;
let last_updated = tc_model.core_string.last_updated;
if created > now {
errors.push(ValidationError::InvalidTimestamp {
field: "created".to_string(),
reason: "Timestamp is in the future".to_string(),
});
}
if last_updated < created {
errors.push(ValidationError::InvalidTimestamp {
field: "last_updated".to_string(),
reason: "Last updated is before created timestamp".to_string(),
});
}
if last_updated > now {
errors.push(ValidationError::InvalidTimestamp {
field: "last_updated".to_string(),
reason: "Timestamp is in the future".to_string(),
});
}
let age_days = (now - created).num_days();
if age_days > 395 {
warnings.push(ValidationWarning::SuspiciousTimestamp {
field: "created".to_string(),
reason: format!(
"Consent is {age_days} days old (GDPR recommends refresh every 13 months)"
),
});
}
}
fn validate_mandatory_segments(tc_model: &TCModel, errors: &mut Vec<ValidationError>) {
if tc_model.disclosed_vendors.is_none() {
errors.push(ValidationError::MissingDisclosedVendors);
}
}
fn validate_purposes(
tc_model: &TCModel,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) {
let has_any_consent = (0..24).any(|i| tc_model.core_string.purposes_consent.is_set(i));
if !has_any_consent {
warnings.push(ValidationWarning::NoPurposes);
}
for restriction in &tc_model.core_string.publisher_restrictions.restrictions {
if restriction.purpose_id == 0 || restriction.purpose_id > 24 {
errors.push(ValidationError::InvalidPurposeId(restriction.purpose_id));
}
}
if let Some(ref _pub_tc) = tc_model.publisher_tc {
}
}
fn validate_vendors(
tc_model: &TCModel,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) {
let consent_count = tc_model.core_string.vendor_consents.len();
if consent_count == 0 {
warnings.push(ValidationWarning::NoConsents);
}
if let Some(ref disclosed) = tc_model.disclosed_vendors {
if disclosed.vendors.is_empty() {
errors.push(ValidationError::EmptyVendorSet {
segment: "disclosed_vendors".to_string(),
});
}
}
if let Some(ref allowed) = tc_model.allowed_vendors {
if allowed.vendors.is_empty() {
warnings.push(ValidationWarning::VendorDisclosedButNotConsented(0));
}
}
}
#[allow(clippy::ptr_arg)] fn validate_segment_consistency(
tc_model: &TCModel,
_errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) {
if let Some(ref disclosed) = tc_model.disclosed_vendors {
let consented_vendors = tc_model.core_string.vendor_consents.to_vec();
let disclosed_vendors = disclosed.vendors.to_vec();
for vendor_id in &consented_vendors {
if !disclosed_vendors.contains(vendor_id) {
warnings.push(ValidationWarning::VendorNotDisclosed(*vendor_id));
}
}
for vendor_id in &disclosed_vendors {
let has_consent = consented_vendors.contains(vendor_id);
let has_li = tc_model
.core_string
.vendor_legitimate_interests
.contains(*vendor_id);
if !has_consent && !has_li {
warnings.push(ValidationWarning::VendorDisclosedButNotConsented(
*vendor_id,
));
}
}
}
}
fn validate_policy_version(tc_model: &TCModel, errors: &mut Vec<ValidationError>) {
let policy_version = tc_model.core_string.tcf_policy_version;
if policy_version != 2 {
errors.push(ValidationError::PolicyVersionMismatch {
core_version: policy_version,
expected: 2,
});
}
}
fn validate_vendors_against_gvl(
tc_model: &TCModel,
gvl: &GlobalVendorList,
errors: &mut Vec<ValidationError>,
warnings: &mut Vec<ValidationWarning>,
) {
let all_vendor_ids: Vec<u16> = tc_model
.core_string
.vendor_consents
.to_vec()
.into_iter()
.chain(tc_model.core_string.vendor_legitimate_interests.to_vec())
.collect();
for vendor_id in all_vendor_ids {
if let Some(vendor) = gvl.get_vendor(vendor_id) {
if vendor.is_deleted() {
warnings.push(ValidationWarning::DeletedVendor {
id: vendor_id,
deleted_date: vendor.deleted_date.clone().unwrap_or_default(),
});
}
} else {
errors.push(ValidationError::UnknownVendor(vendor_id));
}
}
}
fn validate_vendor_list_version(
tc_model: &TCModel,
gvl: &GlobalVendorList,
warnings: &mut Vec<ValidationWarning>,
) {
let tc_version = tc_model.core_string.vendor_list_version;
let gvl_version = gvl.vendor_list_version;
if tc_version < gvl_version {
warnings.push(ValidationWarning::OldVendorListVersion {
version: tc_version,
latest: gvl_version,
});
}
}
#[must_use = "Validation result must be checked"]
pub fn quick_validate(tc_string: &str) -> Result<bool> {
let tc_model = decode_tc_string(tc_string)?;
if tc_model.core_string.version != 2 {
return Ok(false);
}
if tc_model.disclosed_vendors.is_none() {
return Ok(false);
}
let now = Utc::now();
if tc_model.core_string.created > now || tc_model.core_string.last_updated > now {
return Ok(false);
}
if tc_model.core_string.last_updated < tc_model.core_string.created {
return Ok(false);
}
Ok(true)
}
impl ValidationResult {
#[must_use]
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
#[must_use]
pub fn has_warnings(&self) -> bool {
!self.warnings.is_empty()
}
#[must_use]
pub fn error_count(&self) -> usize {
self.errors.len()
}
#[must_use]
pub fn warning_count(&self) -> usize {
self.warnings.len()
}
#[must_use]
pub fn summary(&self) -> String {
if self.is_valid && !self.has_warnings() {
"TC String is valid".to_string()
} else if self.is_valid {
format!(
"TC String is valid with {} warning(s)",
self.warning_count()
)
} else {
format!(
"TC String is invalid: {} error(s), {} warning(s)",
self.error_count(),
self.warning_count()
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compliance::iab_tcf::{encode_tc_string, CoreString, DisclosedVendors, VendorSet};
use std::collections::HashSet;
fn create_valid_tc_model() -> TCModel {
let mut core = CoreString::new();
core.version = 2;
core.tcf_policy_version = 2;
core.purposes_consent.set(0, true);
let mut vendors = HashSet::new();
vendors.insert(1);
vendors.insert(35);
core.vendor_consents = VendorSet::BitField(vendors.clone());
let disclosed = DisclosedVendors::new(VendorSet::BitField(vendors));
TCModel {
core_string: core,
disclosed_vendors: Some(disclosed),
allowed_vendors: None,
publisher_tc: None,
}
}
#[test]
fn test_validate_valid_tc_string() -> Result<()> {
let tc_model = create_valid_tc_model();
let tc_string = encode_tc_string(&tc_model)?;
let result = validate_tc_string(&tc_string)?;
assert!(result.is_valid);
assert!(result.errors.is_empty());
Ok(())
}
#[test]
fn test_validate_missing_disclosed_vendors() {
let mut tc_model = create_valid_tc_model();
tc_model.disclosed_vendors = None;
let mut errors = Vec::new();
validate_mandatory_segments(&tc_model, &mut errors);
assert!(!errors.is_empty());
assert!(matches!(
errors[0],
ValidationError::MissingDisclosedVendors
));
}
#[test]
fn test_validate_invalid_version() {
let mut tc_model = create_valid_tc_model();
tc_model.core_string.version = 3;
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_version(&tc_model, &mut errors, &mut warnings);
assert!(!errors.is_empty());
assert!(matches!(errors[0], ValidationError::InvalidVersion(3)));
}
#[test]
fn test_validate_timestamps() {
let tc_model = create_valid_tc_model();
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_timestamps(&tc_model, &mut errors, &mut warnings);
assert!(errors.is_empty());
}
#[test]
fn test_validate_future_timestamp() {
use chrono::Duration;
let mut tc_model = create_valid_tc_model();
tc_model.core_string.created = Utc::now() + Duration::days(1);
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_timestamps(&tc_model, &mut errors, &mut warnings);
assert!(!errors.is_empty());
}
#[test]
fn test_validate_no_purposes() {
let mut tc_model = create_valid_tc_model();
tc_model.core_string.purposes_consent = crate::compliance::iab_tcf::BitField::new(24);
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_purposes(&tc_model, &mut errors, &mut warnings);
assert!(warnings
.iter()
.any(|w| matches!(w, ValidationWarning::NoPurposes)));
}
#[test]
fn test_validate_no_vendors() {
let mut tc_model = create_valid_tc_model();
tc_model.core_string.vendor_consents = VendorSet::new_bitfield();
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_vendors(&tc_model, &mut errors, &mut warnings);
assert!(warnings
.iter()
.any(|w| matches!(w, ValidationWarning::NoConsents)));
}
#[test]
fn test_validate_segment_consistency() {
let mut tc_model = create_valid_tc_model();
let mut vendors = HashSet::new();
vendors.insert(1);
vendors.insert(35);
vendors.insert(100); tc_model.core_string.vendor_consents = VendorSet::BitField(vendors);
let mut errors = Vec::new();
let mut warnings = Vec::new();
validate_segment_consistency(&tc_model, &mut errors, &mut warnings);
assert!(warnings
.iter()
.any(|w| matches!(w, ValidationWarning::VendorNotDisclosed(100))));
}
#[test]
fn test_validate_policy_version() {
let mut tc_model = create_valid_tc_model();
tc_model.core_string.tcf_policy_version = 1;
let mut errors = Vec::new();
validate_policy_version(&tc_model, &mut errors);
assert!(!errors.is_empty());
}
#[test]
fn test_quick_validate_valid() -> Result<()> {
let tc_model = create_valid_tc_model();
let tc_string = encode_tc_string(&tc_model)?;
let is_valid = quick_validate(&tc_string)?;
assert!(is_valid);
Ok(())
}
#[test]
fn test_quick_validate_invalid_version() {
let mut tc_model = create_valid_tc_model();
tc_model.core_string.version = 3;
let is_valid = tc_model.core_string.version == 2;
assert!(!is_valid);
}
#[test]
fn test_validation_result_helpers() -> Result<()> {
let tc_model = create_valid_tc_model();
let tc_string = encode_tc_string(&tc_model)?;
let result = validate_tc_string(&tc_string)?;
assert!(!result.has_errors());
assert_eq!(result.error_count(), 0);
assert!(result.summary().contains("valid"));
Ok(())
}
}