this repo has no description
1use serde_json::Value; 2use thiserror::Error; 3 4#[derive(Debug, Error)] 5pub enum ValidationError { 6 #[error("No $type provided")] 7 MissingType, 8 #[error("Invalid $type: expected {expected}, got {actual}")] 9 TypeMismatch { expected: String, actual: String }, 10 #[error("Missing required field: {0}")] 11 MissingField(String), 12 #[error("Invalid field value at {path}: {message}")] 13 InvalidField { path: String, message: String }, 14 #[error("Invalid datetime format at {path}: must be RFC-3339/ISO-8601")] 15 InvalidDatetime { path: String }, 16 #[error("Invalid record: {0}")] 17 InvalidRecord(String), 18 #[error("Unknown record type: {0}")] 19 UnknownType(String), 20 #[error("Unacceptable slur in record at {path}")] 21 BannedContent { path: String }, 22} 23 24#[derive(Debug, Clone, Copy, PartialEq, Eq)] 25pub enum ValidationStatus { 26 Valid, 27 Unknown, 28 Invalid, 29} 30 31pub struct RecordValidator { 32 require_lexicon: bool, 33} 34 35impl Default for RecordValidator { 36 fn default() -> Self { 37 Self::new() 38 } 39} 40 41impl RecordValidator { 42 pub fn new() -> Self { 43 Self { 44 require_lexicon: false, 45 } 46 } 47 48 pub fn require_lexicon(mut self, require: bool) -> Self { 49 self.require_lexicon = require; 50 self 51 } 52 53 pub fn validate( 54 &self, 55 record: &Value, 56 collection: &str, 57 ) -> Result<ValidationStatus, ValidationError> { 58 self.validate_with_rkey(record, collection, None) 59 } 60 61 pub fn validate_with_rkey( 62 &self, 63 record: &Value, 64 collection: &str, 65 rkey: Option<&str>, 66 ) -> Result<ValidationStatus, ValidationError> { 67 let obj = record.as_object().ok_or_else(|| { 68 ValidationError::InvalidRecord("Record must be an object".to_string()) 69 })?; 70 let record_type = obj 71 .get("$type") 72 .and_then(|v| v.as_str()) 73 .ok_or(ValidationError::MissingType)?; 74 if record_type != collection { 75 return Err(ValidationError::TypeMismatch { 76 expected: collection.to_string(), 77 actual: record_type.to_string(), 78 }); 79 } 80 if let Some(created_at) = obj.get("createdAt").and_then(|v| v.as_str()) { 81 validate_datetime(created_at, "createdAt")?; 82 } 83 match record_type { 84 "app.bsky.feed.post" => self.validate_post(obj)?, 85 "app.bsky.actor.profile" => self.validate_profile(obj)?, 86 "app.bsky.feed.like" => self.validate_like(obj)?, 87 "app.bsky.feed.repost" => self.validate_repost(obj)?, 88 "app.bsky.graph.follow" => self.validate_follow(obj)?, 89 "app.bsky.graph.block" => self.validate_block(obj)?, 90 "app.bsky.graph.list" => self.validate_list(obj)?, 91 "app.bsky.graph.listitem" => self.validate_list_item(obj)?, 92 "app.bsky.feed.generator" => self.validate_feed_generator(obj, rkey)?, 93 "app.bsky.feed.threadgate" => self.validate_threadgate(obj)?, 94 "app.bsky.labeler.service" => self.validate_labeler_service(obj)?, 95 "app.bsky.graph.starterpack" => self.validate_starterpack(obj)?, 96 _ => { 97 if self.require_lexicon { 98 return Err(ValidationError::UnknownType(record_type.to_string())); 99 } 100 return Ok(ValidationStatus::Unknown); 101 } 102 } 103 Ok(ValidationStatus::Valid) 104 } 105 106 fn validate_post(&self, obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 107 if !obj.contains_key("text") { 108 return Err(ValidationError::MissingField("text".to_string())); 109 } 110 if !obj.contains_key("createdAt") { 111 return Err(ValidationError::MissingField("createdAt".to_string())); 112 } 113 if let Some(text) = obj.get("text").and_then(|v| v.as_str()) { 114 let grapheme_count = text.chars().count(); 115 if grapheme_count > 3000 { 116 return Err(ValidationError::InvalidField { 117 path: "text".to_string(), 118 message: format!( 119 "Text exceeds maximum length of 3000 characters (got {})", 120 grapheme_count 121 ), 122 }); 123 } 124 } 125 if let Some(langs) = obj.get("langs").and_then(|v| v.as_array()) 126 && langs.len() > 3 127 { 128 return Err(ValidationError::InvalidField { 129 path: "langs".to_string(), 130 message: "Maximum 3 languages allowed".to_string(), 131 }); 132 } 133 if let Some(tags) = obj.get("tags").and_then(|v| v.as_array()) { 134 if tags.len() > 8 { 135 return Err(ValidationError::InvalidField { 136 path: "tags".to_string(), 137 message: "Maximum 8 tags allowed".to_string(), 138 }); 139 } 140 for (i, tag) in tags.iter().enumerate() { 141 if let Some(tag_str) = tag.as_str() { 142 if tag_str.len() > 640 { 143 return Err(ValidationError::InvalidField { 144 path: format!("tags/{}", i), 145 message: "Tag exceeds maximum length of 640 bytes".to_string(), 146 }); 147 } 148 if crate::moderation::has_explicit_slur(tag_str) { 149 return Err(ValidationError::BannedContent { 150 path: format!("tags/{}", i), 151 }); 152 } 153 } 154 } 155 } 156 if let Some(facets) = obj.get("facets").and_then(|v| v.as_array()) { 157 for (i, facet) in facets.iter().enumerate() { 158 if let Some(features) = facet.get("features").and_then(|v| v.as_array()) { 159 for (j, feature) in features.iter().enumerate() { 160 let is_tag = feature 161 .get("$type") 162 .and_then(|v| v.as_str()) 163 .is_some_and(|t| t == "app.bsky.richtext.facet#tag"); 164 if is_tag { 165 if let Some(tag) = feature.get("tag").and_then(|v| v.as_str()) { 166 if crate::moderation::has_explicit_slur(tag) { 167 return Err(ValidationError::BannedContent { 168 path: format!("facets/{}/features/{}/tag", i, j), 169 }); 170 } 171 } 172 } 173 } 174 } 175 } 176 } 177 Ok(()) 178 } 179 180 fn validate_profile( 181 &self, 182 obj: &serde_json::Map<String, Value>, 183 ) -> Result<(), ValidationError> { 184 if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { 185 let grapheme_count = display_name.chars().count(); 186 if grapheme_count > 640 { 187 return Err(ValidationError::InvalidField { 188 path: "displayName".to_string(), 189 message: format!( 190 "Display name exceeds maximum length of 640 characters (got {})", 191 grapheme_count 192 ), 193 }); 194 } 195 if crate::moderation::has_explicit_slur(display_name) { 196 return Err(ValidationError::BannedContent { 197 path: "displayName".to_string(), 198 }); 199 } 200 } 201 if let Some(description) = obj.get("description").and_then(|v| v.as_str()) { 202 let grapheme_count = description.chars().count(); 203 if grapheme_count > 2560 { 204 return Err(ValidationError::InvalidField { 205 path: "description".to_string(), 206 message: format!( 207 "Description exceeds maximum length of 2560 characters (got {})", 208 grapheme_count 209 ), 210 }); 211 } 212 if crate::moderation::has_explicit_slur(description) { 213 return Err(ValidationError::BannedContent { 214 path: "description".to_string(), 215 }); 216 } 217 } 218 Ok(()) 219 } 220 221 fn validate_like(&self, obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 222 if !obj.contains_key("subject") { 223 return Err(ValidationError::MissingField("subject".to_string())); 224 } 225 if !obj.contains_key("createdAt") { 226 return Err(ValidationError::MissingField("createdAt".to_string())); 227 } 228 self.validate_strong_ref(obj.get("subject"), "subject")?; 229 Ok(()) 230 } 231 232 fn validate_repost(&self, obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 233 if !obj.contains_key("subject") { 234 return Err(ValidationError::MissingField("subject".to_string())); 235 } 236 if !obj.contains_key("createdAt") { 237 return Err(ValidationError::MissingField("createdAt".to_string())); 238 } 239 self.validate_strong_ref(obj.get("subject"), "subject")?; 240 Ok(()) 241 } 242 243 fn validate_follow(&self, obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 244 if !obj.contains_key("subject") { 245 return Err(ValidationError::MissingField("subject".to_string())); 246 } 247 if !obj.contains_key("createdAt") { 248 return Err(ValidationError::MissingField("createdAt".to_string())); 249 } 250 if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) 251 && !subject.starts_with("did:") 252 { 253 return Err(ValidationError::InvalidField { 254 path: "subject".to_string(), 255 message: "Subject must be a DID".to_string(), 256 }); 257 } 258 Ok(()) 259 } 260 261 fn validate_block(&self, obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 262 if !obj.contains_key("subject") { 263 return Err(ValidationError::MissingField("subject".to_string())); 264 } 265 if !obj.contains_key("createdAt") { 266 return Err(ValidationError::MissingField("createdAt".to_string())); 267 } 268 if let Some(subject) = obj.get("subject").and_then(|v| v.as_str()) 269 && !subject.starts_with("did:") 270 { 271 return Err(ValidationError::InvalidField { 272 path: "subject".to_string(), 273 message: "Subject must be a DID".to_string(), 274 }); 275 } 276 Ok(()) 277 } 278 279 fn validate_list(&self, obj: &serde_json::Map<String, Value>) -> Result<(), ValidationError> { 280 if !obj.contains_key("name") { 281 return Err(ValidationError::MissingField("name".to_string())); 282 } 283 if !obj.contains_key("purpose") { 284 return Err(ValidationError::MissingField("purpose".to_string())); 285 } 286 if !obj.contains_key("createdAt") { 287 return Err(ValidationError::MissingField("createdAt".to_string())); 288 } 289 if let Some(name) = obj.get("name").and_then(|v| v.as_str()) { 290 if name.is_empty() || name.len() > 64 { 291 return Err(ValidationError::InvalidField { 292 path: "name".to_string(), 293 message: "Name must be 1-64 characters".to_string(), 294 }); 295 } 296 if crate::moderation::has_explicit_slur(name) { 297 return Err(ValidationError::BannedContent { 298 path: "name".to_string(), 299 }); 300 } 301 } 302 Ok(()) 303 } 304 305 fn validate_list_item( 306 &self, 307 obj: &serde_json::Map<String, Value>, 308 ) -> Result<(), ValidationError> { 309 if !obj.contains_key("subject") { 310 return Err(ValidationError::MissingField("subject".to_string())); 311 } 312 if !obj.contains_key("list") { 313 return Err(ValidationError::MissingField("list".to_string())); 314 } 315 if !obj.contains_key("createdAt") { 316 return Err(ValidationError::MissingField("createdAt".to_string())); 317 } 318 Ok(()) 319 } 320 321 fn validate_feed_generator( 322 &self, 323 obj: &serde_json::Map<String, Value>, 324 rkey: Option<&str>, 325 ) -> Result<(), ValidationError> { 326 if !obj.contains_key("did") { 327 return Err(ValidationError::MissingField("did".to_string())); 328 } 329 if !obj.contains_key("displayName") { 330 return Err(ValidationError::MissingField("displayName".to_string())); 331 } 332 if !obj.contains_key("createdAt") { 333 return Err(ValidationError::MissingField("createdAt".to_string())); 334 } 335 if let Some(rkey) = rkey { 336 if crate::moderation::has_explicit_slur(rkey) { 337 return Err(ValidationError::BannedContent { 338 path: "rkey".to_string(), 339 }); 340 } 341 } 342 if let Some(display_name) = obj.get("displayName").and_then(|v| v.as_str()) { 343 if display_name.is_empty() || display_name.len() > 240 { 344 return Err(ValidationError::InvalidField { 345 path: "displayName".to_string(), 346 message: "displayName must be 1-240 characters".to_string(), 347 }); 348 } 349 if crate::moderation::has_explicit_slur(display_name) { 350 return Err(ValidationError::BannedContent { 351 path: "displayName".to_string(), 352 }); 353 } 354 } 355 Ok(()) 356 } 357 358 fn validate_starterpack( 359 &self, 360 obj: &serde_json::Map<String, Value>, 361 ) -> 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 }); 392 } 393 } 394 Ok(()) 395 } 396 397 fn validate_threadgate( 398 &self, 399 obj: &serde_json::Map<String, Value>, 400 ) -> Result<(), ValidationError> { 401 if !obj.contains_key("post") { 402 return Err(ValidationError::MissingField("post".to_string())); 403 } 404 if !obj.contains_key("createdAt") { 405 return Err(ValidationError::MissingField("createdAt".to_string())); 406 } 407 Ok(()) 408 } 409 410 fn validate_labeler_service( 411 &self, 412 obj: &serde_json::Map<String, Value>, 413 ) -> Result<(), ValidationError> { 414 if !obj.contains_key("policies") { 415 return Err(ValidationError::MissingField("policies".to_string())); 416 } 417 if !obj.contains_key("createdAt") { 418 return Err(ValidationError::MissingField("createdAt".to_string())); 419 } 420 Ok(()) 421 } 422 423 fn validate_strong_ref( 424 &self, 425 value: Option<&Value>, 426 path: &str, 427 ) -> Result<(), ValidationError> { 428 let obj = 429 value 430 .and_then(|v| v.as_object()) 431 .ok_or_else(|| ValidationError::InvalidField { 432 path: path.to_string(), 433 message: "Must be a strong reference object".to_string(), 434 })?; 435 if !obj.contains_key("uri") { 436 return Err(ValidationError::MissingField(format!("{}/uri", path))); 437 } 438 if !obj.contains_key("cid") { 439 return Err(ValidationError::MissingField(format!("{}/cid", path))); 440 } 441 if let Some(uri) = obj.get("uri").and_then(|v| v.as_str()) 442 && !uri.starts_with("at://") 443 { 444 return Err(ValidationError::InvalidField { 445 path: format!("{}/uri", path), 446 message: "URI must be an at:// URI".to_string(), 447 }); 448 } 449 Ok(()) 450 } 451} 452 453fn validate_datetime(value: &str, path: &str) -> Result<(), ValidationError> { 454 if chrono::DateTime::parse_from_rfc3339(value).is_err() { 455 return Err(ValidationError::InvalidDatetime { 456 path: path.to_string(), 457 }); 458 } 459 Ok(()) 460} 461 462pub fn validate_record_key(rkey: &str) -> Result<(), ValidationError> { 463 if rkey.is_empty() { 464 return Err(ValidationError::InvalidRecord( 465 "Record key cannot be empty".to_string(), 466 )); 467 } 468 if rkey.len() > 512 { 469 return Err(ValidationError::InvalidRecord( 470 "Record key exceeds maximum length of 512".to_string(), 471 )); 472 } 473 if rkey == "." || rkey == ".." { 474 return Err(ValidationError::InvalidRecord( 475 "Record key cannot be '.' or '..'".to_string(), 476 )); 477 } 478 let valid_chars = rkey 479 .chars() 480 .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' || c == '~'); 481 if !valid_chars { 482 return Err(ValidationError::InvalidRecord( 483 "Record key contains invalid characters (must be alphanumeric, '.', '-', '_', or '~')" 484 .to_string(), 485 )); 486 } 487 Ok(()) 488} 489 490pub fn is_valid_did(did: &str) -> bool { 491 if !did.starts_with("did:") { 492 return false; 493 } 494 let parts: Vec<&str> = did.splitn(3, ':').collect(); 495 if parts.len() < 3 { 496 return false; 497 } 498 let method = parts[1]; 499 if method.is_empty() || !method.chars().all(|c| c.is_ascii_lowercase()) { 500 return false; 501 } 502 let id = parts[2]; 503 !id.is_empty() 504} 505 506pub fn validate_did(did: &str) -> Result<(), ValidationError> { 507 if !is_valid_did(did) { 508 return Err(ValidationError::InvalidField { 509 path: "did".to_string(), 510 message: "Invalid DID format".to_string(), 511 }); 512 } 513 Ok(()) 514} 515 516pub fn validate_collection_nsid(collection: &str) -> Result<(), ValidationError> { 517 if collection.is_empty() { 518 return Err(ValidationError::InvalidRecord( 519 "Collection NSID cannot be empty".to_string(), 520 )); 521 } 522 let parts: Vec<&str> = collection.split('.').collect(); 523 if parts.len() < 3 { 524 return Err(ValidationError::InvalidRecord( 525 "Collection NSID must have at least 3 segments".to_string(), 526 )); 527 } 528 for part in &parts { 529 if part.is_empty() { 530 return Err(ValidationError::InvalidRecord( 531 "Collection NSID segments cannot be empty".to_string(), 532 )); 533 } 534 if !part.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { 535 return Err(ValidationError::InvalidRecord( 536 "Collection NSID segments must be alphanumeric or hyphens".to_string(), 537 )); 538 } 539 } 540 Ok(()) 541} 542 543#[derive(Debug)] 544pub struct PasswordValidationError { 545 pub errors: Vec<String>, 546} 547 548impl std::fmt::Display for PasswordValidationError { 549 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 550 write!(f, "{}", self.errors.join("; ")) 551 } 552} 553 554impl std::error::Error for PasswordValidationError {} 555 556pub fn validate_password(password: &str) -> Result<(), PasswordValidationError> { 557 let mut errors = Vec::new(); 558 559 if password.len() < 8 { 560 errors.push("Password must be at least 8 characters".to_string()); 561 } 562 563 if password.len() > 256 { 564 errors.push("Password must be at most 256 characters".to_string()); 565 } 566 567 if !password.chars().any(|c| c.is_ascii_lowercase()) { 568 errors.push("Password must contain at least one lowercase letter".to_string()); 569 } 570 571 if !password.chars().any(|c| c.is_ascii_uppercase()) { 572 errors.push("Password must contain at least one uppercase letter".to_string()); 573 } 574 575 if !password.chars().any(|c| c.is_ascii_digit()) { 576 errors.push("Password must contain at least one number".to_string()); 577 } 578 579 if is_common_password(password) { 580 errors.push("Password is too common, please choose a different one".to_string()); 581 } 582 583 if errors.is_empty() { 584 Ok(()) 585 } else { 586 Err(PasswordValidationError { errors }) 587 } 588} 589 590fn is_common_password(password: &str) -> bool { 591 const COMMON_PASSWORDS: &[&str] = &[ 592 "password", 593 "Password1", 594 "Password123", 595 "Passw0rd", 596 "Passw0rd!", 597 "12345678", 598 "123456789", 599 "1234567890", 600 "qwerty123", 601 "Qwerty123", 602 "qwertyui", 603 "Qwertyui", 604 "letmein1", 605 "Letmein1", 606 "welcome1", 607 "Welcome1", 608 "admin123", 609 "Admin123", 610 "password1", 611 "Password1!", 612 "iloveyou", 613 "Iloveyou1", 614 "monkey123", 615 "Monkey123", 616 "dragon12", 617 "Dragon123", 618 "master12", 619 "Master123", 620 "login123", 621 "Login123", 622 "abc12345", 623 "Abc12345", 624 "football", 625 "Football1", 626 "baseball", 627 "Baseball1", 628 "trustno1", 629 "Trustno1", 630 "sunshine", 631 "Sunshine1", 632 "princess", 633 "Princess1", 634 "computer", 635 "Computer1", 636 "whatever", 637 "Whatever1", 638 "nintendo", 639 "Nintendo1", 640 "bluesky1", 641 "Bluesky1", 642 "Bluesky123", 643 ]; 644 645 let lower = password.to_lowercase(); 646 COMMON_PASSWORDS.iter().any(|p| p.to_lowercase() == lower) 647} 648 649#[cfg(test)] 650mod tests { 651 use super::*; 652 use serde_json::json; 653 654 #[test] 655 fn test_validate_post() { 656 let validator = RecordValidator::new(); 657 let valid_post = json!({ 658 "$type": "app.bsky.feed.post", 659 "text": "Hello, world!", 660 "createdAt": "2024-01-01T00:00:00.000Z" 661 }); 662 assert_eq!( 663 validator 664 .validate(&valid_post, "app.bsky.feed.post") 665 .unwrap(), 666 ValidationStatus::Valid 667 ); 668 } 669 670 #[test] 671 fn test_validate_post_missing_text() { 672 let validator = RecordValidator::new(); 673 let invalid_post = json!({ 674 "$type": "app.bsky.feed.post", 675 "createdAt": "2024-01-01T00:00:00.000Z" 676 }); 677 assert!( 678 validator 679 .validate(&invalid_post, "app.bsky.feed.post") 680 .is_err() 681 ); 682 } 683 684 #[test] 685 fn test_validate_type_mismatch() { 686 let validator = RecordValidator::new(); 687 let record = json!({ 688 "$type": "app.bsky.feed.like", 689 "subject": {"uri": "at://did:plc:test/app.bsky.feed.post/123", "cid": "bafyrei..."}, 690 "createdAt": "2024-01-01T00:00:00.000Z" 691 }); 692 let result = validator.validate(&record, "app.bsky.feed.post"); 693 assert!(matches!(result, Err(ValidationError::TypeMismatch { .. }))); 694 } 695 696 #[test] 697 fn test_validate_unknown_type() { 698 let validator = RecordValidator::new(); 699 let record = json!({ 700 "$type": "com.example.custom", 701 "data": "test" 702 }); 703 assert_eq!( 704 validator.validate(&record, "com.example.custom").unwrap(), 705 ValidationStatus::Unknown 706 ); 707 } 708 709 #[test] 710 fn test_validate_unknown_type_strict() { 711 let validator = RecordValidator::new().require_lexicon(true); 712 let record = json!({ 713 "$type": "com.example.custom", 714 "data": "test" 715 }); 716 let result = validator.validate(&record, "com.example.custom"); 717 assert!(matches!(result, Err(ValidationError::UnknownType(_)))); 718 } 719 720 #[test] 721 fn test_validate_record_key() { 722 assert!(validate_record_key("valid-key_123").is_ok()); 723 assert!(validate_record_key("3k2n5j2").is_ok()); 724 assert!(validate_record_key(".").is_err()); 725 assert!(validate_record_key("..").is_err()); 726 assert!(validate_record_key("").is_err()); 727 assert!(validate_record_key("invalid/key").is_err()); 728 } 729 730 #[test] 731 fn test_validate_collection_nsid() { 732 assert!(validate_collection_nsid("app.bsky.feed.post").is_ok()); 733 assert!(validate_collection_nsid("com.atproto.repo.record").is_ok()); 734 assert!(validate_collection_nsid("invalid").is_err()); 735 assert!(validate_collection_nsid("a.b").is_err()); 736 assert!(validate_collection_nsid("").is_err()); 737 } 738 739 #[test] 740 fn test_is_valid_did() { 741 assert!(is_valid_did("did:plc:1234567890abcdefghijk")); 742 assert!(is_valid_did("did:web:example.com")); 743 assert!(is_valid_did( 744 "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" 745 )); 746 assert!(!is_valid_did("")); 747 assert!(!is_valid_did("plc:1234567890abcdefghijk")); 748 assert!(!is_valid_did("did:")); 749 assert!(!is_valid_did("did:plc:")); 750 assert!(!is_valid_did("did::something")); 751 assert!(!is_valid_did("DID:plc:test")); 752 } 753}