1use regex::Regex;
24use std::sync::LazyLock;
25use thiserror::Error;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RichTextContext {
33 TaskNotes,
36 StoryText,
38 ProjectBrief,
41}
42
43#[derive(Debug, Error, PartialEq, Eq)]
45pub enum RichTextError {
46 #[error("invalid rich text XML: {0}")]
48 InvalidXml(String),
49 #[error("root element must be <body>, found <{0}>")]
51 RootMustBeBody(String),
52 #[error("unsupported tag <{tag}> for {context:?}")]
54 UnsupportedTag {
55 tag: String,
57 context: RichTextContext,
59 },
60 #[error("attribute `{attr}` is not allowed on <{tag}>")]
62 DisallowedAttribute {
63 tag: String,
65 attr: String,
67 },
68 #[error("invalid data-asana-type value: {0}")]
70 InvalidDataAsanaType(String),
71 #[error("invalid nesting: <{child}> cannot appear inside <{parent}>")]
73 InvalidNesting {
74 parent: String,
76 child: String,
78 },
79}
80
81pub fn prepare_rich_text(input: &str, context: RichTextContext) -> Result<String, RichTextError> {
88 let wrapped = ensure_body_wrap(input);
89 let escaped = escape_bare_ampersands(&wrapped);
90 validate(&escaped, context)?;
91 Ok(escaped)
92}
93
94static XML_ENTITY: LazyLock<Regex> = LazyLock::new(|| {
96 Regex::new(r"^(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);")
97 .expect("the regex literal is valid at compile time")
98});
99
100fn ensure_body_wrap(input: &str) -> String {
102 let trimmed = input.trim();
103 if trimmed.starts_with("<body>") && trimmed.ends_with("</body>") {
104 return trimmed.to_string();
105 }
106 format!("<body>{trimmed}</body>")
107}
108
109fn escape_bare_ampersands(input: &str) -> String {
111 let mut escaped = String::with_capacity(input.len());
112 let mut iter = input.char_indices();
113
114 while let Some((index, ch)) = iter.next() {
115 if ch != '&' {
116 escaped.push(ch);
117 continue;
118 }
119
120 let tail_start = index + ch.len_utf8();
121 let tail = &input[tail_start..];
122
123 if let Some(entity) = XML_ENTITY.find(tail).filter(|matched| matched.start() == 0) {
124 escaped.push('&');
125 escaped.push_str(entity.as_str());
126 for _ in 0..entity.as_str().chars().count() {
127 let _ = iter.next();
128 }
129 } else {
130 escaped.push_str("&");
131 }
132 }
133
134 escaped
135}
136
137const UNIVERSAL_TAGS: &[&str] = &[
139 "body",
140 "strong",
141 "em",
142 "u",
143 "s",
144 "code",
145 "a",
146 "ol",
147 "ul",
148 "li",
149 "blockquote",
150 "pre",
151];
152
153const TASK_EXTRA_TAGS: &[&str] = &["h1", "h2", "hr", "img"];
155
156const PROJECT_BRIEF_EXTRA_TAGS: &[&str] = &["table", "tr", "td", "object"];
158
159const NO_LIST_INSIDE: &[&str] = &["h1", "h2", "blockquote", "pre"];
161
162const FORBIDDEN_INSIDE_LI: &[&str] = &["h1", "h2", "blockquote", "pre"];
164
165const A_ATTRS: &[&str] = &[
167 "href",
168 "data-asana-gid",
169 "data-asana-type",
170 "data-asana-project",
171 "data-asana-tag",
172 "data-asana-dynamic",
173];
174
175const IMG_ATTRS: &[&str] = &[
177 "data-asana-gid",
178 "src",
179 "data-src-width",
180 "data-src-height",
181 "data-thumbnail-url",
182 "data-thumbnail-width",
183 "data-thumbnail-height",
184 "style",
185];
186
187const OBJECT_ATTRS: &[&str] = &["type", "data-asana-gid", "data", "data-asana-type"];
189
190const DATA_ASANA_TYPE_VALUES: &[&str] = &[
192 "user",
193 "task",
194 "project",
195 "tag",
196 "conversation",
197 "project_status",
198 "team",
199 "search",
200 "attachment",
201];
202
203fn tag_allowed(tag: &str, context: RichTextContext) -> bool {
204 if UNIVERSAL_TAGS.contains(&tag) {
205 return true;
206 }
207 match context {
208 RichTextContext::StoryText => false,
209 RichTextContext::TaskNotes => TASK_EXTRA_TAGS.contains(&tag),
210 RichTextContext::ProjectBrief => {
211 TASK_EXTRA_TAGS.contains(&tag) || PROJECT_BRIEF_EXTRA_TAGS.contains(&tag)
212 }
213 }
214}
215
216fn allowed_attrs(tag: &str) -> Option<&'static [&'static str]> {
217 match tag {
218 "a" => Some(A_ATTRS),
219 "img" => Some(IMG_ATTRS),
220 "object" => Some(OBJECT_ATTRS),
221 _ => None,
222 }
223}
224
225fn validate(input: &str, context: RichTextContext) -> Result<(), RichTextError> {
227 use quick_xml::events::Event;
228 use quick_xml::reader::Reader;
229
230 let mut reader = Reader::from_str(input);
231 let mut stack: Vec<String> = Vec::new();
232 let mut saw_root = false;
233
234 loop {
235 match reader.read_event() {
236 Ok(Event::Eof) => return Ok(()),
237 Ok(Event::Start(e)) => {
238 let tag = std::str::from_utf8(e.name().as_ref())
239 .map_err(|err| RichTextError::InvalidXml(err.to_string()))?
240 .to_string();
241 check_element(&tag, &e, &stack, context, saw_root)?;
242 saw_root = true;
243 stack.push(tag);
244 }
245 Ok(Event::Empty(e)) => {
246 let tag = std::str::from_utf8(e.name().as_ref())
247 .map_err(|err| RichTextError::InvalidXml(err.to_string()))?
248 .to_string();
249 check_element(&tag, &e, &stack, context, saw_root)?;
250 saw_root = true;
251 }
252 Ok(Event::End(_)) => {
253 stack.pop();
254 }
255 Ok(_) => {}
256 Err(error) => return Err(RichTextError::InvalidXml(error.to_string())),
257 }
258 }
259}
260
261fn check_element(
262 tag: &str,
263 bytes: &quick_xml::events::BytesStart,
264 stack: &[String],
265 context: RichTextContext,
266 saw_root: bool,
267) -> Result<(), RichTextError> {
268 if !saw_root && tag != "body" {
269 return Err(RichTextError::RootMustBeBody(tag.to_string()));
270 }
271
272 if !tag_allowed(tag, context) {
273 return Err(RichTextError::UnsupportedTag {
274 tag: tag.to_string(),
275 context,
276 });
277 }
278
279 check_nesting(tag, stack)?;
280 check_attributes(tag, bytes)?;
281 Ok(())
282}
283
284fn check_nesting(tag: &str, stack: &[String]) -> Result<(), RichTextError> {
285 for ancestor in stack.iter().rev() {
286 let a = ancestor.as_str();
287 if a == "li" && FORBIDDEN_INSIDE_LI.contains(&tag) {
288 return Err(RichTextError::InvalidNesting {
289 parent: a.to_string(),
290 child: tag.to_string(),
291 });
292 }
293 if NO_LIST_INSIDE.contains(&a) && (tag == "ol" || tag == "ul" || tag == "li") {
294 return Err(RichTextError::InvalidNesting {
295 parent: a.to_string(),
296 child: tag.to_string(),
297 });
298 }
299 }
300 Ok(())
301}
302
303fn check_attributes(tag: &str, bytes: &quick_xml::events::BytesStart) -> Result<(), RichTextError> {
304 let allowed = allowed_attrs(tag);
305 for attr in bytes.attributes() {
306 let attr = attr.map_err(|err| RichTextError::InvalidXml(err.to_string()))?;
307 let name = std::str::from_utf8(attr.key.as_ref())
308 .map_err(|err| RichTextError::InvalidXml(err.to_string()))?
309 .to_string();
310
311 let Some(allow_list) = allowed else {
312 return Err(RichTextError::DisallowedAttribute {
313 tag: tag.to_string(),
314 attr: name,
315 });
316 };
317
318 if !allow_list.contains(&name.as_str()) {
319 return Err(RichTextError::DisallowedAttribute {
320 tag: tag.to_string(),
321 attr: name,
322 });
323 }
324
325 if name == "data-asana-type" {
326 let value = attr
327 .normalized_value(quick_xml::XmlVersion::Implicit1_0)
328 .map_err(|err| RichTextError::InvalidXml(err.to_string()))?;
329 if !DATA_ASANA_TYPE_VALUES.contains(&value.as_ref()) {
330 return Err(RichTextError::InvalidDataAsanaType(value.into_owned()));
331 }
332 }
333 }
334 Ok(())
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340
341 fn task(s: &str) -> Result<String, RichTextError> {
342 prepare_rich_text(s, RichTextContext::TaskNotes)
343 }
344 fn story(s: &str) -> Result<String, RichTextError> {
345 prepare_rich_text(s, RichTextContext::StoryText)
346 }
347
348 #[test]
349 fn body_wrap_adds_tags_when_missing() {
350 assert_eq!(
351 ensure_body_wrap("<em>hello</em>"),
352 "<body><em>hello</em></body>"
353 );
354 }
355
356 #[test]
357 fn body_wrap_preserves_existing_tags() {
358 assert_eq!(
359 ensure_body_wrap("<body><em>hello</em></body>"),
360 "<body><em>hello</em></body>"
361 );
362 }
363
364 #[test]
365 fn body_wrap_handles_plain_text() {
366 assert_eq!(ensure_body_wrap("hello world"), "<body>hello world</body>");
367 }
368
369 #[test]
370 fn escape_bare_ampersand() {
371 assert_eq!(escape_bare_ampersands("Tom & Jerry"), "Tom & Jerry");
372 }
373
374 #[test]
375 fn escape_preserves_existing_entities() {
376 assert_eq!(escape_bare_ampersands("Tom & Jerry"), "Tom & Jerry");
377 }
378
379 #[test]
380 fn escape_preserves_numeric_entities() {
381 assert_eq!(escape_bare_ampersands("© 2026"), "© 2026");
382 }
383
384 #[test]
385 fn escape_idempotent() {
386 let once = escape_bare_ampersands("Tom & Jerry");
387 let twice = escape_bare_ampersands(&once);
388 assert_eq!(once, twice);
389 }
390
391 #[test]
392 fn task_accepts_universal_tags() {
393 assert!(task("<body><strong>bold</strong> <em>i</em> <code>x</code></body>").is_ok());
394 }
395
396 #[test]
397 fn task_accepts_h1_and_hr() {
398 assert!(task("<body><h1>Title</h1><hr/></body>").is_ok());
399 }
400
401 #[test]
402 fn story_rejects_h1() {
403 let err = story("<body><h1>Nope</h1></body>").unwrap_err();
404 assert!(matches!(
405 err,
406 RichTextError::UnsupportedTag { ref tag, .. } if tag == "h1"
407 ));
408 }
409
410 #[test]
411 fn story_accepts_universal_tags() {
412 assert!(story("<body><strong>x</strong> & <em>y</em></body>").is_ok());
413 }
414
415 #[test]
416 fn rejects_unknown_tag() {
417 let err = task("<body><div>hi</div></body>").unwrap_err();
418 assert!(matches!(
419 err,
420 RichTextError::UnsupportedTag { ref tag, .. } if tag == "div"
421 ));
422 }
423
424 #[test]
425 fn rejects_p_tag() {
426 let err = task("<body><p>paragraph</p></body>").unwrap_err();
427 assert!(matches!(err, RichTextError::UnsupportedTag { .. }));
428 }
429
430 #[test]
431 fn rejects_attribute_on_strong() {
432 let err = task(r#"<body><strong class="x">hi</strong></body>"#).unwrap_err();
433 assert!(matches!(
434 err,
435 RichTextError::DisallowedAttribute { ref tag, ref attr }
436 if tag == "strong" && attr == "class"
437 ));
438 }
439
440 #[test]
441 fn accepts_a_with_href_and_gid() {
442 assert!(task(
443 r#"<body><a href="https://x" data-asana-gid="123" data-asana-type="task">t</a></body>"#
444 )
445 .is_ok());
446 }
447
448 #[test]
449 fn rejects_unknown_a_attribute() {
450 let err = task(r#"<body><a target="_blank">x</a></body>"#).unwrap_err();
451 assert!(matches!(
452 err,
453 RichTextError::DisallowedAttribute { ref attr, .. } if attr == "target"
454 ));
455 }
456
457 #[test]
458 fn rejects_invalid_data_asana_type() {
459 let err = task(r#"<body><a data-asana-type="bogus">x</a></body>"#).unwrap_err();
460 assert!(matches!(err, RichTextError::InvalidDataAsanaType(ref v) if v == "bogus"));
461 }
462
463 #[test]
464 fn rejects_h1_inside_li() {
465 let err = task("<body><ul><li><h1>nope</h1></li></ul></body>").unwrap_err();
466 assert!(matches!(
467 err,
468 RichTextError::InvalidNesting { ref parent, ref child }
469 if parent == "li" && child == "h1"
470 ));
471 }
472
473 #[test]
474 fn rejects_blockquote_inside_li() {
475 let err = task("<body><ul><li><blockquote>x</blockquote></li></ul></body>").unwrap_err();
476 assert!(matches!(err, RichTextError::InvalidNesting { .. }));
477 }
478
479 #[test]
480 fn rejects_list_inside_blockquote() {
481 let err = task("<body><blockquote><ul><li>x</li></ul></blockquote></body>").unwrap_err();
482 assert!(matches!(
483 err,
484 RichTextError::InvalidNesting { ref parent, ref child }
485 if parent == "blockquote" && child == "ul"
486 ));
487 }
488
489 #[test]
490 fn rejects_list_inside_pre() {
491 let err = task("<body><pre><ol><li>x</li></ol></pre></body>").unwrap_err();
492 assert!(matches!(err, RichTextError::InvalidNesting { .. }));
493 }
494
495 #[test]
496 fn rejects_uppercase_tag() {
497 let err = task("<BODY>x</BODY>").unwrap_err();
498 assert!(
500 matches!(
501 err,
502 RichTextError::UnsupportedTag { ref tag, .. } if tag == "BODY"
503 || tag.eq_ignore_ascii_case("body")
504 ) || matches!(err, RichTextError::RootMustBeBody(_))
505 );
506 }
507
508 #[test]
509 fn accepts_self_closing_hr_in_task() {
510 assert!(task("<body>line<hr/>end</body>").is_ok());
511 }
512
513 #[test]
514 fn rejects_mismatched_tags() {
515 let err = task("<body><em>x</strong></body>").unwrap_err();
516 assert!(matches!(err, RichTextError::InvalidXml(_)));
517 }
518
519 #[test]
520 fn prepare_wraps_and_validates() {
521 let result = task("<em>hello</em>").unwrap();
522 assert_eq!(result, "<body><em>hello</em></body>");
523 }
524
525 #[test]
526 fn prepare_escapes_and_validates() {
527 let result = task("<body>Tom & Jerry</body>").unwrap();
528 assert_eq!(result, "<body>Tom & Jerry</body>");
529 }
530
531 #[test]
532 fn prepare_passes_valid_through() {
533 let input = "<body><strong>bold</strong> & done</body>";
534 assert_eq!(task(input).unwrap(), input);
535 }
536
537 #[test]
538 fn prepare_handles_nested_lists() {
539 let input = "<body><ul><li>one</li><li>two</li></ul></body>";
540 assert_eq!(task(input).unwrap(), input);
541 }
542
543 #[test]
544 fn prepare_handles_link_attributes() {
545 let input = r#"<body><a data-asana-gid="123" data-asana-type="task">t</a></body>"#;
546 assert_eq!(task(input).unwrap(), input);
547 }
548
549 #[test]
550 fn project_brief_allows_table() {
551 let input = "<body><table><tr><td>cell</td></tr></table></body>";
552 assert!(prepare_rich_text(input, RichTextContext::ProjectBrief).is_ok());
553 }
554
555 #[test]
556 fn task_rejects_table() {
557 let err = task("<body><table><tr><td>c</td></tr></table></body>").unwrap_err();
558 assert!(matches!(err, RichTextError::UnsupportedTag { .. }));
559 }
560}