use serde_json::Value;
use super::{
InviteAbuseAction, InviteAbuseReason, InviteAbuseStatus, InviteAbuseSubject, InviteAbuseSubjectKind,
PersistedInviteAbuseError, PersistedInviteAbuseRecord, invite_abuse_status, normalize_domain, workspace_subject_key,
};
fn required<'a>(value: &'a Option<String>, field: &'static str) -> Result<&'a str, PersistedInviteAbuseError> {
value.as_deref().ok_or(PersistedInviteAbuseError::Missing(field))
}
fn hash_identity(value: &str, prefix: &str, length: usize) -> bool {
value.strip_prefix(prefix).is_some_and(|hash| {
hash.len() == length
&& hash
.bytes()
.all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f'))
})
}
fn decode_kind(value: &str) -> Result<InviteAbuseSubjectKind, PersistedInviteAbuseError> {
InviteAbuseSubjectKind::from_persisted(value).ok_or(PersistedInviteAbuseError::Invalid("subject kind"))
}
fn decode_status(value: &str) -> Result<InviteAbuseStatus, PersistedInviteAbuseError> {
InviteAbuseStatus::from_persisted(value).ok_or(PersistedInviteAbuseError::Invalid("subject status"))
}
fn decode_action(value: &str) -> Result<InviteAbuseAction, PersistedInviteAbuseError> {
InviteAbuseAction::from_persisted(value).ok_or(PersistedInviteAbuseError::Invalid("subject action"))
}
fn decode_reason(value: &str) -> Result<InviteAbuseReason, PersistedInviteAbuseError> {
InviteAbuseReason::from_persisted(value).ok_or(PersistedInviteAbuseError::Invalid("subject reason"))
}
fn validate_targets(value: &Value, requested: i32) -> Result<(), PersistedInviteAbuseError> {
let targets = value
.as_array()
.ok_or(PersistedInviteAbuseError::Invalid("evidence target domains"))?;
let mut total = 0_i32;
for target in targets {
let target = target
.as_object()
.filter(|target| target.len() == 2)
.ok_or(PersistedInviteAbuseError::Invalid("evidence target domains"))?;
let domain = target
.get("domain")
.and_then(Value::as_str)
.ok_or(PersistedInviteAbuseError::Invalid("evidence target domains"))?;
let count = target
.get("count")
.and_then(Value::as_i64)
.and_then(|count| i32::try_from(count).ok())
.filter(|count| *count > 0)
.ok_or(PersistedInviteAbuseError::Invalid("evidence target domains"))?;
if domain.is_empty() || domain != normalize_domain(domain) {
return Err(PersistedInviteAbuseError::Invalid("evidence target domains"));
}
total = total
.checked_add(count)
.ok_or(PersistedInviteAbuseError::CounterOverflow)?;
}
if total != requested {
return Err(PersistedInviteAbuseError::Inconsistent("requested counter"));
}
Ok(())
}
pub fn validate_persisted_invite_abuse(
record: &PersistedInviteAbuseRecord,
) -> Result<InviteAbuseSubject, PersistedInviteAbuseError> {
let kind = decode_kind(&record.subject_kind)?;
let status = decode_status(&record.subject_status)?;
let action = decode_action(required(&record.subject_action, "subject action")?)?;
let reason = decode_reason(required(&record.subject_reason, "subject reason")?)?;
if required(&record.evidence_action, "evidence action")? != action.as_str()
|| record
.action_action
.as_deref()
.is_some_and(|value| value != action.as_str())
{
return Err(PersistedInviteAbuseError::Inconsistent("action"));
}
if required(&record.evidence_reason, "evidence reason")? != reason.as_str() {
return Err(PersistedInviteAbuseError::Inconsistent("reason"));
}
if required(&record.evidence_subject_key, "evidence subject key")? != record.subject_key {
return Err(PersistedInviteAbuseError::Inconsistent("subject key"));
}
if invite_abuse_status(kind, action, reason) != Some(status) {
return Err(PersistedInviteAbuseError::Invalid("subject combination"));
}
let evidence_user_id = required(&record.evidence_user_id, "evidence user id")?;
let workspace_id = required(&record.evidence_workspace_id, "evidence workspace id")?;
let actor_email_hash = required(&record.subject_actor_email_hash, "subject actor email hash")?;
if evidence_user_id.is_empty() || workspace_id.is_empty() {
return Err(PersistedInviteAbuseError::Invalid("evidence identity"));
}
if required(&record.evidence_actor_email_hash, "evidence actor email hash")? != actor_email_hash {
return Err(PersistedInviteAbuseError::Inconsistent("actor email hash"));
}
if !hash_identity(actor_email_hash, "actor_email_sha256:v1:", 64) {
return Err(PersistedInviteAbuseError::Invalid("actor email hash"));
}
let email_domain = required(&record.subject_email_domain, "subject email domain")?;
if email_domain != normalize_domain(email_domain) || !email_domain.contains('.') {
return Err(PersistedInviteAbuseError::Invalid("subject email domain"));
}
if let Some(source_prefix_hash) = &record.evidence_source_prefix_hash
&& !hash_identity(source_prefix_hash, "", 24)
{
return Err(PersistedInviteAbuseError::Invalid("evidence source prefix hash"));
}
if record
.evidence_source_asn
.is_some_and(|asn| u32::try_from(asn).is_err())
{
return Err(PersistedInviteAbuseError::Invalid("evidence source ASN"));
}
match kind {
InviteAbuseSubjectKind::ActorEmail => {
if record.subject_key != actor_email_hash || record.subject_user_id.as_deref() != Some(evidence_user_id) {
return Err(PersistedInviteAbuseError::Invalid("actor subject identity"));
}
}
InviteAbuseSubjectKind::Workspace => {
if record.subject_user_id.is_some() || record.subject_key != workspace_subject_key(workspace_id) {
return Err(PersistedInviteAbuseError::Invalid("workspace subject identity"));
}
}
InviteAbuseSubjectKind::SourcePrefixDomain => {
if record.subject_user_id.is_some()
|| !hash_identity(&record.subject_key, "source_prefix_domain_sha256:v1:", 24)
|| record.evidence_source_prefix_hash.is_none()
{
return Err(PersistedInviteAbuseError::Invalid("source cohort subject identity"));
}
}
}
let counters = record
.evidence_counters
.as_ref()
.and_then(Value::as_object)
.filter(|counters| counters.len() == 1)
.ok_or(PersistedInviteAbuseError::Invalid("evidence counters"))?;
let requested = counters
.get("requested")
.and_then(Value::as_i64)
.and_then(|requested| i32::try_from(requested).ok())
.filter(|requested| *requested > 0)
.ok_or(PersistedInviteAbuseError::Invalid("evidence counters"))?;
validate_targets(
record
.evidence_target_domains
.as_ref()
.ok_or(PersistedInviteAbuseError::Missing("evidence target domains"))?,
requested,
)?;
Ok(InviteAbuseSubject {
kind,
key: record.subject_key.clone(),
status,
})
}