use crate::rls::RlsContext;
pub const PG_CHANNEL_MAX_BYTES: usize = 63;
pub const LIVE_QUERY_TABLE_PREFIX: &str = "qail_table_";
pub const LIVE_QUERY_COMPACT_PREFIX: &str = "qail_lq_";
pub fn validate_channel_fragment(fragment: &str) -> Result<(), String> {
if fragment.is_empty() {
return Err("Channel name cannot be empty".to_string());
}
if !fragment
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(
"Invalid channel name — ASCII alphanumeric, underscores and hyphens only".to_string(),
);
}
if fragment.starts_with(LIVE_QUERY_TABLE_PREFIX)
|| fragment.starts_with(LIVE_QUERY_COMPACT_PREFIX)
{
return Err(format!(
"Channel name '{}' uses a reserved live_query namespace",
fragment
));
}
Ok(())
}
fn validate_tenant_scope(tenant_id: &str) -> Result<(), String> {
if tenant_id.is_empty() {
return Err("Tenant identifier is required for scoped channel names".to_string());
}
if !tenant_id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
{
return Err(
"Tenant identifier contains unsupported characters for channel scoping".to_string(),
);
}
Ok(())
}
fn ensure_pg_channel_name_limit(channel: &str) -> Result<(), String> {
if channel.len() <= PG_CHANNEL_MAX_BYTES {
return Ok(());
}
Err(format!(
"Channel name too long for PostgreSQL LISTEN/NOTIFY ({} bytes > {} bytes)",
channel.len(),
PG_CHANNEL_MAX_BYTES
))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChannelScope {
Tenant,
User,
}
impl ChannelScope {
fn prefix(self) -> &'static str {
match self {
ChannelScope::Tenant => "t",
ChannelScope::User => "u",
}
}
}
fn scoped(scope: ChannelScope, id: &str, suffix: &str) -> Result<String, String> {
validate_tenant_scope(id)?;
Ok(format!("{}_{}_{}_{}", scope.prefix(), id.len(), id, suffix))
}
pub fn scoped_channel(tenant_id: &str, fragment: &str) -> Result<String, String> {
scoped_channel_in(ChannelScope::Tenant, tenant_id, fragment)
}
pub fn scoped_channel_in(scope: ChannelScope, id: &str, fragment: &str) -> Result<String, String> {
validate_channel_fragment(fragment)?;
let channel = scoped(scope, id, fragment)?;
ensure_pg_channel_name_limit(&channel)?;
Ok(channel)
}
pub fn scoped_channel_for(ctx: &RlsContext, fragment: &str) -> Result<String, String> {
if ctx.has_tenant() {
return scoped_channel_in(ChannelScope::Tenant, &ctx.tenant_id, fragment);
}
if ctx.has_user() {
return scoped_channel_in(ChannelScope::User, ctx.user_id(), fragment);
}
Err("Scoped channel requires a tenant or user context".to_string())
}
fn stable_channel_hash(input: &str) -> u64 {
let mut hash = 0xcbf29ce484222325_u64;
for byte in input.as_bytes() {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(0x100000001b3);
}
hash
}
pub fn live_query_channel(tenant_id: Option<&str>, table: &str) -> Result<String, String> {
let channel = match tenant_id {
Some(tid) if !tid.is_empty() => {
let scoped = scoped(
ChannelScope::Tenant,
tid,
&format!("{}{}", LIVE_QUERY_TABLE_PREFIX, table),
)?;
if scoped.len() <= PG_CHANNEL_MAX_BYTES {
scoped
} else {
format!(
"{}{:016x}_{:016x}",
LIVE_QUERY_COMPACT_PREFIX,
stable_channel_hash(tid),
stable_channel_hash(table)
)
}
}
_ => format!("{}{}", LIVE_QUERY_TABLE_PREFIX, table),
};
ensure_pg_channel_name_limit(&channel)?;
Ok(channel)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scoped_channel_is_length_delimited() {
let a = scoped_channel("acme", "eu_orders").unwrap();
let b = scoped_channel("acme_eu", "orders").unwrap();
assert_ne!(a, b);
assert_eq!(a, "t_4_acme_eu_orders");
}
#[test]
fn reserved_live_query_prefixes_are_refused() {
assert!(validate_channel_fragment("qail_table_orders").is_err());
assert!(validate_channel_fragment("qail_lq_abc").is_err());
assert!(validate_channel_fragment("chat_42").is_ok());
}
#[test]
fn scoped_channel_for_prefers_tenant_then_user() {
let t = RlsContext::tenant("acme").with_user("u1");
assert_eq!(scoped_channel_for(&t, "chat").unwrap(), "t_4_acme_chat");
let u = RlsContext::user("u1");
assert_eq!(scoped_channel_for(&u, "chat").unwrap(), "u_2_u1_chat");
assert!(scoped_channel_for(&RlsContext::empty(), "chat").is_err());
}
#[test]
fn tenant_and_user_with_equal_ids_never_share_a_channel() {
let t = scoped_channel_for(&RlsContext::tenant("acme"), "chat").unwrap();
let u = scoped_channel_for(&RlsContext::user("acme"), "chat").unwrap();
assert_ne!(t, u);
assert!(t.starts_with("t_") && u.starts_with("u_"));
}
#[test]
fn live_query_channel_compacts_when_too_long() {
let long_tenant = "t".repeat(50);
let c = live_query_channel(Some(&long_tenant), "orders").unwrap();
assert!(c.starts_with(LIVE_QUERY_COMPACT_PREFIX));
assert!(c.len() <= PG_CHANNEL_MAX_BYTES);
assert_eq!(
live_query_channel(None, "orders").unwrap(),
"qail_table_orders"
);
}
}