View Javadoc
1   package davmail.exchange;
2   import java.util.regex.Pattern;
3   import org.apache.log4j.Logger;
4   
5   /**
6    * Validator for iCalendar data according to RFC 5545 specifications.
7    * This implementation provides comprehensive validation and repair capabilities for iCalendar content,
8    * specifically focusing on character validation rather than XML structure.
9    * <p>
10   * This helpful tool addresses synchronization issues between different calendar clients
11   * (OWA, Outlook, and Thunderbird via DavMail) where calendar entries containing invalid
12   * characters are handled differently across platforms. These problematic entries originate
13   * from the MS Exchange Server, where they were either stored with invalid characters or
14   * became corrupted during storage. While OWA and Outlook silently hide such entries without
15   * displaying them to users, Thunderbird logs XML parse errors in its error console. The
16   * validator provides detailed validation information about invalid characters and offers
17   * repair functionality to automatically remove problematic characters while preserving
18   * valid content.
19   * <p>
20   * The implementation was developed to address a specific issue where calendar entries
21   * containing invalid string content are hidden in OWA and Outlook, making them inaccessible
22   * for manual deletion or repair. Since these entries are not visible in OWA and Outlook,
23   * users cannot remove or fix them before synchronization to Thunderbird, where they cause
24   * XML parsing errors. The solution provides a way to detect and repair these problematic
25   * entries, which could otherwise be handled by DavMail during the synchronization process.
26   * The issue is documented in Bugzilla at <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=1941840">...</a>.
27   *
28   * @author ifrh (<a href="https://github.com/ifrh">GitHub</a>)
29   * @author ifrh (<a href="https://sourceforge.net/u/ifrh/profile/">SourceForge</a>)
30   * @since 2025-02-05 // yyyy-mm-dd
31   */
32  
33  public class ICSCalendarValidator {
34      protected static final Logger LOGGER = Logger.getLogger(ICSCalendarValidator.class);
35      // Optimized pattern for validation, tab, CR and LF are allowed in icalendar content
36      // Excludes C1 control characters (0x80-0x9F), consistent with isValidChar() and validateWithDetails()
37      private static final Pattern VALID_CHARS_PATTERN =
38              Pattern.compile("^[\r\n\t\\x20-\\x7E\u00A0-\uFFFF]*$");
39  
40      // Constants for better readability
41      private static final char NULL_BYTE = '\u0000';
42      private static final char DELETE = '\u007F';
43  
44      /**
45       * Validates whether a string contains only valid characters for iCalendar content.
46       * Ensures the string contains no null bytes and only printable ASCII characters,
47       * while allowing properly encoded Unicode characters.
48       * @param content The string to validate
49       * @return true if all characters are valid
50       */
51      public static boolean isValidICSContent(String content) {
52          return content != null && VALID_CHARS_PATTERN.matcher(content).matches();
53      }
54  
55      /**
56       * Returns detailed validation information about the content.
57       * @param content The string to validate
58       * @return ValidationResult object containing details
59       */
60      public static ValidationResult validateWithDetails(String content) {
61          if (content == null) {
62              return new ValidationResult(false, "Content is null");
63          }
64  
65          // Efficient validation checking all conditions in one pass
66          StringBuilder issues = new StringBuilder();
67          int nullByteCount = 0;
68          StringBuilder invalidChars = new StringBuilder();
69  
70          for (char c : content.toCharArray()) {
71              if (c == NULL_BYTE) {
72                  nullByteCount++;
73              } else if (((c < 32 && c!='\r' && c!='\n' && c!='\t') || c == DELETE || (c >= 128 && c <= 159))) {
74                  invalidChars.append(String.format("\\u%04x,", (int)c));
75              }
76          }
77  
78          // Collect all found problems
79          if (nullByteCount > 0) {
80              issues.append(nullByteCount).append(" null byte(s) found");
81          }
82          if (invalidChars.length() > 0) {
83              if (issues.length() > 0) issues.append(", ");
84              issues.append("Invalid character(s): ").append(
85                      invalidChars, 0, invalidChars.length() - 1);
86          }
87  
88          return new ValidationResult(issues.length() == 0, issues.toString());
89      }
90  
91      /**
92       * Repairs an iCalendar string by removing invalid characters.
93       * Invalid characters are dropped rather than replaced to avoid
94       * introducing unexpected spaces in ICS property values.
95       * @param content The string to repair
96       * @return The repaired string with invalid characters removed
97       */
98      public static String repairICSContent(String content) {
99          if (content == null) return null;
100         String message = "ICSCalendarValidator repair characters in ICS content:";
101 
102         StringBuilder repaired = new StringBuilder();
103 
104         for (char c : content.toCharArray()) {
105             if (isValidChar(c)) {
106                 repaired.append(c);
107             }
108         }
109         String fixed = repaired.toString();
110         // just put output to debug logger, only if some invalid characters have been removed.
111         if (!content.equals(fixed)) {
112             // strip invalid characters from debug message
113             if (LOGGER.isDebugEnabled()) {
114                 StringBuilder escapedContent = new StringBuilder();
115                 for (char c : content.toCharArray()) {
116                     if (!isValidChar(c)) {
117                         escapedContent.append(String.format("\\u%04x", (int) c));
118                     } else {
119                         escapedContent.append(c);
120                     }
121                 }
122 
123                 LOGGER.debug(message + "\n[" + escapedContent.toString() + "]\n => [" + fixed + "]\n fix complete.");
124             }
125         }
126         return fixed;
127     }
128 
129     /**
130      * Checks if a single character is valid for iCalendar content.
131      * A character is valid if it is:
132      * - A tab (0x09), carriage return (0x0D), or line feed (0x0A)
133      * - A printable ASCII character (0x20-0x7E)
134      * - A Unicode character above U+007F (0x80-0xFFFF), excluding C1 control range (0x80-0x9F)
135      * This is consistent with VALID_CHARS_PATTERN.
136      * @param c The character to check
137      * @return true if the character is valid
138      */
139     static boolean isValidChar(char c) {
140         // Allow TAB, CR, LF
141         if (c == '\t' || c == '\r' || c == '\n') {
142             return true;
143         }
144         // Printable ASCII range (space through tilde)
145         if (c >= 0x20 && c <= 0x7E) {
146             return true;
147         }
148         // Valid Unicode above ASCII, excluding C1 control characters (0x80-0x9F)
149         return c >= 0xA0;
150     }
151 
152     /**
153      * Result structure for validation results.
154      */
155     public static class ValidationResult {
156         private final boolean isValid;
157         private final String reason;
158 
159         public ValidationResult(boolean isValid, String reason) {
160             this.isValid = isValid;
161             this.reason = reason;
162         }
163 
164         public boolean isValid() { return isValid; }
165         public String showReason() { return reason; }
166     }
167 }