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