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