Our Personal Data Server from scratch! tranquil.farm
atproto pds rust postgresql fun oauth

refactor(pds): integrate tranquil-lexicon for record validation #49

merged opened by oyster.cafe targeting main from feat/real-lex-schema-validation
Labels

None yet.

assignee
Participants 1
AT URI
at://did:plc:3fwecdnvtcscjnrx2p4n7alz/sh.tangled.repo.pull/3mgvbqsbilm22
+299 -1072
Diff #1
+1 -1
Cargo.toml
··· 20 20 ] 21 21 22 22 [workspace.package] 23 - version = "0.3.1" 23 + version = "0.4.0" 24 24 edition = "2024" 25 25 license = "AGPL-3.0-or-later" 26 26
+10 -8
crates/tranquil-comms/src/sender.rs
··· 169 169 source: e, 170 170 })?; 171 171 if let Some(mut stdin) = child.stdin.take() { 172 - stdin.write_all(email_content.as_bytes()).await.map_err(|e| { 173 - SendError::ProcessSpawn { 172 + stdin 173 + .write_all(email_content.as_bytes()) 174 + .await 175 + .map_err(|e| SendError::ProcessSpawn { 174 176 command: self.sendmail_path.clone(), 175 177 source: e, 176 - } 177 - })?; 178 + })?; 178 179 } 179 - let output = child.wait_with_output().await.map_err(|e| { 180 - SendError::ProcessSpawn { 180 + let output = child 181 + .wait_with_output() 182 + .await 183 + .map_err(|e| SendError::ProcessSpawn { 181 184 command: self.sendmail_path.clone(), 182 185 source: e, 183 - } 184 - })?; 186 + })?; 185 187 if !output.status.success() { 186 188 let stderr = String::from_utf8_lossy(&output.stderr); 187 189 return Err(SendError::ProcessFailed {
+1
crates/tranquil-pds/Cargo.toml
··· 17 17 tranquil-comms = { workspace = true } 18 18 tranquil-db = { workspace = true } 19 19 tranquil-db-traits = { workspace = true } 20 + tranquil-lexicon = { workspace = true, features = ["resolve"] } 20 21 21 22 aes-gcm = { workspace = true } 22 23 async-trait = { workspace = true }
+1 -3
crates/tranquil-pds/src/api/identity/account.rs
··· 147 147 .filter(|d| input.handle.ends_with(&format!(".{}", d))) 148 148 .max_by_key(|d| d.len()); 149 149 150 - let validated_short_handle = if !input.handle.contains('.') 151 - || matched_domain.is_some() 152 - { 150 + let validated_short_handle = if !input.handle.contains('.') || matched_domain.is_some() { 153 151 let handle_to_validate = match matched_domain { 154 152 Some(domain) => input 155 153 .handle
+3 -1
crates/tranquil-pds/src/api/identity/did.rs
··· 684 684 .max_by_key(|d| d.len()) 685 685 .cloned(); 686 686 let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle); 687 - let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself { 687 + let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) 688 + && !is_domain_itself 689 + { 688 690 let (short_part, full_handle) = match &matched_handle_domain { 689 691 Some(domain) => { 690 692 let suffix = format!(".{}", domain);
+6 -2
crates/tranquil-pds/src/api/repo/record/batch.rs
··· 65 65 collection, 66 66 rkey.as_ref(), 67 67 validate.requires_lexicon(), 68 - ) { 68 + ) 69 + .await 70 + { 69 71 Ok(status) => Some(status), 70 72 Err(err_response) => return Err(*err_response), 71 73 } ··· 116 118 collection, 117 119 Some(rkey), 118 120 validate.requires_lexicon(), 119 - ) { 121 + ) 122 + .await 123 + { 120 124 Ok(status) => Some(status), 121 125 Err(err_response) => return Err(*err_response), 122 126 }
+6 -27
crates/tranquil-pds/src/api/repo/record/validation.rs
··· 3 3 use crate::validation::{RecordValidator, ValidationError, ValidationStatus}; 4 4 use axum::response::Response; 5 5 6 - pub fn validate_record(record: &serde_json::Value, collection: &Nsid) -> Result<(), Box<Response>> { 7 - validate_record_with_rkey(record, collection, None) 8 - } 9 - 10 - pub fn validate_record_with_rkey( 11 - record: &serde_json::Value, 12 - collection: &Nsid, 13 - rkey: Option<&Rkey>, 14 - ) -> Result<(), Box<Response>> { 15 - let validator = RecordValidator::new(); 16 - validation_error_to_response(validator.validate_with_rkey( 17 - record, 18 - collection.as_str(), 19 - rkey.map(|r| r.as_str()), 20 - )) 21 - } 22 - 23 - pub fn validate_record_with_status( 6 + pub async fn validate_record_with_status( 24 7 record: &serde_json::Value, 25 8 collection: &Nsid, 26 9 rkey: Option<&Rkey>, 27 10 require_lexicon: bool, 28 11 ) -> Result<ValidationStatus, Box<Response>> { 12 + let registry = tranquil_lexicon::LexiconRegistry::global(); 13 + if !registry.has_schema(collection.as_str()) { 14 + let _ = registry.resolve_dynamic(collection.as_str()).await; 15 + } 16 + 29 17 let validator = RecordValidator::new().require_lexicon(require_lexicon); 30 18 match validator.validate_with_rkey(record, collection.as_str(), rkey.map(|r| r.as_str())) { 31 19 Ok(status) => Ok(status), ··· 33 21 } 34 22 } 35 23 36 - fn validation_error_to_response( 37 - result: Result<ValidationStatus, ValidationError>, 38 - ) -> Result<(), Box<Response>> { 39 - match result { 40 - Ok(_) => Ok(()), 41 - Err(e) => Err(validation_error_to_box_response(e)), 42 - } 43 - } 44 - 45 24 fn validation_error_to_box_response(e: ValidationError) -> Box<Response> { 46 25 use axum::response::IntoResponse; 47 26 let msg = match e {
+6 -2
crates/tranquil-pds/src/api/repo/record/write.rs
··· 136 136 &input.collection, 137 137 input.rkey.as_ref(), 138 138 input.validate.requires_lexicon(), 139 - ) { 139 + ) 140 + .await 141 + { 140 142 Ok(status) => Some(status), 141 143 Err(err_response) => return Ok(*err_response), 142 144 } ··· 456 458 &input.collection, 457 459 Some(&input.rkey), 458 460 input.validate.requires_lexicon(), 459 - ) { 461 + ) 462 + .await 463 + { 460 464 Ok(status) => Some(status), 461 465 Err(err_response) => return Ok(*err_response), 462 466 }
+6 -3
crates/tranquil-pds/src/api/validation.rs
··· 285 285 } 286 286 287 287 let labels: Vec<&str> = handle.split('.').collect(); 288 - let has_invalid_label = labels 289 - .iter() 290 - .any(|label| label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-')); 288 + let has_invalid_label = labels.iter().any(|label| { 289 + label.is_empty() 290 + || label.len() > MAX_DOMAIN_LABEL_LENGTH 291 + || label.starts_with('-') 292 + || label.ends_with('-') 293 + }); 291 294 if has_invalid_label { 292 295 return Err(HandleValidationError::InvalidCharacters); 293 296 }
+2 -7
crates/tranquil-pds/src/sso/endpoints.rs
··· 776 776 let available_domains = tranquil_config::get().server.available_user_domain_list(); 777 777 if let Some(ref d) = query.domain { 778 778 if !available_domains.iter().any(|ad| ad == d) { 779 - return Err(ApiError::InvalidRequest( 780 - "Unknown user domain".into(), 781 - )); 779 + return Err(ApiError::InvalidRequest("Unknown user domain".into())); 782 780 } 783 781 } 784 - let domain = query 785 - .domain 786 - .as_deref() 787 - .unwrap_or(&available_domains[0]); 782 + let domain = query.domain.as_deref().unwrap_or(&available_domains[0]); 788 783 let full_handle = format!("{}.{}", validated, domain); 789 784 let handle_typed: crate::types::Handle = match full_handle.parse() { 790 785 Ok(h) => h,
+128 -519
crates/tranquil-pds/src/validation/mod.rs
··· 1 1 use serde_json::Value; 2 2 use thiserror::Error; 3 + use tranquil_lexicon::LexValidationError; 3 4 4 5 #[derive(Debug, Error)] 5 6 pub enum ValidationError { ··· 75 76 collection: &str, 76 77 rkey: Option<&str>, 77 78 ) -> Result<ValidationStatus, ValidationError> { 78 - let obj = record.as_object().ok_or_else(|| { 79 - ValidationError::InvalidRecord("Record must be an object".to_string()) 80 - })?; 81 - let record_type = obj 82 - .get("$type") 83 - .and_then(|v| v.as_str()) 84 - .ok_or(ValidationError::MissingType)?; 85 - if record_type != collection { 86 - return Err(ValidationError::TypeMismatch { 87 - expected: collection.to_string(), 88 - actual: record_type.to_string(), 89 - }); 90 - } 91 - if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) { 92 - validate_datetime(created_at, "createdAt")?; 93 - } 94 - match record_type { 95 - "app.bsky.feed.post" => Self::validate_post(obj)?, 96 - "app.bsky.actor.profile" => Self::validate_profile(obj)?, 97 - "app.bsky.feed.like" => Self::validate_like(obj)?, 98 - "app.bsky.feed.repost" => Self::validate_repost(obj)?, 99 - "app.bsky.graph.follow" => Self::validate_follow(obj)?, 100 - "app.bsky.graph.block" => Self::validate_block(obj)?, 101 - "app.bsky.graph.list" => Self::validate_list(obj)?, 102 - "app.bsky.graph.listitem" => Self::validate_list_item(obj)?, 103 - "app.bsky.feed.generator" => Self::validate_feed_generator(obj, rkey)?, 104 - "app.bsky.feed.threadgate" => Self::validate_threadgate(obj)?, 105 - "app.bsky.labeler.service" => Self::validate_labeler_service(obj)?, 106 - "app.bsky.graph.starterpack" => Self::validate_starterpack(obj)?, 107 - _ => { 108 - if self.require_lexicon { 109 - return Err(ValidationError::UnknownType(record_type.to_string())); 110 - } 111 - return Ok(ValidationStatus::Unknown); 112 - } 113 - } 114 - Ok(ValidationStatus::Valid) 115 - } 79 + let (record_type, obj) = validate_preamble(record, collection)?; 80 + let registry = tranquil_lexicon::LexiconRegistry::global(); 116 81 117 - fn validate_post(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 118 - if !obj.contains_key("text") { 119 - return Err(ValidationError::MissingField("text".to_string())); 120 - } 121 - if !obj.contains_key("createdAt") { 122 - return Err(ValidationError::MissingField("createdAt".to_string())); 123 - } 124 - if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { 125 - let grapheme_count = text.chars().count(); 126 - if grapheme_count > 3000 { 127 - return Err(ValidationError::InvalidField { 128 - path: "text".to_string(), 129 - message: format!( 130 - "Text exceeds maximum length of 3000 characters (got {})", 131 - grapheme_count 132 - ), 133 - }); 134 - } 135 - } 136 - if let Some(langs) = obj.get("langs").and_then(|v| v.as_array()) 137 - && langs.len() > 3 138 - { 139 - return Err(ValidationError::InvalidField { 140 - path: "langs".to_string(), 141 - message: "Maximum 3 languages allowed".to_string(), 142 - }); 143 - } 144 - if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { 145 - if tags.len() > 8 { 146 - return Err(ValidationError::InvalidField { 147 - path: "tags".to_string(), 148 - message: "Maximum 8 tags allowed".to_string(), 149 - }); 150 - } 151 - for (i, tag) in tags.iter().enumerate() { 152 - if let Some(tag_str) = tag.as_str() { 153 - if tag_str.len() > 640 { 154 - return Err(ValidationError::InvalidField { 155 - path: format!("tags/{}", i), 156 - message: "Tag exceeds maximum length of 640 bytes".to_string(), 157 - }); 158 - } 159 - if crate::moderation::has_explicit_slur(tag_str) { 160 - return Err(ValidationError::BannedContent { 161 - path: format!("tags/{}", i), 162 - }); 163 - } 164 - } 82 + match tranquil_lexicon::validate_record(registry, record_type, record) { 83 + Ok(()) => { 84 + check_banned_content(record_type, obj, rkey)?; 85 + Ok(ValidationStatus::Valid) 165 86 } 166 - } 167 - if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { 168 - for (i, facet) in facets.iter().enumerate() { 169 - if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { 170 - for (j, feature) in features.iter().enumerate() { 171 - let is_tag = feature 172 - .get("$type") 173 - .and_then(|v| v.as_str()) 174 - .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); 175 - if is_tag 176 - && let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) 177 - && crate::moderation::has_explicit_slur(tag) 178 - { 179 - return Err(ValidationError::BannedContent { 180 - path: format!("facets/{}/features/{}/tag", i, j), 181 - }); 182 - } 183 - } 87 + Err(LexValidationError::LexiconNotFound(_)) => { 88 + if self.require_lexicon { 89 + Err(ValidationError::UnknownType(record_type.to_string())) 90 + } else { 91 + check_banned_content(record_type, obj, rkey)?; 92 + Ok(ValidationStatus::Unknown) 184 93 } 185 94 } 186 - } 187 - Ok(()) 188 - } 189 - 190 - fn validate_profile(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 191 - if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { 192 - let grapheme_count = display_name.chars().count(); 193 - if grapheme_count > 640 { 194 - return Err(ValidationError::InvalidField { 195 - path: "displayName".to_string(), 196 - message: format!( 197 - "Display name exceeds maximum length of 640 characters (got {})", 198 - grapheme_count 199 - ), 200 - }); 201 - } 202 - if crate::moderation::has_explicit_slur(display_name) { 203 - return Err(ValidationError::BannedContent { 204 - path: "displayName".to_string(), 205 - }); 95 + Err(LexValidationError::MissingRequired { path }) => { 96 + Err(ValidationError::MissingField(path)) 206 97 } 207 - } 208 - if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { 209 - let grapheme_count = description.chars().count(); 210 - if grapheme_count > 2560 { 211 - return Err(ValidationError::InvalidField { 212 - path: "description".to_string(), 213 - message: format!( 214 - "Description exceeds maximum length of 2560 characters (got {})", 215 - grapheme_count 216 - ), 217 - }); 98 + Err(LexValidationError::InvalidField { path, message }) => { 99 + Err(ValidationError::InvalidField { path, message }) 218 100 } 219 - if crate::moderation::has_explicit_slur(description) { 220 - return Err(ValidationError::BannedContent { 221 - path: "description".to_string(), 222 - }); 101 + Err(LexValidationError::RecursionDepthExceeded { path }) => { 102 + Err(ValidationError::InvalidField { 103 + path, 104 + message: "recursion depth exceeded".to_string(), 105 + }) 223 106 } 224 107 } 225 - Ok(()) 226 - } 227 - 228 - fn validate_like(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 229 - if !obj.contains_key("subject") { 230 - return Err(ValidationError::MissingField("subject".to_string())); 231 - } 232 - if !obj.contains_key("createdAt") { 233 - return Err(ValidationError::MissingField("createdAt".to_string())); 234 - } 235 - Self::validate_strong_ref(obj.get("subject"), "subject")?; 236 - Ok(()) 237 - } 238 - 239 - fn validate_repost(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 240 - if !obj.contains_key("subject") { 241 - return Err(ValidationError::MissingField("subject".to_string())); 242 - } 243 - if !obj.contains_key("createdAt") { 244 - return Err(ValidationError::MissingField("createdAt".to_string())); 245 - } 246 - Self::validate_strong_ref(obj.get("subject"), "subject")?; 247 - Ok(()) 248 108 } 109 + } 249 110 250 - fn validate_follow(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 251 - if !obj.contains_key("subject") { 252 - return Err(ValidationError::MissingField("subject".to_string())); 253 - } 254 - if !obj.contains_key("createdAt") { 255 - return Err(ValidationError::MissingField("createdAt".to_string())); 256 - } 257 - if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) 258 - && !subject.starts_with("did:") 259 - { 260 - return Err(ValidationError::InvalidField { 261 - path: "subject".to_string(), 262 - message: "Subject must be a DID".to_string(), 263 - }); 264 - } 265 - Ok(()) 111 + fn validate_preamble<'a>( 112 + record: &'a Value, 113 + collection: &str, 114 + ) -> Result<(&'a str, &'a serde_json::Map<String, Value>), ValidationError> { 115 + let obj = record 116 + .as_object() 117 + .ok_or_else(|| ValidationError::InvalidRecord("Record must be an object".to_string()))?; 118 + let record_type = obj 119 + .get("$type") 120 + .and_then(|v| v.as_str()) 121 + .ok_or(ValidationError::MissingType)?; 122 + if record_type != collection { 123 + return Err(ValidationError::TypeMismatch { 124 + expected: collection.to_string(), 125 + actual: record_type.to_string(), 126 + }); 266 127 } 267 - 268 - fn validate_block(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 269 - if !obj.contains_key("subject") { 270 - return Err(ValidationError::MissingField("subject".to_string())); 271 - } 272 - if !obj.contains_key("createdAt") { 273 - return Err(ValidationError::MissingField("createdAt".to_string())); 274 - } 275 - if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) 276 - && !subject.starts_with("did:") 277 - { 278 - return Err(ValidationError::InvalidField { 279 - path: "subject".to_string(), 280 - message: "Subject must be a DID".to_string(), 281 - }); 282 - } 283 - Ok(()) 128 + if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) { 129 + validate_datetime(created_at, "createdAt")?; 284 130 } 131 + Ok((record_type, obj)) 132 + } 285 133 286 - fn validate_list(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 287 - if !obj.contains_key("name") { 288 - return Err(ValidationError::MissingField("name".to_string())); 289 - } 290 - if !obj.contains_key("purpose") { 291 - return Err(ValidationError::MissingField("purpose".to_string())); 292 - } 293 - if !obj.contains_key("createdAt") { 294 - return Err(ValidationError::MissingField("createdAt".to_string())); 295 - } 296 - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { 297 - if name.is_empty() || name.len() > 64 { 298 - return Err(ValidationError::InvalidField { 299 - path: "name".to_string(), 300 - message: "Name must be 1-64 characters".to_string(), 301 - }); 302 - } 303 - if crate::moderation::has_explicit_slur(name) { 304 - return Err(ValidationError::BannedContent { 305 - path: "name".to_string(), 306 - }); 134 + fn check_banned_content( 135 + record_type: &str, 136 + obj: &serde_json::Map<String, Value>, 137 + rkey: Option<&str>, 138 + ) -> Result<(), ValidationError> { 139 + match record_type { 140 + "app.bsky.feed.post" => { 141 + check_post_banned_content(obj)?; 142 + } 143 + "app.bsky.actor.profile" => { 144 + check_string_field(obj, "displayName")?; 145 + check_string_field(obj, "description")?; 146 + } 147 + "app.bsky.graph.list" => { 148 + check_string_field(obj, "name")?; 149 + } 150 + "app.bsky.graph.starterpack" => { 151 + check_string_field(obj, "name")?; 152 + check_string_field(obj, "description")?; 153 + } 154 + "app.bsky.feed.generator" => { 155 + if let Some(rkey) = rkey { 156 + if crate::moderation::has_explicit_slur(rkey) { 157 + return Err(ValidationError::BannedContent { 158 + path: "rkey".to_string(), 159 + }); 160 + } 307 161 } 162 + check_string_field(obj, "displayName")?; 308 163 } 309 - Ok(()) 310 - } 311 - 312 - fn validate_list_item(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 313 - if !obj.contains_key("subject") { 314 - return Err(ValidationError::MissingField("subject".to_string())); 315 - } 316 - if !obj.contains_key("list") { 317 - return Err(ValidationError::MissingField("list".to_string())); 318 - } 319 - if !obj.contains_key("createdAt") { 320 - return Err(ValidationError::MissingField("createdAt".to_string())); 321 - } 322 - Ok(()) 164 + _ => {} 323 165 } 166 + Ok(()) 167 + } 324 168 325 - fn validate_feed_generator( 326 - obj: &serde_json::Map<String, Value>, 327 - rkey: Option<&str>, 328 - ) -> Result<(), ValidationError> { 329 - if !obj.contains_key("did") { 330 - return Err(ValidationError::MissingField("did".to_string())); 331 - } 332 - if !obj.contains_key("displayName") { 333 - return Err(ValidationError::MissingField("displayName".to_string())); 334 - } 335 - if !obj.contains_key("createdAt") { 336 - return Err(ValidationError::MissingField("createdAt".to_string())); 337 - } 338 - if let Some(rkey) = rkey 339 - && crate::moderation::has_explicit_slur(rkey) 340 - { 341 - return Err(ValidationError::BannedContent { 342 - path: "rkey".to_string(), 343 - }); 344 - } 345 - if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { 346 - if display_name.is_empty() || display_name.len() > 240 { 347 - return Err(ValidationError::InvalidField { 348 - path: "displayName".to_string(), 349 - message: "displayName must be 1-240 characters".to_string(), 350 - }); 351 - } 352 - if crate::moderation::has_explicit_slur(display_name) { 353 - return Err(ValidationError::BannedContent { 354 - path: "displayName".to_string(), 355 - }); 169 + fn check_post_banned_content(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 170 + if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { 171 + tags.iter().enumerate().try_for_each(|(i, tag)| { 172 + if let Some(tag_str) = tag.as_str() { 173 + if crate::moderation::has_explicit_slur(tag_str) { 174 + return Err(ValidationError::BannedContent { 175 + path: format!("tags/{}", i), 176 + }); 177 + } 356 178 } 357 - } 358 - Ok(()) 179 + Ok(()) 180 + })?; 359 181 } 360 - 361 - fn validate_starterpack(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 362 - if !obj.contains_key("name") { 363 - return Err(ValidationError::MissingField("name".to_string())); 364 - } 365 - if !obj.contains_key("createdAt") { 366 - return Err(ValidationError::MissingField("createdAt".to_string())); 367 - } 368 - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { 369 - if name.is_empty() || name.len() > 500 { 370 - return Err(ValidationError::InvalidField { 371 - path: "name".to_string(), 372 - message: "name must be 1-500 characters".to_string(), 373 - }); 374 - } 375 - if crate::moderation::has_explicit_slur(name) { 376 - return Err(ValidationError::BannedContent { 377 - path: "name".to_string(), 378 - }); 379 - } 380 - } 381 - if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { 382 - if description.len() > 3000 { 383 - return Err(ValidationError::InvalidField { 384 - path: "description".to_string(), 385 - message: "description must be at most 3000 characters".to_string(), 386 - }); 387 - } 388 - if crate::moderation::has_explicit_slur(description) { 389 - return Err(ValidationError::BannedContent { 390 - path: "description".to_string(), 391 - }); 182 + if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { 183 + facets.iter().enumerate().try_for_each(|(i, facet)| { 184 + if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { 185 + features.iter().enumerate().try_for_each(|(j, feature)| { 186 + let is_tag = feature 187 + .get("$type") 188 + .and_then(|v| v.as_str()) 189 + .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); 190 + if is_tag { 191 + if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) { 192 + if crate::moderation::has_explicit_slur(tag) { 193 + return Err(ValidationError::BannedContent { 194 + path: format!("facets/{}/features/{}/tag", i, j), 195 + }); 196 + } 197 + } 198 + } 199 + Ok(()) 200 + })?; 392 201 } 393 - } 394 - Ok(()) 395 - } 396 - 397 - fn validate_threadgate(obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 398 - if !obj.contains_key("post") { 399 - return Err(ValidationError::MissingField("post".to_string())); 400 - } 401 - if !obj.contains_key("createdAt") { 402 - return Err(ValidationError::MissingField("createdAt".to_string())); 403 - } 404 - Ok(()) 405 - } 406 - 407 - fn validate_labeler_service( 408 - obj: &serde_json::Map<String, Value>, 409 - ) -> Result<(), ValidationError> { 410 - if !obj.contains_key("policies") { 411 - return Err(ValidationError::MissingField("policies".to_string())); 412 - } 413 - if !obj.contains_key("createdAt") { 414 - return Err(ValidationError::MissingField("createdAt".to_string())); 415 - } 416 - Ok(()) 202 + Ok(()) 203 + })?; 417 204 } 205 + Ok(()) 206 + } 418 207 419 - fn validate_strong_ref(value: Option<&Value>, path: &str) -> Result<(), ValidationError> { 420 - let obj = 421 - value 422 - .and_then(|v| v.as_object()) 423 - .ok_or_else(|| ValidationError::InvalidField { 424 - path: path.to_string(), 425 - message: "Must be a strong reference object".to_string(), 426 - })?; 427 - if !obj.contains_key("uri") { 428 - return Err(ValidationError::MissingField(format!("{}/uri", path))); 429 - } 430 - if !obj.contains_key("cid") { 431 - return Err(ValidationError::MissingField(format!("{}/cid", path))); 432 - } 433 - if let Some(uri) = obj.get("uri").and_then(|v| v.as_str()) 434 - && !uri.starts_with("at://") 435 - { 436 - return Err(ValidationError::InvalidField { 437 - path: format!("{}/uri", path), 438 - message: "URI must be an at:// URI".to_string(), 208 + fn check_string_field( 209 + obj: &serde_json::Map<String, Value>, 210 + field: &str, 211 + ) -> Result<(), ValidationError> { 212 + if let Some(value) = obj.get(field).and_then(|v| v.as_str()) { 213 + if crate::moderation::has_explicit_slur(value) { 214 + return Err(ValidationError::BannedContent { 215 + path: field.to_string(), 439 216 }); 440 217 } 441 - Ok(()) 442 218 } 219 + Ok(()) 443 220 } 444 221 445 222 fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> { 446 - if chrono::DateTime::parse_from_rfc3339(value).is_err() { 223 + if !tranquil_lexicon::is_valid_datetime(value) { 447 224 return Err(ValidationError::InvalidDatetime { 448 225 path: path.to_string(), 449 226 }); ··· 452 229 } 453 230 454 231 pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> { 455 - if rkey.is_empty() { 456 - return Err(ValidationError::InvalidRecord( 457 - "Record key cannot be empty".to_string(), 458 - )); 459 - } 460 - if rkey.len() > 512 { 461 - return Err(ValidationError::InvalidRecord( 462 - "Record key exceeds maximum length of 512".to_string(), 463 - )); 464 - } 465 - if rkey == "." || rkey == ".." { 466 - return Err(ValidationError::InvalidRecord( 467 - "Record key cannot be '.' or '..'".to_string(), 468 - )); 469 - } 470 - let valid_chars = rkey 471 - .chars() 472 - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~'); 473 - if !valid_chars { 474 - return Err(ValidationError::InvalidRecord( 475 - "Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')" 476 - .to_string(), 477 - )); 478 - } 479 - Ok(()) 480 - } 481 - 482 - pub fn is_valid_did(did: &str) -> bool { 483 - if !did.starts_with("did:") { 484 - return false; 485 - } 486 - let parts: Vec<&str> = did.splitn(3, ':').collect(); 487 - if parts.len() < 3 { 488 - return false; 489 - } 490 - let method = parts[1]; 491 - if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) { 492 - return false; 493 - } 494 - let id = parts[2]; 495 - !id.is_empty() 496 - } 497 - 498 - pub fn validate_did(did: &str) -> Result<(), ValidationError> { 499 - if !is_valid_did(did) { 500 - return Err(ValidationError::InvalidField { 501 - path: "did".to_string(), 502 - message: "Invalid DID format".to_string(), 503 - }); 232 + if !tranquil_lexicon::is_valid_record_key(rkey) { 233 + return Err(ValidationError::InvalidRecord(format!( 234 + "Invalid record key: '{}'", 235 + rkey 236 + ))); 504 237 } 505 238 Ok(()) 506 239 } 507 240 508 241 pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> { 509 - if collection.is_empty() { 510 - return Err(ValidationError::InvalidRecord( 511 - "Collection NSID cannot be empty".to_string(), 512 - )); 242 + if !tranquil_lexicon::is_valid_nsid(collection) { 243 + return Err(ValidationError::InvalidRecord(format!( 244 + "Invalid collection NSID: '{}'", 245 + collection 246 + ))); 513 247 } 514 - let parts: Vec<&str> = collection.split('.').collect(); 515 - if parts.len() < 3 { 516 - return Err(ValidationError::InvalidRecord( 517 - "Collection NSID must have at least 3 segments".to_string(), 518 - )); 519 - } 520 - parts.iter().try_for_each(|part| { 521 - if part.is_empty() { 522 - return Err(ValidationError::InvalidRecord( 523 - "Collection NSID segments cannot be empty".to_string(), 524 - )); 525 - } 526 - if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { 527 - return Err(ValidationError::InvalidRecord( 528 - "Collection NSID segments must be alphanumeric or hyphens".to_string(), 529 - )); 530 - } 531 - Ok(()) 532 - })?; 533 248 Ok(()) 534 249 } 535 250 ··· 630 345 let lower = password.to_lowercase(); 631 346 COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower) 632 347 } 633 - 634 - #[cfg(test)] 635 - mod tests { 636 - use super::*; 637 - use serde_json::json; 638 - 639 - #[test] 640 - fn test_validate_post() { 641 - let validator = RecordValidator::new(); 642 - let valid_post = json!({ 643 - "$type": "app.bsky.feed.post", 644 - "text": "Hello, world!", 645 - "createdAt": "2024-01-01T00:00:00.000Z" 646 - }); 647 - assert_eq!( 648 - validator 649 - .validate(&valid_post, "app.bsky.feed.post") 650 - .unwrap(), 651 - ValidationStatus::Valid 652 - ); 653 - } 654 - 655 - #[test] 656 - fn test_validate_post_missing_text() { 657 - let validator = RecordValidator::new(); 658 - let invalid_post = json!({ 659 - "$type": "app.bsky.feed.post", 660 - "createdAt": "2024-01-01T00:00:00.000Z" 661 - }); 662 - assert!( 663 - validator 664 - .validate(&invalid_post, "app.bsky.feed.post") 665 - .is_err() 666 - ); 667 - } 668 - 669 - #[test] 670 - fn test_validate_type_mismatch() { 671 - let validator = RecordValidator::new(); 672 - let record = json!({ 673 - "$type": "app.bsky.feed.like", 674 - "subject": {"uri": "at://did:plc:test/app.bsky.feed.post/123", "cid": "bafyrei..."}, 675 - "createdAt": "2024-01-01T00:00:00.000Z" 676 - }); 677 - let result = validator.validate(&record, "app.bsky.feed.post"); 678 - assert!(matches!(result, Err(ValidationError::TypeMismatch { .. }))); 679 - } 680 - 681 - #[test] 682 - fn test_validate_unknown_type() { 683 - let validator = RecordValidator::new(); 684 - let record = json!({ 685 - "$type": "com.example.custom", 686 - "data": "test" 687 - }); 688 - assert_eq!( 689 - validator.validate(&record, "com.example.custom").unwrap(), 690 - ValidationStatus::Unknown 691 - ); 692 - } 693 - 694 - #[test] 695 - fn test_validate_unknown_type_strict() { 696 - let validator = RecordValidator::new().require_lexicon(true); 697 - let record = json!({ 698 - "$type": "com.example.custom", 699 - "data": "test" 700 - }); 701 - let result = validator.validate(&record, "com.example.custom"); 702 - assert!(matches!(result, Err(ValidationError::UnknownType(_)))); 703 - } 704 - 705 - #[test] 706 - fn test_validate_record_key() { 707 - assert!(validate_record_key("valid-key_123").is_ok()); 708 - assert!(validate_record_key("3k2n5j2").is_ok()); 709 - assert!(validate_record_key(".").is_err()); 710 - assert!(validate_record_key("..").is_err()); 711 - assert!(validate_record_key("").is_err()); 712 - assert!(validate_record_key("invalid/key").is_err()); 713 - } 714 - 715 - #[test] 716 - fn test_validate_collection_nsid() { 717 - assert!(validate_collection_nsid("app.bsky.feed.post").is_ok()); 718 - assert!(validate_collection_nsid("com.atproto.repo.record").is_ok()); 719 - assert!(validate_collection_nsid("invalid").is_err()); 720 - assert!(validate_collection_nsid("a.b").is_err()); 721 - assert!(validate_collection_nsid("").is_err()); 722 - } 723 - 724 - #[test] 725 - fn test_is_valid_did() { 726 - assert!(is_valid_did("did:plc:1234567890abcdefghijk")); 727 - assert!(is_valid_did("did:web:example.com")); 728 - assert!(is_valid_did( 729 - "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 730 - )); 731 - assert!(!is_valid_did("")); 732 - assert!(!is_valid_did("plc:1234567890abcdefghijk")); 733 - assert!(!is_valid_did("did:")); 734 - assert!(!is_valid_did("did:plc:")); 735 - assert!(!is_valid_did("did::something")); 736 - assert!(!is_valid_did("DID:plc:test")); 737 - } 738 - }
+11 -44
crates/tranquil-pds/tests/handle_domains.rs
··· 23 23 let client = client(); 24 24 let base = base_url_with_domain().await; 25 25 let res = client 26 - .get(format!( 27 - "{}/xrpc/com.atproto.server.describeServer", 28 - base 29 - )) 26 + .get(format!("{}/xrpc/com.atproto.server.describeServer", base)) 30 27 .send() 31 28 .await 32 29 .expect("describeServer request failed"); ··· 54 51 "password": "Testpass123!" 55 52 }); 56 53 let res = client 57 - .post(format!( 58 - "{}/xrpc/com.atproto.server.createAccount", 59 - base 60 - )) 54 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 61 55 .json(&payload) 62 56 .send() 63 57 .await ··· 91 85 "password": "Testpass123!" 92 86 }); 93 87 let res = client 94 - .post(format!( 95 - "{}/xrpc/com.atproto.server.createAccount", 96 - base 97 - )) 88 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 98 89 .json(&payload) 99 90 .send() 100 91 .await ··· 122 113 "password": "Testpass123!" 123 114 }); 124 115 let res = client 125 - .post(format!( 126 - "{}/xrpc/com.atproto.server.createAccount", 127 - base 128 - )) 116 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 129 117 .json(&payload) 130 118 .send() 131 119 .await ··· 150 138 "password": "Testpass123!" 151 139 }); 152 140 let res = client 153 - .post(format!( 154 - "{}/xrpc/com.atproto.server.createAccount", 155 - base 156 - )) 141 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 157 142 .json(&payload) 158 143 .send() 159 144 .await ··· 164 149 let full_handle = body["handle"].as_str().expect("No handle").to_string(); 165 150 166 151 let res = client 167 - .get(format!( 168 - "{}/xrpc/com.atproto.identity.resolveHandle", 169 - base 170 - )) 152 + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) 171 153 .query(&[("handle", full_handle.as_str())]) 172 154 .send() 173 155 .await ··· 201 183 assert_eq!(res.status(), StatusCode::OK); 202 184 203 185 let res = client 204 - .get(format!( 205 - "{}/xrpc/com.atproto.identity.resolveHandle", 206 - base 207 - )) 186 + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) 208 187 .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) 209 188 .send() 210 189 .await ··· 228 207 "password": "Testpass123!" 229 208 }); 230 209 let res = client 231 - .post(format!( 232 - "{}/xrpc/com.atproto.server.createAccount", 233 - base 234 - )) 210 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 235 211 .json(&payload) 236 212 .send() 237 213 .await ··· 243 219 244 220 let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); 245 221 let res = client 246 - .post(format!( 247 - "{}/xrpc/com.atproto.identity.updateHandle", 248 - base 249 - )) 222 + .post(format!("{}/xrpc/com.atproto.identity.updateHandle", base)) 250 223 .bearer_auth(&access_jwt) 251 224 .header(header::CONTENT_TYPE, "application/json") 252 225 .json(&json!({ "handle": new_short })) ··· 261 234 ); 262 235 263 236 let res = client 264 - .get(format!( 265 - "{}/xrpc/com.atproto.identity.resolveHandle", 266 - base 267 - )) 237 + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) 268 238 .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) 269 239 .send() 270 240 .await ··· 292 262 "didType": "web" 293 263 }); 294 264 let res = client 295 - .post(format!( 296 - "{}/xrpc/com.atproto.server.createAccount", 297 - base 298 - )) 265 + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) 299 266 .json(&payload) 300 267 .send() 301 268 .await
+2 -2
crates/tranquil-pds/tests/lifecycle_record.rs
··· 193 193 async fn test_profile_with_blob_lifecycle() { 194 194 let client = client(); 195 195 let (did, jwt) = setup_new_user("profile-blob").await; 196 - let blob_data = b"This is test blob data for a profile avatar"; 196 + let blob_data = b"\x89PNG\r\n\x1a\nfake image data for test"; 197 197 let upload_res = client 198 198 .post(format!( 199 199 "{}/xrpc/com.atproto.repo.uploadBlob", 200 200 base_url().await 201 201 )) 202 - .header(header::CONTENT_TYPE, "text/plain") 202 + .header(header::CONTENT_TYPE, "image/png") 203 203 .bearer_auth(&jwt) 204 204 .body(blob_data.to_vec()) 205 205 .send()
+46 -426
crates/tranquil-pds/tests/record_validation.rs
··· 9 9 } 10 10 11 11 #[test] 12 - fn test_post_record_validation() { 12 + fn test_type_mismatch() { 13 13 let validator = RecordValidator::new(); 14 - 15 - let valid_post = json!({ 16 - "$type": "app.bsky.feed.post", 17 - "text": "Hello world!", 18 - "createdAt": now() 19 - }); 20 - assert_eq!( 21 - validator 22 - .validate(&valid_post, "app.bsky.feed.post") 23 - .unwrap(), 24 - ValidationStatus::Valid 25 - ); 26 - 27 - let missing_text = json!({ 28 - "$type": "app.bsky.feed.post", 14 + let record = json!({ 15 + "$type": "com.example.other", 29 16 "createdAt": now() 30 17 }); 31 - assert!( 32 - matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text") 33 - ); 34 - 35 - let missing_created_at = json!({ 36 - "$type": "app.bsky.feed.post", 37 - "text": "Hello" 38 - }); 39 - assert!( 40 - matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt") 41 - ); 42 - 43 - let text_too_long = json!({ 44 - "$type": "app.bsky.feed.post", 45 - "text": "a".repeat(3001), 46 - "createdAt": now() 47 - }); 48 - assert!( 49 - matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text") 50 - ); 51 - 52 - let text_at_limit = json!({ 53 - "$type": "app.bsky.feed.post", 54 - "text": "a".repeat(3000), 55 - "createdAt": now() 56 - }); 57 - assert_eq!( 58 - validator 59 - .validate(&text_at_limit, "app.bsky.feed.post") 60 - .unwrap(), 61 - ValidationStatus::Valid 62 - ); 63 - 64 - let too_many_langs = json!({ 65 - "$type": "app.bsky.feed.post", 66 - "text": "Hello", 67 - "createdAt": now(), 68 - "langs": ["en", "fr", "de", "es"] 69 - }); 70 - assert!( 71 - matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs") 72 - ); 73 - 74 - let three_langs_ok = json!({ 75 - "$type": "app.bsky.feed.post", 76 - "text": "Hello", 77 - "createdAt": now(), 78 - "langs": ["en", "fr", "de"] 79 - }); 80 - assert_eq!( 81 - validator 82 - .validate(&three_langs_ok, "app.bsky.feed.post") 83 - .unwrap(), 84 - ValidationStatus::Valid 85 - ); 86 - 87 - let too_many_tags = json!({ 88 - "$type": "app.bsky.feed.post", 89 - "text": "Hello", 90 - "createdAt": now(), 91 - "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"] 92 - }); 93 - assert!( 94 - matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags") 95 - ); 96 - 97 - let eight_tags_ok = json!({ 98 - "$type": "app.bsky.feed.post", 99 - "text": "Hello", 100 - "createdAt": now(), 101 - "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"] 102 - }); 103 - assert_eq!( 104 - validator 105 - .validate(&eight_tags_ok, "app.bsky.feed.post") 106 - .unwrap(), 107 - ValidationStatus::Valid 108 - ); 109 - 110 - let tag_too_long = json!({ 111 - "$type": "app.bsky.feed.post", 112 - "text": "Hello", 113 - "createdAt": now(), 114 - "tags": ["t".repeat(641)] 115 - }); 116 - assert!( 117 - matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/")) 118 - ); 119 - } 120 - 121 - #[test] 122 - fn test_profile_record_validation() { 123 - let validator = RecordValidator::new(); 124 - 125 - let valid = json!({ 126 - "$type": "app.bsky.actor.profile", 127 - "displayName": "Test User", 128 - "description": "A test user profile" 129 - }); 130 - assert_eq!( 131 - validator 132 - .validate(&valid, "app.bsky.actor.profile") 133 - .unwrap(), 134 - ValidationStatus::Valid 135 - ); 136 - 137 - let empty_ok = json!({ 138 - "$type": "app.bsky.actor.profile" 139 - }); 140 - assert_eq!( 141 - validator 142 - .validate(&empty_ok, "app.bsky.actor.profile") 143 - .unwrap(), 144 - ValidationStatus::Valid 145 - ); 146 - 147 - let displayname_too_long = json!({ 148 - "$type": "app.bsky.actor.profile", 149 - "displayName": "n".repeat(641) 150 - }); 151 - assert!( 152 - matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName") 153 - ); 154 - 155 - let description_too_long = json!({ 156 - "$type": "app.bsky.actor.profile", 157 - "description": "d".repeat(2561) 158 - }); 159 - assert!( 160 - matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description") 161 - ); 18 + assert!(matches!( 19 + validator.validate(&record, "com.example.expected"), 20 + Err(ValidationError::TypeMismatch { expected, actual }) 21 + if expected == "com.example.expected" && actual == "com.example.other" 22 + )); 162 23 } 163 24 164 25 #[test] 165 - fn test_like_and_repost_validation() { 26 + fn test_missing_type() { 166 27 let validator = RecordValidator::new(); 167 - 168 - let valid_like = json!({ 169 - "$type": "app.bsky.feed.like", 170 - "subject": { 171 - "uri": "at://did:plc:test/app.bsky.feed.post/123", 172 - "cid": "bafyreig6xxxxxyyyyyzzzzzz" 173 - }, 174 - "createdAt": now() 175 - }); 176 - assert_eq!( 177 - validator 178 - .validate(&valid_like, "app.bsky.feed.like") 179 - .unwrap(), 180 - ValidationStatus::Valid 181 - ); 182 - 183 - let missing_subject = json!({ 184 - "$type": "app.bsky.feed.like", 185 - "createdAt": now() 186 - }); 187 - assert!( 188 - matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject") 189 - ); 190 - 191 - let missing_subject_uri = json!({ 192 - "$type": "app.bsky.feed.like", 193 - "subject": { 194 - "cid": "bafyreig6xxxxxyyyyyzzzzzz" 195 - }, 196 - "createdAt": now() 197 - }); 198 - assert!( 199 - matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri")) 200 - ); 201 - 202 - let invalid_subject_uri = json!({ 203 - "$type": "app.bsky.feed.like", 204 - "subject": { 205 - "uri": "https://example.com/not-at-uri", 206 - "cid": "bafyreig6xxxxxyyyyyzzzzzz" 207 - }, 208 - "createdAt": now() 209 - }); 210 - assert!( 211 - matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri")) 212 - ); 213 - 214 - let valid_repost = json!({ 215 - "$type": "app.bsky.feed.repost", 216 - "subject": { 217 - "uri": "at://did:plc:test/app.bsky.feed.post/123", 218 - "cid": "bafyreig6xxxxxyyyyyzzzzzz" 219 - }, 220 - "createdAt": now() 221 - }); 222 - assert_eq!( 223 - validator 224 - .validate(&valid_repost, "app.bsky.feed.repost") 225 - .unwrap(), 226 - ValidationStatus::Valid 227 - ); 228 - 229 - let repost_missing_subject = json!({ 230 - "$type": "app.bsky.feed.repost", 231 - "createdAt": now() 232 - }); 233 - assert!( 234 - matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject") 235 - ); 28 + let record = json!({"text": "Hello"}); 29 + assert!(matches!( 30 + validator.validate(&record, "com.example.test"), 31 + Err(ValidationError::MissingType) 32 + )); 236 33 } 237 34 238 35 #[test] 239 - fn test_follow_and_block_validation() { 36 + fn test_not_object() { 240 37 let validator = RecordValidator::new(); 241 - 242 - let valid_follow = json!({ 243 - "$type": "app.bsky.graph.follow", 244 - "subject": "did:plc:test12345", 245 - "createdAt": now() 246 - }); 247 - assert_eq!( 248 - validator 249 - .validate(&valid_follow, "app.bsky.graph.follow") 250 - .unwrap(), 251 - ValidationStatus::Valid 252 - ); 253 - 254 - let missing_follow_subject = json!({ 255 - "$type": "app.bsky.graph.follow", 256 - "createdAt": now() 257 - }); 258 - assert!( 259 - matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject") 260 - ); 261 - 262 - let invalid_follow_subject = json!({ 263 - "$type": "app.bsky.graph.follow", 264 - "subject": "not-a-did", 265 - "createdAt": now() 266 - }); 267 - assert!( 268 - matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject") 269 - ); 270 - 271 - let valid_block = json!({ 272 - "$type": "app.bsky.graph.block", 273 - "subject": "did:plc:blocked123", 274 - "createdAt": now() 275 - }); 276 - assert_eq!( 277 - validator 278 - .validate(&valid_block, "app.bsky.graph.block") 279 - .unwrap(), 280 - ValidationStatus::Valid 281 - ); 282 - 283 - let invalid_block_subject = json!({ 284 - "$type": "app.bsky.graph.block", 285 - "subject": "not-a-did", 286 - "createdAt": now() 287 - }); 288 - assert!( 289 - matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject") 290 - ); 38 + let record = json!("just a string"); 39 + assert!(matches!( 40 + validator.validate(&record, "com.example.test"), 41 + Err(ValidationError::InvalidRecord(_)) 42 + )); 291 43 } 292 44 293 45 #[test] 294 - fn test_list_and_graph_records_validation() { 46 + fn test_unknown_type_lenient() { 295 47 let validator = RecordValidator::new(); 296 - 297 - let valid_list = json!({ 298 - "$type": "app.bsky.graph.list", 299 - "name": "My List", 300 - "purpose": "app.bsky.graph.defs#modlist", 301 - "createdAt": now() 302 - }); 48 + let record = json!({"$type": "com.custom.record", "data": "test"}); 303 49 assert_eq!( 304 - validator 305 - .validate(&valid_list, "app.bsky.graph.list") 306 - .unwrap(), 307 - ValidationStatus::Valid 308 - ); 309 - 310 - let list_name_too_long = json!({ 311 - "$type": "app.bsky.graph.list", 312 - "name": "n".repeat(65), 313 - "purpose": "app.bsky.graph.defs#modlist", 314 - "createdAt": now() 315 - }); 316 - assert!( 317 - matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name") 318 - ); 319 - 320 - let list_empty_name = json!({ 321 - "$type": "app.bsky.graph.list", 322 - "name": "", 323 - "purpose": "app.bsky.graph.defs#modlist", 324 - "createdAt": now() 325 - }); 326 - assert!( 327 - matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name") 328 - ); 329 - 330 - let valid_list_item = json!({ 331 - "$type": "app.bsky.graph.listitem", 332 - "subject": "did:plc:test123", 333 - "list": "at://did:plc:owner/app.bsky.graph.list/mylist", 334 - "createdAt": now() 335 - }); 336 - assert_eq!( 337 - validator 338 - .validate(&valid_list_item, "app.bsky.graph.listitem") 339 - .unwrap(), 340 - ValidationStatus::Valid 50 + validator.validate(&record, "com.custom.record").unwrap(), 51 + ValidationStatus::Unknown 341 52 ); 342 53 } 343 54 344 55 #[test] 345 - fn test_misc_record_types_validation() { 346 - let validator = RecordValidator::new(); 347 - 348 - let valid_generator = json!({ 349 - "$type": "app.bsky.feed.generator", 350 - "did": "did:web:example.com", 351 - "displayName": "My Feed", 352 - "createdAt": now() 353 - }); 354 - assert_eq!( 355 - validator 356 - .validate(&valid_generator, "app.bsky.feed.generator") 357 - .unwrap(), 358 - ValidationStatus::Valid 359 - ); 360 - 361 - let generator_displayname_too_long = json!({ 362 - "$type": "app.bsky.feed.generator", 363 - "did": "did:web:example.com", 364 - "displayName": "f".repeat(241), 365 - "createdAt": now() 366 - }); 367 - assert!( 368 - matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName") 369 - ); 370 - 371 - let valid_threadgate = json!({ 372 - "$type": "app.bsky.feed.threadgate", 373 - "post": "at://did:plc:test/app.bsky.feed.post/123", 374 - "createdAt": now() 375 - }); 376 - assert_eq!( 377 - validator 378 - .validate(&valid_threadgate, "app.bsky.feed.threadgate") 379 - .unwrap(), 380 - ValidationStatus::Valid 381 - ); 382 - 383 - let valid_labeler = json!({ 384 - "$type": "app.bsky.labeler.service", 385 - "policies": { 386 - "labelValues": ["spam", "nsfw"] 387 - }, 388 - "createdAt": now() 389 - }); 390 - assert_eq!( 391 - validator 392 - .validate(&valid_labeler, "app.bsky.labeler.service") 393 - .unwrap(), 394 - ValidationStatus::Valid 395 - ); 56 + fn test_unknown_type_strict() { 57 + let validator = RecordValidator::new().require_lexicon(true); 58 + let record = json!({"$type": "com.custom.record", "data": "test"}); 59 + assert!(matches!( 60 + validator.validate(&record, "com.custom.record"), 61 + Err(ValidationError::UnknownType(_)) 62 + )); 396 63 } 397 64 398 65 #[test] 399 - fn test_type_and_format_validation() { 66 + fn test_datetime_validation() { 400 67 let validator = RecordValidator::new(); 401 - let strict_validator = RecordValidator::new().require_lexicon(true); 402 68 403 - let custom_record = json!({ 404 - "$type": "com.custom.record", 405 - "data": "test" 406 - }); 69 + let valid = json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00.000Z"}); 407 70 assert_eq!( 408 - validator 409 - .validate(&custom_record, "com.custom.record") 410 - .unwrap(), 71 + validator.validate(&valid, "com.custom.record").unwrap(), 411 72 ValidationStatus::Unknown 412 73 ); 413 - assert!(matches!( 414 - strict_validator.validate(&custom_record, "com.custom.record"), 415 - Err(ValidationError::UnknownType(_)) 416 - )); 417 - 418 - let type_mismatch = json!({ 419 - "$type": "app.bsky.feed.like", 420 - "subject": {"uri": "at://test", "cid": "bafytest"}, 421 - "createdAt": now() 422 - }); 423 - assert!(matches!( 424 - validator.validate(&type_mismatch, "app.bsky.feed.post"), 425 - Err(ValidationError::TypeMismatch { expected, actual }) if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like" 426 - )); 427 - 428 - let missing_type = json!({ 429 - "text": "Hello" 430 - }); 431 - assert!(matches!( 432 - validator.validate(&missing_type, "app.bsky.feed.post"), 433 - Err(ValidationError::MissingType) 434 - )); 435 - 436 - let not_object = json!("just a string"); 437 - assert!(matches!( 438 - validator.validate(&not_object, "app.bsky.feed.post"), 439 - Err(ValidationError::InvalidRecord(_)) 440 - )); 441 - 442 - let valid_datetime = json!({ 443 - "$type": "app.bsky.feed.post", 444 - "text": "Test", 445 - "createdAt": "2024-01-15T10:30:00.000Z" 446 - }); 447 - assert_eq!( 448 - validator 449 - .validate(&valid_datetime, "app.bsky.feed.post") 450 - .unwrap(), 451 - ValidationStatus::Valid 452 - ); 453 74 454 - let datetime_with_offset = json!({ 455 - "$type": "app.bsky.feed.post", 456 - "text": "Test", 457 - "createdAt": "2024-01-15T10:30:00+05:30" 458 - }); 75 + let with_offset = 76 + json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00+05:30"}); 459 77 assert_eq!( 460 78 validator 461 - .validate(&datetime_with_offset, "app.bsky.feed.post") 79 + .validate(&with_offset, "com.custom.record") 462 80 .unwrap(), 463 - ValidationStatus::Valid 81 + ValidationStatus::Unknown 464 82 ); 465 83 466 - let invalid_datetime = json!({ 467 - "$type": "app.bsky.feed.post", 468 - "text": "Test", 469 - "createdAt": "2024/01/15" 470 - }); 84 + let invalid = json!({"$type": "com.custom.record", "createdAt": "2024/01/15"}); 471 85 assert!(matches!( 472 - validator.validate(&invalid_datetime, "app.bsky.feed.post"), 86 + validator.validate(&invalid, "com.custom.record"), 473 87 Err(ValidationError::InvalidDatetime { .. }) 474 88 )); 475 89 } ··· 501 115 Err(ValidationError::InvalidRecord(_)) 502 116 )); 503 117 assert!(validate_record_key(&"k".repeat(512)).is_ok()); 118 + 119 + assert!( 120 + validate_record_key("key:with:colons").is_ok(), 121 + "AT Protocol record keys allow colons" 122 + ); 123 + assert!(validate_record_key("at:something").is_ok()); 504 124 } 505 125 506 126 #[test]
+66 -22
crates/tranquil-pds/tests/repo_conformance.rs
··· 6 6 use reqwest::StatusCode; 7 7 use serde_json::{Value, json}; 8 8 9 + fn ensure_test_schemas() { 10 + use std::sync::Once; 11 + static INIT: Once = Once::new(); 12 + INIT.call_once(|| { 13 + let registry = tranquil_lexicon::LexiconRegistry::global(); 14 + let post_schema: tranquil_lexicon::LexiconDoc = serde_json::from_value(json!({ 15 + "lexicon": 1, 16 + "id": "com.test.feed.post", 17 + "defs": { 18 + "main": { 19 + "type": "record", 20 + "key": "tid", 21 + "record": { 22 + "type": "object", 23 + "required": ["text", "createdAt"], 24 + "properties": { 25 + "text": { "type": "string", "maxLength": 300, "maxGraphemes": 300 }, 26 + "createdAt": { "type": "string", "format": "datetime" }, 27 + "reply": { "type": "ref", "ref": "#replyRef" }, 28 + "embed": { "type": "union", "refs": [] }, 29 + "langs": { "type": "array", "maxLength": 3, "items": { "type": "string", "format": "language" } }, 30 + "tags": { "type": "array", "maxLength": 8, "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } }, 31 + "facets": { "type": "array", "items": { "type": "unknown" } } 32 + } 33 + } 34 + }, 35 + "replyRef": { 36 + "type": "object", 37 + "required": ["root", "parent"], 38 + "properties": { 39 + "root": { "type": "unknown" }, 40 + "parent": { "type": "unknown" } 41 + } 42 + } 43 + } 44 + })).expect("invalid post schema"); 45 + registry.preload(post_schema); 46 + }); 47 + } 48 + 9 49 #[tokio::test] 10 50 async fn test_create_record_response_schema() { 51 + ensure_test_schemas(); 11 52 let client = client(); 12 53 let (did, jwt) = setup_new_user("conform-create").await; 13 54 let now = Utc::now().to_rfc3339(); 14 55 15 56 let payload = json!({ 16 57 "repo": did, 17 - "collection": "app.bsky.feed.post", 58 + "collection": "com.test.feed.post", 18 59 "record": { 19 - "$type": "app.bsky.feed.post", 60 + "$type": "com.test.feed.post", 20 61 "text": "Testing conformance", 21 62 "createdAt": now 22 63 } ··· 73 114 74 115 let payload = json!({ 75 116 "repo": did, 76 - "collection": "app.bsky.feed.post", 117 + "collection": "com.test.feed.post", 77 118 "validate": false, 78 119 "record": { 79 - "$type": "app.bsky.feed.post", 120 + "$type": "com.test.feed.post", 80 121 "text": "Testing without validation", 81 122 "createdAt": now 82 123 } ··· 106 147 107 148 #[tokio::test] 108 149 async fn test_put_record_response_schema() { 150 + ensure_test_schemas(); 109 151 let client = client(); 110 152 let (did, jwt) = setup_new_user("conform-put").await; 111 153 let now = Utc::now().to_rfc3339(); 112 154 113 155 let payload = json!({ 114 156 "repo": did, 115 - "collection": "app.bsky.feed.post", 157 + "collection": "com.test.feed.post", 116 158 "rkey": "conformance-put", 117 159 "record": { 118 - "$type": "app.bsky.feed.post", 160 + "$type": "com.test.feed.post", 119 161 "text": "Testing putRecord conformance", 120 162 "createdAt": now 121 163 } ··· 160 202 161 203 let create_payload = json!({ 162 204 "repo": did, 163 - "collection": "app.bsky.feed.post", 205 + "collection": "com.test.feed.post", 164 206 "rkey": "to-delete", 165 207 "record": { 166 - "$type": "app.bsky.feed.post", 208 + "$type": "com.test.feed.post", 167 209 "text": "This will be deleted", 168 210 "createdAt": now 169 211 } ··· 182 224 183 225 let delete_payload = json!({ 184 226 "repo": did, 185 - "collection": "app.bsky.feed.post", 227 + "collection": "com.test.feed.post", 186 228 "rkey": "to-delete" 187 229 }); 188 230 let delete_res = client ··· 215 257 216 258 let delete_payload = json!({ 217 259 "repo": did, 218 - "collection": "app.bsky.feed.post", 260 + "collection": "com.test.feed.post", 219 261 "rkey": "nonexistent-record" 220 262 }); 221 263 let delete_res = client ··· 240 282 241 283 #[tokio::test] 242 284 async fn test_apply_writes_response_schema() { 285 + ensure_test_schemas(); 243 286 let client = client(); 244 287 let (did, jwt) = setup_new_user("conform-apply").await; 245 288 let now = Utc::now().to_rfc3339(); ··· 249 292 "writes": [ 250 293 { 251 294 "$type": "com.atproto.repo.applyWrites#create", 252 - "collection": "app.bsky.feed.post", 295 + "collection": "com.test.feed.post", 253 296 "rkey": "apply-test-1", 254 297 "value": { 255 - "$type": "app.bsky.feed.post", 298 + "$type": "com.test.feed.post", 256 299 "text": "First post", 257 300 "createdAt": now 258 301 } 259 302 }, 260 303 { 261 304 "$type": "com.atproto.repo.applyWrites#create", 262 - "collection": "app.bsky.feed.post", 305 + "collection": "com.test.feed.post", 263 306 "rkey": "apply-test-2", 264 307 "value": { 265 - "$type": "app.bsky.feed.post", 308 + "$type": "com.test.feed.post", 266 309 "text": "Second post", 267 310 "createdAt": now 268 311 } ··· 312 355 313 356 #[tokio::test] 314 357 async fn test_apply_writes_update_and_delete_results() { 358 + ensure_test_schemas(); 315 359 let client = client(); 316 360 let (did, jwt) = setup_new_user("conform-apply-upd").await; 317 361 let now = Utc::now().to_rfc3339(); 318 362 319 363 let create_payload = json!({ 320 364 "repo": did, 321 - "collection": "app.bsky.feed.post", 365 + "collection": "com.test.feed.post", 322 366 "rkey": "to-update", 323 367 "record": { 324 - "$type": "app.bsky.feed.post", 368 + "$type": "com.test.feed.post", 325 369 "text": "Original", 326 370 "createdAt": now 327 371 } ··· 342 386 "writes": [ 343 387 { 344 388 "$type": "com.atproto.repo.applyWrites#update", 345 - "collection": "app.bsky.feed.post", 389 + "collection": "com.test.feed.post", 346 390 "rkey": "to-update", 347 391 "value": { 348 - "$type": "app.bsky.feed.post", 392 + "$type": "com.test.feed.post", 349 393 "text": "Updated", 350 394 "createdAt": now 351 395 } 352 396 }, 353 397 { 354 398 "$type": "com.atproto.repo.applyWrites#delete", 355 - "collection": "app.bsky.feed.post", 399 + "collection": "com.test.feed.post", 356 400 "rkey": "to-update" 357 401 } 358 402 ] ··· 415 459 )) 416 460 .query(&[ 417 461 ("repo", did.as_str()), 418 - ("collection", "app.bsky.feed.post"), 462 + ("collection", "com.test.feed.post"), 419 463 ("rkey", "nonexistent"), 420 464 ]) 421 465 .send() ··· 520 564 let now = Utc::now().to_rfc3339(); 521 565 522 566 let record = json!({ 523 - "$type": "app.bsky.feed.post", 567 + "$type": "com.test.feed.post", 524 568 "text": "This content will not change", 525 569 "createdAt": now 526 570 }); 527 571 528 572 let payload = json!({ 529 573 "repo": did, 530 - "collection": "app.bsky.feed.post", 574 + "collection": "com.test.feed.post", 531 575 "rkey": "noop-test", 532 576 "record": record.clone() 533 577 });
+3 -5
crates/tranquil-pds/tests/validation_edge_cases.rs
··· 1 + use tranquil_lexicon::is_valid_did; 1 2 use tranquil_pds::api::validation::{ 2 3 HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH, 3 4 MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle, 4 5 }; 5 - use tranquil_pds::validation::{ 6 - is_valid_did, validate_collection_nsid, validate_password, validate_record_key, 7 - }; 6 + use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key}; 8 7 9 8 #[test] 10 9 fn test_record_key_boundary_min() { ··· 59 58 assert!(validate_record_key("a+b").is_err()); 60 59 assert!(validate_record_key("a=b").is_err()); 61 60 assert!(validate_record_key("a?b").is_err()); 62 - assert!(validate_record_key("a:b").is_err()); 63 61 assert!(validate_record_key("a;b").is_err()); 64 62 assert!(validate_record_key("a<b").is_err()); 65 63 assert!(validate_record_key("a>b").is_err()); ··· 160 158 161 159 #[test] 162 160 fn test_did_validation_method_chars() { 163 - assert!(!is_valid_did("did:plc1:abc")); 161 + assert!(is_valid_did("did:plc1:abc")); 164 162 assert!(!is_valid_did("did:plc-x:abc")); 165 163 assert!(!is_valid_did("did:plc_x:abc")); 166 164 }
+1
scripts/test-infra.sh
··· 59 59 export TRANQUIL_PDS_ALLOW_INSECURE_SECRETS="1" 60 60 export SKIP_IMPORT_VERIFICATION="true" 61 61 export DISABLE_RATE_LIMITING="1" 62 + export TRANQUIL_LEXICON_OFFLINE="1" 62 63 EOF 63 64 echo "" 64 65 echo "Infrastructure ready!"

History

2 rounds 0 comments
sign up or login to add to the discussion
1 commit
expand
refactor(pds): integrate tranquil-lexicon for record validation
expand 0 comments
pull request successfully merged
1 commit
expand
refactor(pds): integrate tranquil-lexicon for record validation
expand 0 comments