diff --git a/Cargo.toml b/Cargo.toml index 4b0d47a..bb3dfec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ members = [ ] [workspace.package] -version = "0.3.1" +version = "0.4.0" edition = "2024" license = "AGPL-3.0-or-later" diff --git a/crates/tranquil-comms/src/sender.rs b/crates/tranquil-comms/src/sender.rs index 84a2e5f..40d6192 100644 --- a/crates/tranquil-comms/src/sender.rs +++ b/crates/tranquil-comms/src/sender.rs @@ -169,19 +169,21 @@ impl CommsSender for EmailSender { source: e, })?; if let Some(mut stdin) = child.stdin.take() { - stdin.write_all(email_content.as_bytes()).await.map_err(|e| { - SendError::ProcessSpawn { + stdin + .write_all(email_content.as_bytes()) + .await + .map_err(|e| SendError::ProcessSpawn { command: self.sendmail_path.clone(), source: e, - } - })?; + })?; } - let output = child.wait_with_output().await.map_err(|e| { - SendError::ProcessSpawn { + let output = child + .wait_with_output() + .await + .map_err(|e| SendError::ProcessSpawn { command: self.sendmail_path.clone(), source: e, - } - })?; + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(SendError::ProcessFailed { diff --git a/crates/tranquil-pds/Cargo.toml b/crates/tranquil-pds/Cargo.toml index 5591bef..1b05b4c 100644 --- a/crates/tranquil-pds/Cargo.toml +++ b/crates/tranquil-pds/Cargo.toml @@ -17,6 +17,7 @@ tranquil-oauth = { workspace = true } tranquil-comms = { workspace = true } tranquil-db = { workspace = true } tranquil-db-traits = { workspace = true } +tranquil-lexicon = { workspace = true, features = ["resolve"] } aes-gcm = { workspace = true } async-trait = { workspace = true } diff --git a/crates/tranquil-pds/src/api/identity/account.rs b/crates/tranquil-pds/src/api/identity/account.rs index 3b79ae4..d67a5bc 100644 --- a/crates/tranquil-pds/src/api/identity/account.rs +++ b/crates/tranquil-pds/src/api/identity/account.rs @@ -147,9 +147,7 @@ pub async fn create_account( .filter(|d| input.handle.ends_with(&format!(".{}", d))) .max_by_key(|d| d.len()); - let validated_short_handle = if !input.handle.contains('.') - || matched_domain.is_some() - { + let validated_short_handle = if !input.handle.contains('.') || matched_domain.is_some() { let handle_to_validate = match matched_domain { Some(domain) => input .handle diff --git a/crates/tranquil-pds/src/api/identity/did.rs b/crates/tranquil-pds/src/api/identity/did.rs index 7b96490..45e1f72 100644 --- a/crates/tranquil-pds/src/api/identity/did.rs +++ b/crates/tranquil-pds/src/api/identity/did.rs @@ -684,7 +684,9 @@ pub async fn update_handle( .max_by_key(|d| d.len()) .cloned(); let is_domain_itself = handle_domains.iter().any(|d| d == &new_handle); - let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) && !is_domain_itself { + let handle = if (!new_handle.contains('.') || matched_handle_domain.is_some()) + && !is_domain_itself + { let (short_part, full_handle) = match &matched_handle_domain { Some(domain) => { let suffix = format!(".{}", domain); diff --git a/crates/tranquil-pds/src/api/repo/record/batch.rs b/crates/tranquil-pds/src/api/repo/record/batch.rs index d6535a6..0ccb143 100644 --- a/crates/tranquil-pds/src/api/repo/record/batch.rs +++ b/crates/tranquil-pds/src/api/repo/record/batch.rs @@ -65,7 +65,9 @@ async fn process_single_write( collection, rkey.as_ref(), validate.requires_lexicon(), - ) { + ) + .await + { Ok(status) => Some(status), Err(err_response) => return Err(*err_response), } @@ -116,7 +118,9 @@ async fn process_single_write( collection, Some(rkey), validate.requires_lexicon(), - ) { + ) + .await + { Ok(status) => Some(status), Err(err_response) => return Err(*err_response), } diff --git a/crates/tranquil-pds/src/api/repo/record/validation.rs b/crates/tranquil-pds/src/api/repo/record/validation.rs index 56c20fe..e49686c 100644 --- a/crates/tranquil-pds/src/api/repo/record/validation.rs +++ b/crates/tranquil-pds/src/api/repo/record/validation.rs @@ -3,29 +3,17 @@ use crate::types::{Nsid, Rkey}; use crate::validation::{RecordValidator, ValidationError, ValidationStatus}; use axum::response::Response; -pub fn validate_record(record: &serde_json::Value, collection: &Nsid) -> Result<(), Box> { - validate_record_with_rkey(record, collection, None) -} - -pub fn validate_record_with_rkey( - record: &serde_json::Value, - collection: &Nsid, - rkey: Option<&Rkey>, -) -> Result<(), Box> { - let validator = RecordValidator::new(); - validation_error_to_response(validator.validate_with_rkey( - record, - collection.as_str(), - rkey.map(|r| r.as_str()), - )) -} - -pub fn validate_record_with_status( +pub async fn validate_record_with_status( record: &serde_json::Value, collection: &Nsid, rkey: Option<&Rkey>, require_lexicon: bool, ) -> Result> { + let registry = tranquil_lexicon::LexiconRegistry::global(); + if !registry.has_schema(collection.as_str()) { + let _ = registry.resolve_dynamic(collection.as_str()).await; + } + let validator = RecordValidator::new().require_lexicon(require_lexicon); match validator.validate_with_rkey(record, collection.as_str(), rkey.map(|r| r.as_str())) { Ok(status) => Ok(status), @@ -33,15 +21,6 @@ pub fn validate_record_with_status( } } -fn validation_error_to_response( - result: Result, -) -> Result<(), Box> { - match result { - Ok(_) => Ok(()), - Err(e) => Err(validation_error_to_box_response(e)), - } -} - fn validation_error_to_box_response(e: ValidationError) -> Box { use axum::response::IntoResponse; let msg = match e { diff --git a/crates/tranquil-pds/src/api/repo/record/write.rs b/crates/tranquil-pds/src/api/repo/record/write.rs index 390b329..9927c20 100644 --- a/crates/tranquil-pds/src/api/repo/record/write.rs +++ b/crates/tranquil-pds/src/api/repo/record/write.rs @@ -136,7 +136,9 @@ pub async fn create_record( &input.collection, input.rkey.as_ref(), input.validate.requires_lexicon(), - ) { + ) + .await + { Ok(status) => Some(status), Err(err_response) => return Ok(*err_response), } @@ -456,7 +458,9 @@ pub async fn put_record( &input.collection, Some(&input.rkey), input.validate.requires_lexicon(), - ) { + ) + .await + { Ok(status) => Some(status), Err(err_response) => return Ok(*err_response), } diff --git a/crates/tranquil-pds/src/api/validation.rs b/crates/tranquil-pds/src/api/validation.rs index babbbed..d6f085d 100644 --- a/crates/tranquil-pds/src/api/validation.rs +++ b/crates/tranquil-pds/src/api/validation.rs @@ -285,9 +285,12 @@ pub fn validate_full_domain_handle(handle: &str) -> Result = handle.split('.').collect(); - let has_invalid_label = labels - .iter() - .any(|label| label.is_empty() || label.len() > MAX_DOMAIN_LABEL_LENGTH || label.starts_with('-') || label.ends_with('-')); + let has_invalid_label = labels.iter().any(|label| { + label.is_empty() + || label.len() > MAX_DOMAIN_LABEL_LENGTH + || label.starts_with('-') + || label.ends_with('-') + }); if has_invalid_label { return Err(HandleValidationError::InvalidCharacters); } diff --git a/crates/tranquil-pds/src/sso/endpoints.rs b/crates/tranquil-pds/src/sso/endpoints.rs index 9b514e3..7ee9a98 100644 --- a/crates/tranquil-pds/src/sso/endpoints.rs +++ b/crates/tranquil-pds/src/sso/endpoints.rs @@ -776,15 +776,10 @@ pub async fn check_handle_available( let available_domains = tranquil_config::get().server.available_user_domain_list(); if let Some(ref d) = query.domain { if !available_domains.iter().any(|ad| ad == d) { - return Err(ApiError::InvalidRequest( - "Unknown user domain".into(), - )); + return Err(ApiError::InvalidRequest("Unknown user domain".into())); } } - let domain = query - .domain - .as_deref() - .unwrap_or(&available_domains[0]); + let domain = query.domain.as_deref().unwrap_or(&available_domains[0]); let full_handle = format!("{}.{}", validated, domain); let handle_typed: crate::types::Handle = match full_handle.parse() { Ok(h) => h, diff --git a/crates/tranquil-pds/src/validation/mod.rs b/crates/tranquil-pds/src/validation/mod.rs index ad46e6f..5e8b67a 100644 --- a/crates/tranquil-pds/src/validation/mod.rs +++ b/crates/tranquil-pds/src/validation/mod.rs @@ -1,5 +1,6 @@ use serde_json::Value; use thiserror::Error; +use tranquil_lexicon::LexValidationError; #[derive(Debug, Error)] pub enum ValidationError { @@ -75,375 +76,151 @@ impl RecordValidator { collection: &str, rkey: Option<&str>, ) -> Result { - let obj = record.as_object().ok_or_else(|| { - ValidationError::InvalidRecord("Record must be an object".to_string()) - })?; - let record_type = obj - .get("$type") - .and_then(|v| v.as_str()) - .ok_or(ValidationError::MissingType)?; - if record_type != collection { - return Err(ValidationError::TypeMismatch { - expected: collection.to_string(), - actual: record_type.to_string(), - }); - } - if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) { - validate_datetime(created_at, "createdAt")?; - } - match record_type { - "app.bsky.feed.post" => Self::validate_post(obj)?, - "app.bsky.actor.profile" => Self::validate_profile(obj)?, - "app.bsky.feed.like" => Self::validate_like(obj)?, - "app.bsky.feed.repost" => Self::validate_repost(obj)?, - "app.bsky.graph.follow" => Self::validate_follow(obj)?, - "app.bsky.graph.block" => Self::validate_block(obj)?, - "app.bsky.graph.list" => Self::validate_list(obj)?, - "app.bsky.graph.listitem" => Self::validate_list_item(obj)?, - "app.bsky.feed.generator" => Self::validate_feed_generator(obj, rkey)?, - "app.bsky.feed.threadgate" => Self::validate_threadgate(obj)?, - "app.bsky.labeler.service" => Self::validate_labeler_service(obj)?, - "app.bsky.graph.starterpack" => Self::validate_starterpack(obj)?, - _ => { + let (record_type, obj) = validate_preamble(record, collection)?; + let registry = tranquil_lexicon::LexiconRegistry::global(); + + match tranquil_lexicon::validate_record(registry, record_type, record) { + Ok(()) => { + check_banned_content(record_type, obj, rkey)?; + Ok(ValidationStatus::Valid) + } + Err(LexValidationError::LexiconNotFound(_)) => { if self.require_lexicon { - return Err(ValidationError::UnknownType(record_type.to_string())); - } - return Ok(ValidationStatus::Unknown); - } - } - Ok(ValidationStatus::Valid) - } - - fn validate_post(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("text") { - return Err(ValidationError::MissingField("text".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { - let grapheme_count = text.chars().count(); - if grapheme_count > 3000 { - return Err(ValidationError::InvalidField { - path: "text".to_string(), - message: format!( - "Text exceeds maximum length of 3000 characters (got {})", - grapheme_count - ), - }); - } - } - if let Some(langs) = obj.get("langs").and_then(|v| v.as_array()) - && langs.len() > 3 - { - return Err(ValidationError::InvalidField { - path: "langs".to_string(), - message: "Maximum 3 languages allowed".to_string(), - }); - } - if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { - if tags.len() > 8 { - return Err(ValidationError::InvalidField { - path: "tags".to_string(), - message: "Maximum 8 tags allowed".to_string(), - }); - } - for (i, tag) in tags.iter().enumerate() { - if let Some(tag_str) = tag.as_str() { - if tag_str.len() > 640 { - return Err(ValidationError::InvalidField { - path: format!("tags/{}", i), - message: "Tag exceeds maximum length of 640 bytes".to_string(), - }); - } - if crate::moderation::has_explicit_slur(tag_str) { - return Err(ValidationError::BannedContent { - path: format!("tags/{}", i), - }); - } + Err(ValidationError::UnknownType(record_type.to_string())) + } else { + check_banned_content(record_type, obj, rkey)?; + Ok(ValidationStatus::Unknown) } } - } - if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { - for (i, facet) in facets.iter().enumerate() { - if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { - for (j, feature) in features.iter().enumerate() { - let is_tag = feature - .get("$type") - .and_then(|v| v.as_str()) - .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); - if is_tag - && let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) - && crate::moderation::has_explicit_slur(tag) - { - return Err(ValidationError::BannedContent { - path: format!("facets/{}/features/{}/tag", i, j), - }); - } - } - } + Err(LexValidationError::MissingRequired { path }) => { + Err(ValidationError::MissingField(path)) + } + Err(LexValidationError::InvalidField { path, message }) => { + Err(ValidationError::InvalidField { path, message }) + } + Err(LexValidationError::RecursionDepthExceeded { path }) => { + Err(ValidationError::InvalidField { + path, + message: "recursion depth exceeded".to_string(), + }) } } - Ok(()) - } - - fn validate_profile(obj: &serde_json::Map) -> Result<(), ValidationError> { - if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { - let grapheme_count = display_name.chars().count(); - if grapheme_count > 640 { - return Err(ValidationError::InvalidField { - path: "displayName".to_string(), - message: format!( - "Display name exceeds maximum length of 640 characters (got {})", - grapheme_count - ), - }); - } - if crate::moderation::has_explicit_slur(display_name) { - return Err(ValidationError::BannedContent { - path: "displayName".to_string(), - }); - } - } - if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { - let grapheme_count = description.chars().count(); - if grapheme_count > 2560 { - return Err(ValidationError::InvalidField { - path: "description".to_string(), - message: format!( - "Description exceeds maximum length of 2560 characters (got {})", - grapheme_count - ), - }); - } - if crate::moderation::has_explicit_slur(description) { - return Err(ValidationError::BannedContent { - path: "description".to_string(), - }); - } - } - Ok(()) - } - - fn validate_like(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("subject") { - return Err(ValidationError::MissingField("subject".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - Self::validate_strong_ref(obj.get("subject"), "subject")?; - Ok(()) - } - - fn validate_repost(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("subject") { - return Err(ValidationError::MissingField("subject".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - Self::validate_strong_ref(obj.get("subject"), "subject")?; - Ok(()) - } - - fn validate_follow(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("subject") { - return Err(ValidationError::MissingField("subject".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) - && !subject.starts_with("did:") - { - return Err(ValidationError::InvalidField { - path: "subject".to_string(), - message: "Subject must be a DID".to_string(), - }); - } - Ok(()) - } - - fn validate_block(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("subject") { - return Err(ValidationError::MissingField("subject".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) - && !subject.starts_with("did:") - { - return Err(ValidationError::InvalidField { - path: "subject".to_string(), - message: "Subject must be a DID".to_string(), - }); - } - Ok(()) - } - - fn validate_list(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("name") { - return Err(ValidationError::MissingField("name".to_string())); - } - if !obj.contains_key("purpose") { - return Err(ValidationError::MissingField("purpose".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { - if name.is_empty() || name.len() > 64 { - return Err(ValidationError::InvalidField { - path: "name".to_string(), - message: "Name must be 1-64 characters".to_string(), - }); - } - if crate::moderation::has_explicit_slur(name) { - return Err(ValidationError::BannedContent { - path: "name".to_string(), - }); - } - } - Ok(()) - } - - fn validate_list_item(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("subject") { - return Err(ValidationError::MissingField("subject".to_string())); - } - if !obj.contains_key("list") { - return Err(ValidationError::MissingField("list".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - Ok(()) - } - - fn validate_feed_generator( - obj: &serde_json::Map, - rkey: Option<&str>, - ) -> Result<(), ValidationError> { - if !obj.contains_key("did") { - return Err(ValidationError::MissingField("did".to_string())); - } - if !obj.contains_key("displayName") { - return Err(ValidationError::MissingField("displayName".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - if let Some(rkey) = rkey - && crate::moderation::has_explicit_slur(rkey) - { - return Err(ValidationError::BannedContent { - path: "rkey".to_string(), - }); - } - if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { - if display_name.is_empty() || display_name.len() > 240 { - return Err(ValidationError::InvalidField { - path: "displayName".to_string(), - message: "displayName must be 1-240 characters".to_string(), - }); - } - if crate::moderation::has_explicit_slur(display_name) { - return Err(ValidationError::BannedContent { - path: "displayName".to_string(), - }); - } - } - Ok(()) - } - - fn validate_starterpack(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("name") { - return Err(ValidationError::MissingField("name".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { - if name.is_empty() || name.len() > 500 { - return Err(ValidationError::InvalidField { - path: "name".to_string(), - message: "name must be 1-500 characters".to_string(), - }); - } - if crate::moderation::has_explicit_slur(name) { - return Err(ValidationError::BannedContent { - path: "name".to_string(), - }); - } - } - if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { - if description.len() > 3000 { - return Err(ValidationError::InvalidField { - path: "description".to_string(), - message: "description must be at most 3000 characters".to_string(), - }); - } - if crate::moderation::has_explicit_slur(description) { - return Err(ValidationError::BannedContent { - path: "description".to_string(), - }); - } - } - Ok(()) - } - - fn validate_threadgate(obj: &serde_json::Map) -> Result<(), ValidationError> { - if !obj.contains_key("post") { - return Err(ValidationError::MissingField("post".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - Ok(()) - } - - fn validate_labeler_service( - obj: &serde_json::Map, - ) -> Result<(), ValidationError> { - if !obj.contains_key("policies") { - return Err(ValidationError::MissingField("policies".to_string())); - } - if !obj.contains_key("createdAt") { - return Err(ValidationError::MissingField("createdAt".to_string())); - } - Ok(()) - } - - fn validate_strong_ref(value: Option<&Value>, path: &str) -> Result<(), ValidationError> { - let obj = - value - .and_then(|v| v.as_object()) - .ok_or_else(|| ValidationError::InvalidField { - path: path.to_string(), - message: "Must be a strong reference object".to_string(), - })?; - if !obj.contains_key("uri") { - return Err(ValidationError::MissingField(format!("{}/uri", path))); - } - if !obj.contains_key("cid") { - return Err(ValidationError::MissingField(format!("{}/cid", path))); - } - if let Some(uri) = obj.get("uri").and_then(|v| v.as_str()) - && !uri.starts_with("at://") - { - return Err(ValidationError::InvalidField { - path: format!("{}/uri", path), - message: "URI must be an at:// URI".to_string(), - }); - } - Ok(()) } } +fn validate_preamble<'a>( + record: &'a Value, + collection: &str, +) -> Result<(&'a str, &'a serde_json::Map), ValidationError> { + let obj = record + .as_object() + .ok_or_else(|| ValidationError::InvalidRecord("Record must be an object".to_string()))?; + let record_type = obj + .get("$type") + .and_then(|v| v.as_str()) + .ok_or(ValidationError::MissingType)?; + if record_type != collection { + return Err(ValidationError::TypeMismatch { + expected: collection.to_string(), + actual: record_type.to_string(), + }); + } + if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) { + validate_datetime(created_at, "createdAt")?; + } + Ok((record_type, obj)) +} + +fn check_banned_content( + record_type: &str, + obj: &serde_json::Map, + rkey: Option<&str>, +) -> Result<(), ValidationError> { + match record_type { + "app.bsky.feed.post" => { + check_post_banned_content(obj)?; + } + "app.bsky.actor.profile" => { + check_string_field(obj, "displayName")?; + check_string_field(obj, "description")?; + } + "app.bsky.graph.list" => { + check_string_field(obj, "name")?; + } + "app.bsky.graph.starterpack" => { + check_string_field(obj, "name")?; + check_string_field(obj, "description")?; + } + "app.bsky.feed.generator" => { + if let Some(rkey) = rkey { + if crate::moderation::has_explicit_slur(rkey) { + return Err(ValidationError::BannedContent { + path: "rkey".to_string(), + }); + } + } + check_string_field(obj, "displayName")?; + } + _ => {} + } + Ok(()) +} + +fn check_post_banned_content(obj: &serde_json::Map) -> Result<(), ValidationError> { + if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { + tags.iter().enumerate().try_for_each(|(i, tag)| { + if let Some(tag_str) = tag.as_str() { + if crate::moderation::has_explicit_slur(tag_str) { + return Err(ValidationError::BannedContent { + path: format!("tags/{}", i), + }); + } + } + Ok(()) + })?; + } + if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { + facets.iter().enumerate().try_for_each(|(i, facet)| { + if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { + features.iter().enumerate().try_for_each(|(j, feature)| { + let is_tag = feature + .get("$type") + .and_then(|v| v.as_str()) + .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); + if is_tag { + if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) { + if crate::moderation::has_explicit_slur(tag) { + return Err(ValidationError::BannedContent { + path: format!("facets/{}/features/{}/tag", i, j), + }); + } + } + } + Ok(()) + })?; + } + Ok(()) + })?; + } + Ok(()) +} + +fn check_string_field( + obj: &serde_json::Map, + field: &str, +) -> Result<(), ValidationError> { + if let Some(value) = obj.get(field).and_then(|v| v.as_str()) { + if crate::moderation::has_explicit_slur(value) { + return Err(ValidationError::BannedContent { + path: field.to_string(), + }); + } + } + Ok(()) +} + fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> { - if chrono::DateTime::parse_from_rfc3339(value).is_err() { + if !tranquil_lexicon::is_valid_datetime(value) { return Err(ValidationError::InvalidDatetime { path: path.to_string(), }); @@ -452,84 +229,22 @@ fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> { } pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> { - if rkey.is_empty() { - return Err(ValidationError::InvalidRecord( - "Record key cannot be empty".to_string(), - )); - } - if rkey.len() > 512 { - return Err(ValidationError::InvalidRecord( - "Record key exceeds maximum length of 512".to_string(), - )); - } - if rkey == "." || rkey == ".." { - return Err(ValidationError::InvalidRecord( - "Record key cannot be '.' or '..'".to_string(), - )); - } - let valid_chars = rkey - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~'); - if !valid_chars { - return Err(ValidationError::InvalidRecord( - "Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')" - .to_string(), - )); - } - Ok(()) -} - -pub fn is_valid_did(did: &str) -> bool { - if !did.starts_with("did:") { - return false; - } - let parts: Vec<&str> = did.splitn(3, ':').collect(); - if parts.len() < 3 { - return false; - } - let method = parts[1]; - if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) { - return false; - } - let id = parts[2]; - !id.is_empty() -} - -pub fn validate_did(did: &str) -> Result<(), ValidationError> { - if !is_valid_did(did) { - return Err(ValidationError::InvalidField { - path: "did".to_string(), - message: "Invalid DID format".to_string(), - }); + if !tranquil_lexicon::is_valid_record_key(rkey) { + return Err(ValidationError::InvalidRecord(format!( + "Invalid record key: '{}'", + rkey + ))); } Ok(()) } pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> { - if collection.is_empty() { - return Err(ValidationError::InvalidRecord( - "Collection NSID cannot be empty".to_string(), - )); + if !tranquil_lexicon::is_valid_nsid(collection) { + return Err(ValidationError::InvalidRecord(format!( + "Invalid collection NSID: '{}'", + collection + ))); } - let parts: Vec<&str> = collection.split('.').collect(); - if parts.len() < 3 { - return Err(ValidationError::InvalidRecord( - "Collection NSID must have at least 3 segments".to_string(), - )); - } - parts.iter().try_for_each(|part| { - if part.is_empty() { - return Err(ValidationError::InvalidRecord( - "Collection NSID segments cannot be empty".to_string(), - )); - } - if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { - return Err(ValidationError::InvalidRecord( - "Collection NSID segments must be alphanumeric or hyphens".to_string(), - )); - } - Ok(()) - })?; Ok(()) } @@ -630,109 +345,3 @@ fn is_common_password(password: &str) -> bool { let lower = password.to_lowercase(); COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower) } - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn test_validate_post() { - let validator = RecordValidator::new(); - let valid_post = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello, world!", - "createdAt": "2024-01-01T00:00:00.000Z" - }); - assert_eq!( - validator - .validate(&valid_post, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - } - - #[test] - fn test_validate_post_missing_text() { - let validator = RecordValidator::new(); - let invalid_post = json!({ - "$type": "app.bsky.feed.post", - "createdAt": "2024-01-01T00:00:00.000Z" - }); - assert!( - validator - .validate(&invalid_post, "app.bsky.feed.post") - .is_err() - ); - } - - #[test] - fn test_validate_type_mismatch() { - let validator = RecordValidator::new(); - let record = json!({ - "$type": "app.bsky.feed.like", - "subject": {"uri": "at://did:plc:test/app.bsky.feed.post/123", "cid": "bafyrei..."}, - "createdAt": "2024-01-01T00:00:00.000Z" - }); - let result = validator.validate(&record, "app.bsky.feed.post"); - assert!(matches!(result, Err(ValidationError::TypeMismatch { .. }))); - } - - #[test] - fn test_validate_unknown_type() { - let validator = RecordValidator::new(); - let record = json!({ - "$type": "com.example.custom", - "data": "test" - }); - assert_eq!( - validator.validate(&record, "com.example.custom").unwrap(), - ValidationStatus::Unknown - ); - } - - #[test] - fn test_validate_unknown_type_strict() { - let validator = RecordValidator::new().require_lexicon(true); - let record = json!({ - "$type": "com.example.custom", - "data": "test" - }); - let result = validator.validate(&record, "com.example.custom"); - assert!(matches!(result, Err(ValidationError::UnknownType(_)))); - } - - #[test] - fn test_validate_record_key() { - assert!(validate_record_key("valid-key_123").is_ok()); - assert!(validate_record_key("3k2n5j2").is_ok()); - assert!(validate_record_key(".").is_err()); - assert!(validate_record_key("..").is_err()); - assert!(validate_record_key("").is_err()); - assert!(validate_record_key("invalid/key").is_err()); - } - - #[test] - fn test_validate_collection_nsid() { - assert!(validate_collection_nsid("app.bsky.feed.post").is_ok()); - assert!(validate_collection_nsid("com.atproto.repo.record").is_ok()); - assert!(validate_collection_nsid("invalid").is_err()); - assert!(validate_collection_nsid("a.b").is_err()); - assert!(validate_collection_nsid("").is_err()); - } - - #[test] - fn test_is_valid_did() { - assert!(is_valid_did("did:plc:1234567890abcdefghijk")); - assert!(is_valid_did("did:web:example.com")); - assert!(is_valid_did( - "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" - )); - assert!(!is_valid_did("")); - assert!(!is_valid_did("plc:1234567890abcdefghijk")); - assert!(!is_valid_did("did:")); - assert!(!is_valid_did("did:plc:")); - assert!(!is_valid_did("did::something")); - assert!(!is_valid_did("DID:plc:test")); - } -} diff --git a/crates/tranquil-pds/tests/handle_domains.rs b/crates/tranquil-pds/tests/handle_domains.rs index 2420e96..17ab727 100644 --- a/crates/tranquil-pds/tests/handle_domains.rs +++ b/crates/tranquil-pds/tests/handle_domains.rs @@ -23,10 +23,7 @@ async fn describe_server_returns_configured_domain() { let client = client(); let base = base_url_with_domain().await; let res = client - .get(format!( - "{}/xrpc/com.atproto.server.describeServer", - base - )) + .get(format!("{}/xrpc/com.atproto.server.describeServer", base)) .send() .await .expect("describeServer request failed"); @@ -54,10 +51,7 @@ async fn short_handle_uses_configured_domain() { "password": "Testpass123!" }); let res = client - .post(format!( - "{}/xrpc/com.atproto.server.createAccount", - base - )) + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) .send() .await @@ -91,10 +85,7 @@ async fn full_handle_with_configured_domain_accepted() { "password": "Testpass123!" }); let res = client - .post(format!( - "{}/xrpc/com.atproto.server.createAccount", - base - )) + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) .send() .await @@ -122,10 +113,7 @@ async fn handle_with_pds_hostname_treated_as_custom() { "password": "Testpass123!" }); let res = client - .post(format!( - "{}/xrpc/com.atproto.server.createAccount", - base - )) + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) .send() .await @@ -150,10 +138,7 @@ async fn resolve_handle_works_with_configured_domain() { "password": "Testpass123!" }); let res = client - .post(format!( - "{}/xrpc/com.atproto.server.createAccount", - base - )) + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) .send() .await @@ -164,10 +149,7 @@ async fn resolve_handle_works_with_configured_domain() { let full_handle = body["handle"].as_str().expect("No handle").to_string(); let res = client - .get(format!( - "{}/xrpc/com.atproto.identity.resolveHandle", - base - )) + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) .query(&[("handle", full_handle.as_str())]) .send() .await @@ -201,10 +183,7 @@ async fn admin_update_handle_uses_configured_domain() { assert_eq!(res.status(), StatusCode::OK); let res = client - .get(format!( - "{}/xrpc/com.atproto.identity.resolveHandle", - base - )) + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) .send() .await @@ -228,10 +207,7 @@ async fn update_handle_bare_uses_configured_domain() { "password": "Testpass123!" }); let res = client - .post(format!( - "{}/xrpc/com.atproto.server.createAccount", - base - )) + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) .send() .await @@ -243,10 +219,7 @@ async fn update_handle_bare_uses_configured_domain() { let new_short = format!("hd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]); let res = client - .post(format!( - "{}/xrpc/com.atproto.identity.updateHandle", - base - )) + .post(format!("{}/xrpc/com.atproto.identity.updateHandle", base)) .bearer_auth(&access_jwt) .header(header::CONTENT_TYPE, "application/json") .json(&json!({ "handle": new_short })) @@ -261,10 +234,7 @@ async fn update_handle_bare_uses_configured_domain() { ); let res = client - .get(format!( - "{}/xrpc/com.atproto.identity.resolveHandle", - base - )) + .get(format!("{}/xrpc/com.atproto.identity.resolveHandle", base)) .query(&[("handle", format!("{}.{}", new_short, HANDLE_DOMAIN))]) .send() .await @@ -292,10 +262,7 @@ async fn did_web_uses_handle_domain_not_hostname() { "didType": "web" }); let res = client - .post(format!( - "{}/xrpc/com.atproto.server.createAccount", - base - )) + .post(format!("{}/xrpc/com.atproto.server.createAccount", base)) .json(&payload) .send() .await diff --git a/crates/tranquil-pds/tests/lifecycle_record.rs b/crates/tranquil-pds/tests/lifecycle_record.rs index 847104f..5659c31 100644 --- a/crates/tranquil-pds/tests/lifecycle_record.rs +++ b/crates/tranquil-pds/tests/lifecycle_record.rs @@ -193,13 +193,13 @@ async fn test_record_crud_lifecycle() { async fn test_profile_with_blob_lifecycle() { let client = client(); let (did, jwt) = setup_new_user("profile-blob").await; - let blob_data = b"This is test blob data for a profile avatar"; + let blob_data = b"\x89PNG\r\n\x1a\nfake image data for test"; let upload_res = client .post(format!( "{}/xrpc/com.atproto.repo.uploadBlob", base_url().await )) - .header(header::CONTENT_TYPE, "text/plain") + .header(header::CONTENT_TYPE, "image/png") .bearer_auth(&jwt) .body(blob_data.to_vec()) .send() diff --git a/crates/tranquil-pds/tests/record_validation.rs b/crates/tranquil-pds/tests/record_validation.rs index 692b759..f812783 100644 --- a/crates/tranquil-pds/tests/record_validation.rs +++ b/crates/tranquil-pds/tests/record_validation.rs @@ -9,467 +9,81 @@ fn now() -> String { } #[test] -fn test_post_record_validation() { +fn test_type_mismatch() { let validator = RecordValidator::new(); - - let valid_post = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello world!", + let record = json!({ + "$type": "com.example.other", "createdAt": now() }); + assert!(matches!( + validator.validate(&record, "com.example.expected"), + Err(ValidationError::TypeMismatch { expected, actual }) + if expected == "com.example.expected" && actual == "com.example.other" + )); +} + +#[test] +fn test_missing_type() { + let validator = RecordValidator::new(); + let record = json!({"text": "Hello"}); + assert!(matches!( + validator.validate(&record, "com.example.test"), + Err(ValidationError::MissingType) + )); +} + +#[test] +fn test_not_object() { + let validator = RecordValidator::new(); + let record = json!("just a string"); + assert!(matches!( + validator.validate(&record, "com.example.test"), + Err(ValidationError::InvalidRecord(_)) + )); +} + +#[test] +fn test_unknown_type_lenient() { + let validator = RecordValidator::new(); + let record = json!({"$type": "com.custom.record", "data": "test"}); assert_eq!( - validator - .validate(&valid_post, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - - let missing_text = json!({ - "$type": "app.bsky.feed.post", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&missing_text, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "text") - ); - - let missing_created_at = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello" - }); - assert!( - matches!(validator.validate(&missing_created_at, "app.bsky.feed.post"), Err(ValidationError::MissingField(f)) if f == "createdAt") - ); - - let text_too_long = json!({ - "$type": "app.bsky.feed.post", - "text": "a".repeat(3001), - "createdAt": now() - }); - assert!( - matches!(validator.validate(&text_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "text") - ); - - let text_at_limit = json!({ - "$type": "app.bsky.feed.post", - "text": "a".repeat(3000), - "createdAt": now() - }); - assert_eq!( - validator - .validate(&text_at_limit, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - - let too_many_langs = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello", - "createdAt": now(), - "langs": ["en", "fr", "de", "es"] - }); - assert!( - matches!(validator.validate(&too_many_langs, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "langs") - ); - - let three_langs_ok = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello", - "createdAt": now(), - "langs": ["en", "fr", "de"] - }); - assert_eq!( - validator - .validate(&three_langs_ok, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - - let too_many_tags = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello", - "createdAt": now(), - "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8", "tag9"] - }); - assert!( - matches!(validator.validate(&too_many_tags, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path == "tags") - ); - - let eight_tags_ok = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello", - "createdAt": now(), - "tags": ["tag1", "tag2", "tag3", "tag4", "tag5", "tag6", "tag7", "tag8"] - }); - assert_eq!( - validator - .validate(&eight_tags_ok, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - - let tag_too_long = json!({ - "$type": "app.bsky.feed.post", - "text": "Hello", - "createdAt": now(), - "tags": ["t".repeat(641)] - }); - assert!( - matches!(validator.validate(&tag_too_long, "app.bsky.feed.post"), Err(ValidationError::InvalidField { path, .. }) if path.starts_with("tags/")) + validator.validate(&record, "com.custom.record").unwrap(), + ValidationStatus::Unknown ); } #[test] -fn test_profile_record_validation() { - let validator = RecordValidator::new(); - - let valid = json!({ - "$type": "app.bsky.actor.profile", - "displayName": "Test User", - "description": "A test user profile" - }); - assert_eq!( - validator - .validate(&valid, "app.bsky.actor.profile") - .unwrap(), - ValidationStatus::Valid - ); - - let empty_ok = json!({ - "$type": "app.bsky.actor.profile" - }); - assert_eq!( - validator - .validate(&empty_ok, "app.bsky.actor.profile") - .unwrap(), - ValidationStatus::Valid - ); - - let displayname_too_long = json!({ - "$type": "app.bsky.actor.profile", - "displayName": "n".repeat(641) - }); - assert!( - matches!(validator.validate(&displayname_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName") - ); - - let description_too_long = json!({ - "$type": "app.bsky.actor.profile", - "description": "d".repeat(2561) - }); - assert!( - matches!(validator.validate(&description_too_long, "app.bsky.actor.profile"), Err(ValidationError::InvalidField { path, .. }) if path == "description") - ); +fn test_unknown_type_strict() { + let validator = RecordValidator::new().require_lexicon(true); + let record = json!({"$type": "com.custom.record", "data": "test"}); + assert!(matches!( + validator.validate(&record, "com.custom.record"), + Err(ValidationError::UnknownType(_)) + )); } #[test] -fn test_like_and_repost_validation() { +fn test_datetime_validation() { let validator = RecordValidator::new(); - let valid_like = json!({ - "$type": "app.bsky.feed.like", - "subject": { - "uri": "at://did:plc:test/app.bsky.feed.post/123", - "cid": "bafyreig6xxxxxyyyyyzzzzzz" - }, - "createdAt": now() - }); + let valid = json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00.000Z"}); + assert_eq!( + validator.validate(&valid, "com.custom.record").unwrap(), + ValidationStatus::Unknown + ); + + let with_offset = + json!({"$type": "com.custom.record", "createdAt": "2024-01-15T10:30:00+05:30"}); assert_eq!( validator - .validate(&valid_like, "app.bsky.feed.like") - .unwrap(), - ValidationStatus::Valid - ); - - let missing_subject = json!({ - "$type": "app.bsky.feed.like", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&missing_subject, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f == "subject") - ); - - let missing_subject_uri = json!({ - "$type": "app.bsky.feed.like", - "subject": { - "cid": "bafyreig6xxxxxyyyyyzzzzzz" - }, - "createdAt": now() - }); - assert!( - matches!(validator.validate(&missing_subject_uri, "app.bsky.feed.like"), Err(ValidationError::MissingField(f)) if f.contains("uri")) - ); - - let invalid_subject_uri = json!({ - "$type": "app.bsky.feed.like", - "subject": { - "uri": "https://example.com/not-at-uri", - "cid": "bafyreig6xxxxxyyyyyzzzzzz" - }, - "createdAt": now() - }); - assert!( - matches!(validator.validate(&invalid_subject_uri, "app.bsky.feed.like"), Err(ValidationError::InvalidField { path, .. }) if path.contains("uri")) - ); - - let valid_repost = json!({ - "$type": "app.bsky.feed.repost", - "subject": { - "uri": "at://did:plc:test/app.bsky.feed.post/123", - "cid": "bafyreig6xxxxxyyyyyzzzzzz" - }, - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_repost, "app.bsky.feed.repost") - .unwrap(), - ValidationStatus::Valid - ); - - let repost_missing_subject = json!({ - "$type": "app.bsky.feed.repost", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&repost_missing_subject, "app.bsky.feed.repost"), Err(ValidationError::MissingField(f)) if f == "subject") - ); -} - -#[test] -fn test_follow_and_block_validation() { - let validator = RecordValidator::new(); - - let valid_follow = json!({ - "$type": "app.bsky.graph.follow", - "subject": "did:plc:test12345", - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_follow, "app.bsky.graph.follow") - .unwrap(), - ValidationStatus::Valid - ); - - let missing_follow_subject = json!({ - "$type": "app.bsky.graph.follow", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&missing_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::MissingField(f)) if f == "subject") - ); - - let invalid_follow_subject = json!({ - "$type": "app.bsky.graph.follow", - "subject": "not-a-did", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&invalid_follow_subject, "app.bsky.graph.follow"), Err(ValidationError::InvalidField { path, .. }) if path == "subject") - ); - - let valid_block = json!({ - "$type": "app.bsky.graph.block", - "subject": "did:plc:blocked123", - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_block, "app.bsky.graph.block") - .unwrap(), - ValidationStatus::Valid - ); - - let invalid_block_subject = json!({ - "$type": "app.bsky.graph.block", - "subject": "not-a-did", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&invalid_block_subject, "app.bsky.graph.block"), Err(ValidationError::InvalidField { path, .. }) if path == "subject") - ); -} - -#[test] -fn test_list_and_graph_records_validation() { - let validator = RecordValidator::new(); - - let valid_list = json!({ - "$type": "app.bsky.graph.list", - "name": "My List", - "purpose": "app.bsky.graph.defs#modlist", - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_list, "app.bsky.graph.list") - .unwrap(), - ValidationStatus::Valid - ); - - let list_name_too_long = json!({ - "$type": "app.bsky.graph.list", - "name": "n".repeat(65), - "purpose": "app.bsky.graph.defs#modlist", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&list_name_too_long, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name") - ); - - let list_empty_name = json!({ - "$type": "app.bsky.graph.list", - "name": "", - "purpose": "app.bsky.graph.defs#modlist", - "createdAt": now() - }); - assert!( - matches!(validator.validate(&list_empty_name, "app.bsky.graph.list"), Err(ValidationError::InvalidField { path, .. }) if path == "name") - ); - - let valid_list_item = json!({ - "$type": "app.bsky.graph.listitem", - "subject": "did:plc:test123", - "list": "at://did:plc:owner/app.bsky.graph.list/mylist", - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_list_item, "app.bsky.graph.listitem") - .unwrap(), - ValidationStatus::Valid - ); -} - -#[test] -fn test_misc_record_types_validation() { - let validator = RecordValidator::new(); - - let valid_generator = json!({ - "$type": "app.bsky.feed.generator", - "did": "did:web:example.com", - "displayName": "My Feed", - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_generator, "app.bsky.feed.generator") - .unwrap(), - ValidationStatus::Valid - ); - - let generator_displayname_too_long = json!({ - "$type": "app.bsky.feed.generator", - "did": "did:web:example.com", - "displayName": "f".repeat(241), - "createdAt": now() - }); - assert!( - matches!(validator.validate(&generator_displayname_too_long, "app.bsky.feed.generator"), Err(ValidationError::InvalidField { path, .. }) if path == "displayName") - ); - - let valid_threadgate = json!({ - "$type": "app.bsky.feed.threadgate", - "post": "at://did:plc:test/app.bsky.feed.post/123", - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_threadgate, "app.bsky.feed.threadgate") - .unwrap(), - ValidationStatus::Valid - ); - - let valid_labeler = json!({ - "$type": "app.bsky.labeler.service", - "policies": { - "labelValues": ["spam", "nsfw"] - }, - "createdAt": now() - }); - assert_eq!( - validator - .validate(&valid_labeler, "app.bsky.labeler.service") - .unwrap(), - ValidationStatus::Valid - ); -} - -#[test] -fn test_type_and_format_validation() { - let validator = RecordValidator::new(); - let strict_validator = RecordValidator::new().require_lexicon(true); - - let custom_record = json!({ - "$type": "com.custom.record", - "data": "test" - }); - assert_eq!( - validator - .validate(&custom_record, "com.custom.record") + .validate(&with_offset, "com.custom.record") .unwrap(), ValidationStatus::Unknown ); + + let invalid = json!({"$type": "com.custom.record", "createdAt": "2024/01/15"}); assert!(matches!( - strict_validator.validate(&custom_record, "com.custom.record"), - Err(ValidationError::UnknownType(_)) - )); - - let type_mismatch = json!({ - "$type": "app.bsky.feed.like", - "subject": {"uri": "at://test", "cid": "bafytest"}, - "createdAt": now() - }); - assert!(matches!( - validator.validate(&type_mismatch, "app.bsky.feed.post"), - Err(ValidationError::TypeMismatch { expected, actual }) if expected == "app.bsky.feed.post" && actual == "app.bsky.feed.like" - )); - - let missing_type = json!({ - "text": "Hello" - }); - assert!(matches!( - validator.validate(&missing_type, "app.bsky.feed.post"), - Err(ValidationError::MissingType) - )); - - let not_object = json!("just a string"); - assert!(matches!( - validator.validate(¬_object, "app.bsky.feed.post"), - Err(ValidationError::InvalidRecord(_)) - )); - - let valid_datetime = json!({ - "$type": "app.bsky.feed.post", - "text": "Test", - "createdAt": "2024-01-15T10:30:00.000Z" - }); - assert_eq!( - validator - .validate(&valid_datetime, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - - let datetime_with_offset = json!({ - "$type": "app.bsky.feed.post", - "text": "Test", - "createdAt": "2024-01-15T10:30:00+05:30" - }); - assert_eq!( - validator - .validate(&datetime_with_offset, "app.bsky.feed.post") - .unwrap(), - ValidationStatus::Valid - ); - - let invalid_datetime = json!({ - "$type": "app.bsky.feed.post", - "text": "Test", - "createdAt": "2024/01/15" - }); - assert!(matches!( - validator.validate(&invalid_datetime, "app.bsky.feed.post"), + validator.validate(&invalid, "com.custom.record"), Err(ValidationError::InvalidDatetime { .. }) )); } @@ -501,6 +115,12 @@ fn test_record_key_validation() { Err(ValidationError::InvalidRecord(_)) )); assert!(validate_record_key(&"k".repeat(512)).is_ok()); + + assert!( + validate_record_key("key:with:colons").is_ok(), + "AT Protocol record keys allow colons" + ); + assert!(validate_record_key("at:something").is_ok()); } #[test] diff --git a/crates/tranquil-pds/tests/repo_conformance.rs b/crates/tranquil-pds/tests/repo_conformance.rs index 0bf3ee2..9a05e56 100644 --- a/crates/tranquil-pds/tests/repo_conformance.rs +++ b/crates/tranquil-pds/tests/repo_conformance.rs @@ -6,17 +6,58 @@ use helpers::*; use reqwest::StatusCode; use serde_json::{Value, json}; +fn ensure_test_schemas() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + let registry = tranquil_lexicon::LexiconRegistry::global(); + let post_schema: tranquil_lexicon::LexiconDoc = serde_json::from_value(json!({ + "lexicon": 1, + "id": "com.test.feed.post", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["text", "createdAt"], + "properties": { + "text": { "type": "string", "maxLength": 300, "maxGraphemes": 300 }, + "createdAt": { "type": "string", "format": "datetime" }, + "reply": { "type": "ref", "ref": "#replyRef" }, + "embed": { "type": "union", "refs": [] }, + "langs": { "type": "array", "maxLength": 3, "items": { "type": "string", "format": "language" } }, + "tags": { "type": "array", "maxLength": 8, "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 } }, + "facets": { "type": "array", "items": { "type": "unknown" } } + } + } + }, + "replyRef": { + "type": "object", + "required": ["root", "parent"], + "properties": { + "root": { "type": "unknown" }, + "parent": { "type": "unknown" } + } + } + } + })).expect("invalid post schema"); + registry.preload(post_schema); + }); +} + #[tokio::test] async fn test_create_record_response_schema() { + ensure_test_schemas(); let client = client(); let (did, jwt) = setup_new_user("conform-create").await; let now = Utc::now().to_rfc3339(); let payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "record": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "Testing conformance", "createdAt": now } @@ -73,10 +114,10 @@ async fn test_create_record_no_validation_status_when_validate_false() { let payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "validate": false, "record": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "Testing without validation", "createdAt": now } @@ -106,16 +147,17 @@ async fn test_create_record_no_validation_status_when_validate_false() { #[tokio::test] async fn test_put_record_response_schema() { + ensure_test_schemas(); let client = client(); let (did, jwt) = setup_new_user("conform-put").await; let now = Utc::now().to_rfc3339(); let payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "conformance-put", "record": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "Testing putRecord conformance", "createdAt": now } @@ -160,10 +202,10 @@ async fn test_delete_record_response_schema() { let create_payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "to-delete", "record": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "This will be deleted", "createdAt": now } @@ -182,7 +224,7 @@ async fn test_delete_record_response_schema() { let delete_payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "to-delete" }); let delete_res = client @@ -215,7 +257,7 @@ async fn test_delete_record_noop_response() { let delete_payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "nonexistent-record" }); let delete_res = client @@ -240,6 +282,7 @@ async fn test_delete_record_noop_response() { #[tokio::test] async fn test_apply_writes_response_schema() { + ensure_test_schemas(); let client = client(); let (did, jwt) = setup_new_user("conform-apply").await; let now = Utc::now().to_rfc3339(); @@ -249,20 +292,20 @@ async fn test_apply_writes_response_schema() { "writes": [ { "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "apply-test-1", "value": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "First post", "createdAt": now } }, { "$type": "com.atproto.repo.applyWrites#create", - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "apply-test-2", "value": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "Second post", "createdAt": now } @@ -312,16 +355,17 @@ async fn test_apply_writes_response_schema() { #[tokio::test] async fn test_apply_writes_update_and_delete_results() { + ensure_test_schemas(); let client = client(); let (did, jwt) = setup_new_user("conform-apply-upd").await; let now = Utc::now().to_rfc3339(); let create_payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "to-update", "record": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "Original", "createdAt": now } @@ -342,17 +386,17 @@ async fn test_apply_writes_update_and_delete_results() { "writes": [ { "$type": "com.atproto.repo.applyWrites#update", - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "to-update", "value": { - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "Updated", "createdAt": now } }, { "$type": "com.atproto.repo.applyWrites#delete", - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "to-update" } ] @@ -415,7 +459,7 @@ async fn test_get_record_error_code() { )) .query(&[ ("repo", did.as_str()), - ("collection", "app.bsky.feed.post"), + ("collection", "com.test.feed.post"), ("rkey", "nonexistent"), ]) .send() @@ -520,14 +564,14 @@ async fn test_put_record_noop_same_content() { let now = Utc::now().to_rfc3339(); let record = json!({ - "$type": "app.bsky.feed.post", + "$type": "com.test.feed.post", "text": "This content will not change", "createdAt": now }); let payload = json!({ "repo": did, - "collection": "app.bsky.feed.post", + "collection": "com.test.feed.post", "rkey": "noop-test", "record": record.clone() }); diff --git a/crates/tranquil-pds/tests/validation_edge_cases.rs b/crates/tranquil-pds/tests/validation_edge_cases.rs index 297328e..be9b61c 100644 --- a/crates/tranquil-pds/tests/validation_edge_cases.rs +++ b/crates/tranquil-pds/tests/validation_edge_cases.rs @@ -1,10 +1,9 @@ +use tranquil_lexicon::is_valid_did; use tranquil_pds::api::validation::{ HandleValidationError, MAX_DOMAIN_LABEL_LENGTH, MAX_EMAIL_LENGTH, MAX_LOCAL_PART_LENGTH, MAX_SERVICE_HANDLE_LOCAL_PART, is_valid_email, validate_short_handle, }; -use tranquil_pds::validation::{ - is_valid_did, validate_collection_nsid, validate_password, validate_record_key, -}; +use tranquil_pds::validation::{validate_collection_nsid, validate_password, validate_record_key}; #[test] fn test_record_key_boundary_min() { @@ -59,7 +58,6 @@ fn test_record_key_invalid_chars() { assert!(validate_record_key("a+b").is_err()); assert!(validate_record_key("a=b").is_err()); assert!(validate_record_key("a?b").is_err()); - assert!(validate_record_key("a:b").is_err()); assert!(validate_record_key("a;b").is_err()); assert!(validate_record_key("ab").is_err()); @@ -160,7 +158,7 @@ fn test_did_validation_method_case() { #[test] fn test_did_validation_method_chars() { - assert!(!is_valid_did("did:plc1:abc")); + assert!(is_valid_did("did:plc1:abc")); assert!(!is_valid_did("did:plc-x:abc")); assert!(!is_valid_did("did:plc_x:abc")); } diff --git a/scripts/test-infra.sh b/scripts/test-infra.sh index 3df93c1..bdee157 100755 --- a/scripts/test-infra.sh +++ b/scripts/test-infra.sh @@ -59,6 +59,7 @@ export TRANQUIL_PDS_TEST_INFRA_READY="1" export TRANQUIL_PDS_ALLOW_INSECURE_SECRETS="1" export SKIP_IMPORT_VERIFICATION="true" export DISABLE_RATE_LIMITING="1" +export TRANQUIL_LEXICON_OFFLINE="1" EOF echo "" echo "Infrastructure ready!"