use std::{fmt, mem};
use serde::Serialize;
use serde_json::Value as JsonValue;
mod value;
pub use self::value::{CanonicalJsonObject, CanonicalJsonValue};
use crate::{room_version_rules::RedactionRules, serde::Raw};
#[derive(Debug)]
#[allow(clippy::exhaustive_enums)]
pub enum CanonicalJsonError {
IntConvert,
SerDe(serde_json::Error),
}
impl fmt::Display for CanonicalJsonError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
CanonicalJsonError::IntConvert => {
f.write_str("number found is not a valid `js_int::Int`")
}
CanonicalJsonError::SerDe(err) => write!(f, "serde Error: {err}"),
}
}
}
impl std::error::Error for CanonicalJsonError {}
#[derive(Debug)]
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
pub enum RedactionError {
#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
NotOfType {
field: String,
of_type: JsonType,
},
JsonFieldMissingFromObject(String),
}
impl fmt::Display for RedactionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RedactionError::NotOfType { field, of_type } => {
write!(f, "Value in {field:?} must be a JSON {of_type:?}")
}
RedactionError::JsonFieldMissingFromObject(field) => {
write!(f, "JSON object must contain the field {field:?}")
}
}
}
}
impl std::error::Error for RedactionError {}
impl RedactionError {
fn not_of_type(target: &str, of_type: JsonType) -> Self {
Self::NotOfType { field: target.to_owned(), of_type }
}
fn field_missing_from_object(target: &str) -> Self {
Self::JsonFieldMissingFromObject(target.to_owned())
}
}
#[derive(Debug)]
#[allow(clippy::exhaustive_enums)]
pub enum JsonType {
Object,
String,
Integer,
Array,
Boolean,
Null,
}
pub fn try_from_json_map(
json: serde_json::Map<String, JsonValue>,
) -> Result<CanonicalJsonObject, CanonicalJsonError> {
json.into_iter().map(|(k, v)| Ok((k, v.try_into()?))).collect()
}
pub fn to_canonical_value<T: Serialize>(
value: T,
) -> Result<CanonicalJsonValue, CanonicalJsonError> {
serde_json::to_value(value).map_err(CanonicalJsonError::SerDe)?.try_into()
}
#[derive(Clone, Debug)]
pub struct RedactedBecause(CanonicalJsonObject);
impl RedactedBecause {
pub fn from_json(obj: CanonicalJsonObject) -> Self {
Self(obj)
}
pub fn from_raw_event(ev: &Raw<impl RedactionEvent>) -> serde_json::Result<Self> {
ev.deserialize_as_unchecked().map(Self)
}
}
pub trait RedactionEvent {}
pub fn redact(
mut object: CanonicalJsonObject,
rules: &RedactionRules,
redacted_because: Option<RedactedBecause>,
) -> Result<CanonicalJsonObject, RedactionError> {
redact_in_place(&mut object, rules, redacted_because)?;
Ok(object)
}
pub fn redact_in_place(
event: &mut CanonicalJsonObject,
rules: &RedactionRules,
redacted_because: Option<RedactedBecause>,
) -> Result<(), RedactionError> {
let retained_event_content_keys = match event.get("type") {
Some(CanonicalJsonValue::String(event_type)) => {
retained_event_content_keys(event_type.as_ref(), rules)
}
Some(_) => return Err(RedactionError::not_of_type("type", JsonType::String)),
None => return Err(RedactionError::field_missing_from_object("type")),
};
if let Some(content_value) = event.get_mut("content") {
let CanonicalJsonValue::Object(content) = content_value else {
return Err(RedactionError::not_of_type("content", JsonType::Object));
};
retained_event_content_keys.apply(rules, content)?;
}
let retained_event_keys =
RetainedKeys::some(|rules, key, _value| Ok(is_event_key_retained(rules, key)));
retained_event_keys.apply(rules, event)?;
if let Some(redacted_because) = redacted_because {
let unsigned = CanonicalJsonObject::from_iter([(
"redacted_because".to_owned(),
redacted_because.0.into(),
)]);
event.insert("unsigned".to_owned(), unsigned.into());
}
Ok(())
}
pub fn redact_content_in_place(
content: &mut CanonicalJsonObject,
rules: &RedactionRules,
event_type: impl AsRef<str>,
) -> Result<(), RedactionError> {
retained_event_content_keys(event_type.as_ref(), rules).apply(rules, content)
}
type RetainKeyFn =
dyn Fn(&RedactionRules, &str, &mut CanonicalJsonValue) -> Result<bool, RedactionError>;
enum RetainedKeys {
All,
Some(Box<RetainKeyFn>),
None,
}
impl RetainedKeys {
fn some<F>(retain_key_fn: F) -> Self
where
F: Fn(&RedactionRules, &str, &mut CanonicalJsonValue) -> Result<bool, RedactionError>
+ 'static,
{
Self::Some(Box::new(retain_key_fn))
}
fn apply(
&self,
rules: &RedactionRules,
object: &mut CanonicalJsonObject,
) -> Result<(), RedactionError> {
match self {
Self::All => {}
Self::Some(allow_field_fn) => {
let old_object = mem::take(object);
for (key, mut value) in old_object {
if allow_field_fn(rules, &key, &mut value)? {
object.insert(key, value);
}
}
}
Self::None => object.clear(),
}
Ok(())
}
}
fn is_event_key_retained(rules: &RedactionRules, key: &str) -> bool {
match key {
"event_id" | "type" | "room_id" | "sender" | "state_key" | "content" | "hashes"
| "signatures" | "depth" | "prev_events" | "auth_events" | "origin_server_ts" => true,
"origin" | "membership" | "prev_state" => rules.keep_origin_membership_prev_state,
_ => false,
}
}
fn retained_event_content_keys(event_type: &str, rules: &RedactionRules) -> RetainedKeys {
match event_type {
"m.room.member" => RetainedKeys::some(is_room_member_content_key_retained),
"m.room.create" => room_create_content_retained_keys(rules),
"m.room.join_rules" => RetainedKeys::some(|rules, key, _value| {
is_room_join_rules_content_key_retained(rules, key)
}),
"m.room.power_levels" => RetainedKeys::some(|rules, key, _value| {
is_room_power_levels_content_key_retained(rules, key)
}),
"m.room.history_visibility" => RetainedKeys::some(|_rules, key, _value| {
is_room_history_visibility_content_key_retained(key)
}),
"m.room.redaction" => room_redaction_content_retained_keys(rules),
"m.room.aliases" => room_aliases_content_retained_keys(rules),
#[cfg(feature = "unstable-msc2870")]
"m.room.server_acl" => RetainedKeys::some(|rules, key, _value| {
is_room_server_acl_content_key_retained(rules, key)
}),
_ => RetainedKeys::None,
}
}
fn is_room_member_content_key_retained(
rules: &RedactionRules,
key: &str,
value: &mut CanonicalJsonValue,
) -> Result<bool, RedactionError> {
Ok(match key {
"membership" => true,
"join_authorised_via_users_server" => {
rules.keep_room_member_join_authorised_via_users_server
}
"third_party_invite" if rules.keep_room_member_third_party_invite_signed => {
let Some(third_party_invite) = value.as_object_mut() else {
return Err(RedactionError::not_of_type("third_party_invite", JsonType::Object));
};
third_party_invite.retain(|key, _| key == "signed");
!third_party_invite.is_empty()
}
_ => false,
})
}
fn room_create_content_retained_keys(rules: &RedactionRules) -> RetainedKeys {
if rules.keep_room_create_content {
RetainedKeys::All
} else {
RetainedKeys::some(|_rules, field, _value| Ok(field == "creator"))
}
}
fn is_room_join_rules_content_key_retained(
rules: &RedactionRules,
key: &str,
) -> Result<bool, RedactionError> {
Ok(match key {
"join_rule" => true,
"allow" => rules.keep_room_join_rules_allow,
_ => false,
})
}
fn is_room_power_levels_content_key_retained(
rules: &RedactionRules,
key: &str,
) -> Result<bool, RedactionError> {
Ok(match key {
"ban" | "events" | "events_default" | "kick" | "redact" | "state_default" | "users"
| "users_default" => true,
"invite" => rules.keep_room_power_levels_invite,
_ => false,
})
}
fn is_room_history_visibility_content_key_retained(key: &str) -> Result<bool, RedactionError> {
Ok(key == "history_visibility")
}
fn room_redaction_content_retained_keys(rules: &RedactionRules) -> RetainedKeys {
if rules.keep_room_redaction_redacts {
RetainedKeys::some(|_rules, field, _value| Ok(field == "redacts"))
} else {
RetainedKeys::None
}
}
fn room_aliases_content_retained_keys(rules: &RedactionRules) -> RetainedKeys {
if rules.keep_room_aliases_aliases {
RetainedKeys::some(|_rules, field, _value| Ok(field == "aliases"))
} else {
RetainedKeys::None
}
}
#[cfg(feature = "unstable-msc2870")]
fn is_room_server_acl_content_key_retained(
rules: &RedactionRules,
key: &str,
) -> Result<bool, RedactionError> {
Ok(match key {
"allow" | "deny" | "allow_ip_literals" => {
rules.keep_room_server_acl_allow_deny_allow_ip_literals
}
_ => false,
})
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use assert_matches2::assert_matches;
use js_int::int;
use serde_json::{
from_str as from_json_str, json, to_string as to_json_string, to_value as to_json_value,
};
use super::{
redact_in_place, to_canonical_value, try_from_json_map, value::CanonicalJsonValue,
};
use crate::room_version_rules::RedactionRules;
#[test]
fn serialize_canon() {
let json: CanonicalJsonValue = json!({
"a": [1, 2, 3],
"other": { "stuff": "hello" },
"string": "Thing"
})
.try_into()
.unwrap();
let ser = to_json_string(&json).unwrap();
let back = from_json_str::<CanonicalJsonValue>(&ser).unwrap();
assert_eq!(json, back);
}
#[test]
fn check_canonical_sorts_keys() {
let json: CanonicalJsonValue = json!({
"auth": {
"success": true,
"mxid": "@john.doe:example.com",
"profile": {
"display_name": "John Doe",
"three_pids": [
{
"medium": "email",
"address": "john.doe@example.org"
},
{
"medium": "msisdn",
"address": "123456789"
}
]
}
}
})
.try_into()
.unwrap();
assert_eq!(
to_json_string(&json).unwrap(),
r#"{"auth":{"mxid":"@john.doe:example.com","profile":{"display_name":"John Doe","three_pids":[{"address":"john.doe@example.org","medium":"email"},{"address":"123456789","medium":"msisdn"}]},"success":true}}"#
);
}
#[test]
fn serialize_map_to_canonical() {
let mut expected = BTreeMap::new();
expected.insert("foo".into(), CanonicalJsonValue::String("string".into()));
expected.insert(
"bar".into(),
CanonicalJsonValue::Array(vec![
CanonicalJsonValue::Integer(int!(0)),
CanonicalJsonValue::Integer(int!(1)),
CanonicalJsonValue::Integer(int!(2)),
]),
);
let mut map = serde_json::Map::new();
map.insert("foo".into(), json!("string"));
map.insert("bar".into(), json!(vec![0, 1, 2,]));
assert_eq!(try_from_json_map(map).unwrap(), expected);
}
#[test]
fn to_canonical() {
#[derive(Debug, serde::Serialize)]
struct Thing {
foo: String,
bar: Vec<u8>,
}
let t = Thing { foo: "string".into(), bar: vec![0, 1, 2] };
let mut expected = BTreeMap::new();
expected.insert("foo".into(), CanonicalJsonValue::String("string".into()));
expected.insert(
"bar".into(),
CanonicalJsonValue::Array(vec![
CanonicalJsonValue::Integer(int!(0)),
CanonicalJsonValue::Integer(int!(1)),
CanonicalJsonValue::Integer(int!(2)),
]),
);
assert_eq!(to_canonical_value(t).unwrap(), CanonicalJsonValue::Object(expected));
}
#[test]
fn redact_allowed_keys_some() {
let original_event = json!({
"content": {
"ban": 50,
"events": {
"m.room.avatar": 50,
"m.room.canonical_alias": 50,
"m.room.history_visibility": 100,
"m.room.name": 50,
"m.room.power_levels": 100
},
"events_default": 0,
"invite": 0,
"kick": 50,
"redact": 50,
"state_default": 50,
"users": {
"@example:localhost": 100
},
"users_default": 0
},
"event_id": "$15139375512JaHAW:localhost",
"origin_server_ts": 45,
"sender": "@example:localhost",
"room_id": "!room:localhost",
"state_key": "",
"type": "m.room.power_levels",
"unsigned": {
"age": 45
}
});
assert_matches!(
CanonicalJsonValue::try_from(original_event),
Ok(CanonicalJsonValue::Object(mut object))
);
redact_in_place(&mut object, &RedactionRules::V1, None).unwrap();
let redacted_event = to_json_value(&object).unwrap();
assert_eq!(
redacted_event,
json!({
"content": {
"ban": 50,
"events": {
"m.room.avatar": 50,
"m.room.canonical_alias": 50,
"m.room.history_visibility": 100,
"m.room.name": 50,
"m.room.power_levels": 100
},
"events_default": 0,
"kick": 50,
"redact": 50,
"state_default": 50,
"users": {
"@example:localhost": 100
},
"users_default": 0
},
"event_id": "$15139375512JaHAW:localhost",
"origin_server_ts": 45,
"sender": "@example:localhost",
"room_id": "!room:localhost",
"state_key": "",
"type": "m.room.power_levels",
})
);
}
#[test]
fn redact_allowed_keys_none() {
let original_event = json!({
"content": {
"aliases": ["#somewhere:localhost"]
},
"event_id": "$152037280074GZeOm:localhost",
"origin_server_ts": 1,
"sender": "@example:localhost",
"state_key": "room.com",
"room_id": "!room:room.com",
"type": "m.room.aliases",
"unsigned": {
"age": 1
}
});
assert_matches!(
CanonicalJsonValue::try_from(original_event),
Ok(CanonicalJsonValue::Object(mut object))
);
redact_in_place(&mut object, &RedactionRules::V9, None).unwrap();
let redacted_event = to_json_value(&object).unwrap();
assert_eq!(
redacted_event,
json!({
"content": {},
"event_id": "$152037280074GZeOm:localhost",
"origin_server_ts": 1,
"sender": "@example:localhost",
"state_key": "room.com",
"room_id": "!room:room.com",
"type": "m.room.aliases",
})
);
}
#[test]
fn redact_allowed_keys_all() {
let original_event = json!({
"content": {
"m.federate": true,
"predecessor": {
"event_id": "$something",
"room_id": "!oldroom:example.org"
},
"room_version": "11",
},
"event_id": "$143273582443PhrSn",
"origin_server_ts": 1_432_735,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"state_key": "",
"type": "m.room.create",
"unsigned": {
"age": 1234,
},
});
assert_matches!(
CanonicalJsonValue::try_from(original_event),
Ok(CanonicalJsonValue::Object(mut object))
);
redact_in_place(&mut object, &RedactionRules::V11, None).unwrap();
let redacted_event = to_json_value(&object).unwrap();
assert_eq!(
redacted_event,
json!({
"content": {
"m.federate": true,
"predecessor": {
"event_id": "$something",
"room_id": "!oldroom:example.org"
},
"room_version": "11",
},
"event_id": "$143273582443PhrSn",
"origin_server_ts": 1_432_735,
"room_id": "!jEsUZKDJdhlrceRyVU:example.org",
"sender": "@example:example.org",
"state_key": "",
"type": "m.room.create",
})
);
}
}