Skip to main content

communitas_core/
validation.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2//
3// This file is part of the Saorsa P2P network.
4//
5// Licensed under the AGPL-3.0 license:
6// <https://www.gnu.org/licenses/agpl-3.0.html>
7//
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU Affero General Public License for more details.
12//
13// You should have received a copy of the GNU Affero General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16//! Input validation service for Communitas
17//!
18//! Centralizes all input validation logic that was previously scattered
19//! across UI components. This ensures consistent validation rules and
20//! keeps business logic out of the presentation layer.
21use std::collections::HashSet;
22
23/// Input type enumeration for validation rules
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum InputType {
26    /// Entity names (channels, groups, projects, etc.)
27    EntityName,
28    /// Display names for users/identities
29    DisplayName,
30    /// Message content
31    Message,
32    /// Thread reply content
33    ThreadReply,
34    /// Password input
35    Password,
36}
37
38/// Validation result type
39pub type ValidationResult<T> = Result<T, ValidationError>;
40
41/// Validation error with user-friendly messages
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct ValidationError {
44    pub field: String,
45    pub message: String,
46    pub code: ValidationErrorCode,
47}
48
49impl std::error::Error for ValidationError {}
50
51impl std::fmt::Display for ValidationError {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        write!(f, "{}: {}", self.field, self.message)
54    }
55}
56
57/// Validation error codes for programmatic handling
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum ValidationErrorCode {
60    Required,
61    TooShort,
62    TooLong,
63    InvalidFormat,
64    ReservedWord,
65    ContainsInvalidChars,
66    Empty,
67    OnlyWhitespace,
68}
69
70/// Main validation service
71pub struct ValidationService {
72    reserved_words: HashSet<String>,
73}
74
75impl Default for ValidationService {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl ValidationService {
82    /// Create a new validation service with default reserved words
83    pub fn new() -> Self {
84        let mut reserved_words = HashSet::new();
85        // Add common reserved words that shouldn't be used as entity names
86        for word in [
87            "admin",
88            "system",
89            "root",
90            "null",
91            "undefined",
92            "none",
93            "default",
94            "public",
95            "private",
96            "internal",
97            "external",
98            "test",
99            "testing",
100            "example",
101            "sample",
102            "demo",
103            "temp",
104            "temporary",
105        ] {
106            reserved_words.insert(word.to_string());
107        }
108
109        Self { reserved_words }
110    }
111
112    /// Validate input based on type
113    pub fn validate(&self, input: &str, input_type: InputType) -> ValidationResult<()> {
114        match input_type {
115            InputType::EntityName => self.validate_entity_name(input),
116            InputType::DisplayName => self.validate_display_name(input),
117            InputType::Message => self.validate_message(input),
118            InputType::ThreadReply => self.validate_thread_reply(input),
119            InputType::Password => self.validate_password(input),
120        }
121    }
122
123    /// Validate entity name (channel, group, project names)
124    pub fn validate_entity_name(&self, name: &str) -> ValidationResult<()> {
125        // Check for empty or whitespace-only
126        if name.trim().is_empty() {
127            return Err(ValidationError {
128                field: "entity_name".to_string(),
129                message: "Entity name cannot be empty".to_string(),
130                code: ValidationErrorCode::Required,
131            });
132        }
133
134        // Check minimum length (after trimming)
135        let trimmed = name.trim();
136        if trimmed.len() < 2 {
137            return Err(ValidationError {
138                field: "entity_name".to_string(),
139                message: "Entity name must be at least 2 characters long".to_string(),
140                code: ValidationErrorCode::TooShort,
141            });
142        }
143
144        // Check maximum length
145        if trimmed.len() > 50 {
146            return Err(ValidationError {
147                field: "entity_name".to_string(),
148                message: "Entity name cannot exceed 50 characters".to_string(),
149                code: ValidationErrorCode::TooLong,
150            });
151        }
152
153        // Check for reserved words (case-insensitive)
154        let lower_trimmed = trimmed.to_lowercase();
155        if self.reserved_words.contains(&lower_trimmed) {
156            return Err(ValidationError {
157                field: "entity_name".to_string(),
158                message: format!("'{}' is a reserved word and cannot be used", trimmed),
159                code: ValidationErrorCode::ReservedWord,
160            });
161        }
162
163        // Check for invalid characters (only allow alphanumeric, spaces, hyphens, underscores)
164        if !trimmed
165            .chars()
166            .all(|c| c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '_')
167        {
168            return Err(ValidationError {
169                field: "entity_name".to_string(),
170                message: "Entity name can only contain letters, numbers, spaces, hyphens, and underscores".to_string(),
171                code: ValidationErrorCode::ContainsInvalidChars,
172            });
173        }
174
175        // Check for consecutive spaces or special characters
176        if trimmed.contains("  ") || trimmed.contains("--") || trimmed.contains("__") {
177            return Err(ValidationError {
178                field: "entity_name".to_string(),
179                message: "Entity name cannot contain consecutive spaces or special characters"
180                    .to_string(),
181                code: ValidationErrorCode::InvalidFormat,
182            });
183        }
184
185        Ok(())
186    }
187
188    /// Validate display name
189    pub fn validate_display_name(&self, name: &str) -> ValidationResult<()> {
190        let trimmed = name.trim();
191
192        // Check for empty or whitespace-only
193        if trimmed.is_empty() {
194            return Err(ValidationError {
195                field: "display_name".to_string(),
196                message: "Display name cannot be empty".to_string(),
197                code: ValidationErrorCode::Required,
198            });
199        }
200
201        // Check maximum length
202        if trimmed.len() > 100 {
203            return Err(ValidationError {
204                field: "display_name".to_string(),
205                message: "Display name cannot exceed 100 characters".to_string(),
206                code: ValidationErrorCode::TooLong,
207            });
208        }
209
210        // Allow more characters for display names (including emojis, special chars)
211        // Just check for control characters
212        if trimmed.chars().any(|c| c.is_control()) {
213            return Err(ValidationError {
214                field: "display_name".to_string(),
215                message: "Display name cannot contain control characters".to_string(),
216                code: ValidationErrorCode::ContainsInvalidChars,
217            });
218        }
219
220        Ok(())
221    }
222
223    /// Validate message content
224    pub fn validate_message(&self, message: &str) -> ValidationResult<()> {
225        // Allow empty messages for drafts, but not whitespace-only if non-empty
226        let trimmed = message.trim();
227
228        if !message.is_empty() && trimmed.is_empty() {
229            return Err(ValidationError {
230                field: "message".to_string(),
231                message: "Message cannot be only whitespace".to_string(),
232                code: ValidationErrorCode::OnlyWhitespace,
233            });
234        }
235
236        // Check maximum length (reasonable limit for messages)
237        if message.len() > 10000 {
238            return Err(ValidationError {
239                field: "message".to_string(),
240                message: "Message cannot exceed 10,000 characters".to_string(),
241                code: ValidationErrorCode::TooLong,
242            });
243        }
244
245        // Allow all characters in messages (including newlines, emojis, etc.)
246        // Just check for null bytes or other problematic characters
247        if message.chars().any(|c| c == '\0') {
248            return Err(ValidationError {
249                field: "message".to_string(),
250                message: "Message cannot contain null characters".to_string(),
251                code: ValidationErrorCode::ContainsInvalidChars,
252            });
253        }
254
255        Ok(())
256    }
257
258    /// Validate thread reply content (same rules as messages)
259    pub fn validate_thread_reply(&self, reply: &str) -> ValidationResult<()> {
260        self.validate_message(reply)
261    }
262
263    /// Validate password
264    pub fn validate_password(&self, password: &str) -> ValidationResult<()> {
265        // Check minimum length
266        if password.len() < 8 {
267            return Err(ValidationError {
268                field: "password".to_string(),
269                message: "Password must be at least 8 characters long".to_string(),
270                code: ValidationErrorCode::TooShort,
271            });
272        }
273
274        // Check maximum length (reasonable limit for security)
275        if password.len() > 128 {
276            return Err(ValidationError {
277                field: "password".to_string(),
278                message: "Password cannot exceed 128 characters".to_string(),
279                code: ValidationErrorCode::TooLong,
280            });
281        }
282
283        // Check for control characters
284        if password.chars().any(|c| c.is_control()) {
285            return Err(ValidationError {
286                field: "password".to_string(),
287                message: "Password cannot contain control characters".to_string(),
288                code: ValidationErrorCode::ContainsInvalidChars,
289            });
290        }
291
292        Ok(())
293    }
294
295    /// Sanitize input by trimming whitespace
296    pub fn sanitize(&self, input: &str, input_type: InputType) -> String {
297        match input_type {
298            InputType::EntityName | InputType::DisplayName => input.trim().to_string(),
299            InputType::Message | InputType::ThreadReply | InputType::Password => input.to_string(),
300        }
301    }
302
303    /// Validate and sanitize input in one step
304    pub fn validate_and_sanitize(
305        &self,
306        input: &str,
307        input_type: InputType,
308    ) -> ValidationResult<String> {
309        let sanitized = self.sanitize(input, input_type);
310        self.validate(&sanitized, input_type)?;
311        Ok(sanitized)
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    fn create_validator() -> ValidationService {
320        ValidationService::new()
321    }
322
323    mod entity_name_validation {
324        use super::*;
325
326        #[test]
327        fn test_valid_entity_names() {
328            let validator = create_validator();
329
330            // Valid names should pass
331            assert!(validator.validate_entity_name("My Channel").is_ok());
332            assert!(validator.validate_entity_name("project-alpha").is_ok());
333            assert!(validator.validate_entity_name("Group_123").is_ok());
334            assert!(validator.validate_entity_name("AB").is_ok()); // Minimum 2 chars after trim
335            assert!(
336                validator
337                    .validate_entity_name("Valid Name With Spaces")
338                    .is_ok()
339            );
340        }
341
342        #[test]
343        fn test_empty_entity_names() {
344            let validator = create_validator();
345
346            let result = validator.validate_entity_name("");
347            assert!(result.is_err());
348            assert_eq!(result.unwrap_err().code, ValidationErrorCode::Required);
349
350            let result = validator.validate_entity_name("   ");
351            assert!(result.is_err());
352            assert_eq!(result.unwrap_err().code, ValidationErrorCode::Required);
353        }
354
355        #[test]
356        fn test_too_short_entity_names() {
357            let validator = create_validator();
358
359            let result = validator.validate_entity_name("A");
360            assert!(result.is_err());
361            assert_eq!(result.unwrap_err().code, ValidationErrorCode::TooShort);
362        }
363
364        #[test]
365        fn test_too_long_entity_names() {
366            let validator = create_validator();
367
368            let long_name = "A".repeat(51);
369            let result = validator.validate_entity_name(&long_name);
370            assert!(result.is_err());
371            assert_eq!(result.unwrap_err().code, ValidationErrorCode::TooLong);
372        }
373
374        #[test]
375        fn test_reserved_words() {
376            let validator = create_validator();
377
378            let result = validator.validate_entity_name("admin");
379            assert!(result.is_err());
380            assert_eq!(result.unwrap_err().code, ValidationErrorCode::ReservedWord);
381
382            let result = validator.validate_entity_name("ADMIN"); // case insensitive
383            assert!(result.is_err());
384            assert_eq!(result.unwrap_err().code, ValidationErrorCode::ReservedWord);
385        }
386
387        #[test]
388        fn test_invalid_characters() {
389            let validator = create_validator();
390
391            let result = validator.validate_entity_name("Channel@Name");
392            assert!(result.is_err());
393            assert_eq!(
394                result.unwrap_err().code,
395                ValidationErrorCode::ContainsInvalidChars
396            );
397
398            let result = validator.validate_entity_name("Channel.Name");
399            assert!(result.is_err());
400            assert_eq!(
401                result.unwrap_err().code,
402                ValidationErrorCode::ContainsInvalidChars
403            );
404        }
405
406        #[test]
407        fn test_consecutive_special_chars() {
408            let validator = create_validator();
409
410            let result = validator.validate_entity_name("Channel--Name");
411            assert!(result.is_err());
412            assert_eq!(result.unwrap_err().code, ValidationErrorCode::InvalidFormat);
413
414            let result = validator.validate_entity_name("Channel  Name");
415            assert!(result.is_err());
416            assert_eq!(result.unwrap_err().code, ValidationErrorCode::InvalidFormat);
417        }
418    }
419
420    mod display_name_validation {
421        use super::*;
422
423        #[test]
424        fn test_valid_display_names() {
425            let validator = create_validator();
426
427            assert!(validator.validate_display_name("John Doe").is_ok());
428            assert!(validator.validate_display_name("Alice").is_ok());
429            assert!(validator.validate_display_name("用户123").is_ok()); // Unicode
430            assert!(
431                validator
432                    .validate_display_name("Name with émojis 🎉")
433                    .is_ok()
434            );
435        }
436
437        #[test]
438        fn test_empty_display_names() {
439            let validator = create_validator();
440
441            let result = validator.validate_display_name("");
442            assert!(result.is_err());
443            assert_eq!(result.unwrap_err().code, ValidationErrorCode::Required);
444        }
445
446        #[test]
447        fn test_too_long_display_names() {
448            let validator = create_validator();
449
450            let long_name = "A".repeat(101);
451            let result = validator.validate_display_name(&long_name);
452            assert!(result.is_err());
453            assert_eq!(result.unwrap_err().code, ValidationErrorCode::TooLong);
454        }
455
456        #[test]
457        fn test_control_characters_in_display_names() {
458            let validator = create_validator();
459
460            let result = validator.validate_display_name("Name\nwith\nnewlines");
461            assert!(result.is_err());
462            assert_eq!(
463                result.unwrap_err().code,
464                ValidationErrorCode::ContainsInvalidChars
465            );
466        }
467    }
468
469    mod message_validation {
470        use super::*;
471
472        #[test]
473        fn test_valid_messages() {
474            let validator = create_validator();
475
476            assert!(validator.validate_message("").is_ok()); // Empty messages allowed
477            assert!(validator.validate_message("Hello world!").is_ok());
478            assert!(validator.validate_message("Multi\nline\nmessage").is_ok());
479            assert!(validator.validate_message("Message with émojis 🎉").is_ok());
480        }
481
482        #[test]
483        fn test_whitespace_only_messages() {
484            let validator = create_validator();
485
486            let result = validator.validate_message("   ");
487            assert!(result.is_err());
488            assert_eq!(
489                result.unwrap_err().code,
490                ValidationErrorCode::OnlyWhitespace
491            );
492
493            let result = validator.validate_message("\t\n  \t");
494            assert!(result.is_err());
495            assert_eq!(
496                result.unwrap_err().code,
497                ValidationErrorCode::OnlyWhitespace
498            );
499        }
500
501        #[test]
502        fn test_too_long_messages() {
503            let validator = create_validator();
504
505            let long_message = "A".repeat(10001);
506            let result = validator.validate_message(&long_message);
507            assert!(result.is_err());
508            assert_eq!(result.unwrap_err().code, ValidationErrorCode::TooLong);
509        }
510
511        #[test]
512        fn test_null_characters_in_messages() {
513            let validator = create_validator();
514
515            let result = validator.validate_message("Message with \0 null");
516            assert!(result.is_err());
517            assert_eq!(
518                result.unwrap_err().code,
519                ValidationErrorCode::ContainsInvalidChars
520            );
521        }
522    }
523
524    mod password_validation {
525        use super::*;
526
527        #[test]
528        fn test_valid_passwords() {
529            let validator = create_validator();
530
531            assert!(validator.validate_password("password123").is_ok());
532            assert!(validator.validate_password("A".repeat(8).as_str()).is_ok());
533            assert!(validator.validate_password("Complex!@#$%^&*()").is_ok());
534        }
535
536        #[test]
537        fn test_too_short_passwords() {
538            let validator = create_validator();
539
540            let result = validator.validate_password("short");
541            assert!(result.is_err());
542            assert_eq!(result.unwrap_err().code, ValidationErrorCode::TooShort);
543        }
544
545        #[test]
546        fn test_too_long_passwords() {
547            let validator = create_validator();
548
549            let long_password = "A".repeat(129);
550            let result = validator.validate_password(&long_password);
551            assert!(result.is_err());
552            assert_eq!(result.unwrap_err().code, ValidationErrorCode::TooLong);
553        }
554
555        #[test]
556        fn test_control_characters_in_passwords() {
557            let validator = create_validator();
558
559            let result = validator.validate_password("password\n123");
560            assert!(result.is_err());
561            assert_eq!(
562                result.unwrap_err().code,
563                ValidationErrorCode::ContainsInvalidChars
564            );
565        }
566    }
567
568    mod sanitization {
569        use super::*;
570
571        #[test]
572        fn test_entity_name_sanitization() {
573            let validator = create_validator();
574
575            assert_eq!(
576                validator.sanitize("  My Channel  ", InputType::EntityName),
577                "My Channel"
578            );
579            assert_eq!(
580                validator.sanitize("project", InputType::EntityName),
581                "project"
582            );
583        }
584
585        #[test]
586        fn test_message_sanitization() {
587            let validator = create_validator();
588
589            // Messages preserve whitespace
590            assert_eq!(
591                validator.sanitize("  Hello  ", InputType::Message),
592                "  Hello  "
593            );
594            assert_eq!(
595                validator.sanitize("Multi\nline", InputType::Message),
596                "Multi\nline"
597            );
598        }
599
600        #[test]
601        fn test_validate_and_sanitize() {
602            let validator = create_validator();
603
604            let result = validator.validate_and_sanitize("  My Channel  ", InputType::EntityName);
605            assert!(result.is_ok());
606            assert_eq!(result.unwrap(), "My Channel");
607
608            let result = validator.validate_and_sanitize("", InputType::EntityName);
609            assert!(result.is_err());
610        }
611    }
612}