Skip to main content

asana_cli/
rich_text.rs

1//! Asana rich text preparation and validation.
2//!
3//! The Asana API requires HTML rich text fields (`html_notes`, `html_text`) to
4//! be valid XML wrapped in `<body>` tags, drawn from a fixed tag whitelist that
5//! varies by object context, with attributes permitted only on a small set of
6//! tags. This module rejects invalid documents locally so that we don't burn
7//! API calls on payloads Asana would 400.
8//!
9//! Pipeline:
10//! 1. Wrap content in `<body>` tags if missing.
11//! 2. Escape bare ampersands that are not part of XML entity references.
12//! 3. Walk the parsed XML asserting:
13//!    - root element is `<body>`,
14//!    - every element is in the context-specific tag whitelist,
15//!    - every attribute is on a tag that allows attributes and is in that
16//!      tag's attribute whitelist,
17//!    - `data-asana-type` values are in the documented enum,
18//!    - nesting rules are honoured (no headers/blockquote/pre inside list
19//!      items, no lists inside headers/blockquote/pre).
20//!
21//! Reference: <https://developers.asana.com/docs/rich-text>
22
23use regex::Regex;
24use std::sync::LazyLock;
25use thiserror::Error;
26
27/// The object context the rich text is being prepared for.
28///
29/// Determines which tags are permitted (e.g. `<h1>` is allowed in
30/// `TaskNotes` but not in `StoryText`).
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum RichTextContext {
33    /// `html_notes` on a task. Allows the universal tags plus
34    /// `h1`, `h2`, `hr`, `img`.
35    TaskNotes,
36    /// `html_text` on a story (comment). Universal tags only.
37    StoryText,
38    /// Project brief body. Allows everything `TaskNotes` does plus
39    /// `table`, `tr`, `td`, `object`.
40    ProjectBrief,
41}
42
43/// Errors from rich text preparation.
44#[derive(Debug, Error, PartialEq, Eq)]
45pub enum RichTextError {
46    /// Content failed XML well-formedness parsing.
47    #[error("invalid rich text XML: {0}")]
48    InvalidXml(String),
49    /// Root element was not `<body>`.
50    #[error("root element must be <body>, found <{0}>")]
51    RootMustBeBody(String),
52    /// Tag is not on Asana's whitelist for this context.
53    #[error("unsupported tag <{tag}> for {context:?}")]
54    UnsupportedTag {
55        /// Offending tag name.
56        tag: String,
57        /// Context being validated against.
58        context: RichTextContext,
59    },
60    /// Attribute appeared on a tag that does not accept attributes.
61    #[error("attribute `{attr}` is not allowed on <{tag}>")]
62    DisallowedAttribute {
63        /// Tag the attribute appeared on.
64        tag: String,
65        /// Attribute name.
66        attr: String,
67    },
68    /// `data-asana-type` carried a value outside the documented enum.
69    #[error("invalid data-asana-type value: {0}")]
70    InvalidDataAsanaType(String),
71    /// Nesting violates Asana's structural rules.
72    #[error("invalid nesting: <{child}> cannot appear inside <{parent}>")]
73    InvalidNesting {
74        /// Containing tag.
75        parent: String,
76        /// Forbidden child tag.
77        child: String,
78    },
79}
80
81/// Prepare an HTML rich text string for the Asana API.
82///
83/// # Errors
84///
85/// Returns [`RichTextError`] if the prepared content is not well-formed XML
86/// or violates any of Asana's structural / whitelist rules for `context`.
87pub 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
94/// Regex matching XML entity references (the part after the `&`).
95static 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
100/// Wrap content in `<body>` tags if not already wrapped.
101fn 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
109/// Escape bare ampersands in the input.
110fn 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("&amp;");
131        }
132    }
133
134    escaped
135}
136
137/// Universal tags allowed in every context.
138const 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
153/// Tags allowed in `TaskNotes` (and `ProjectBrief`) beyond universal.
154const TASK_EXTRA_TAGS: &[&str] = &["h1", "h2", "hr", "img"];
155
156/// Tags allowed only in `ProjectBrief` beyond `TaskNotes`.
157const PROJECT_BRIEF_EXTRA_TAGS: &[&str] = &["table", "tr", "td", "object"];
158
159/// Tags whose contents may not contain a list element.
160const NO_LIST_INSIDE: &[&str] = &["h1", "h2", "blockquote", "pre"];
161
162/// Tags forbidden inside a list item.
163const FORBIDDEN_INSIDE_LI: &[&str] = &["h1", "h2", "blockquote", "pre"];
164
165/// Allowed `<a>` attributes.
166const 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
175/// Allowed `<img>` attributes.
176const 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
187/// Allowed `<object>` attributes.
188const OBJECT_ATTRS: &[&str] = &["type", "data-asana-gid", "data", "data-asana-type"];
189
190/// Allowed values for the `data-asana-type` attribute.
191const 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
225/// Validate XML structure, tag/attribute whitelists, and nesting.
226fn 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 &amp; Jerry");
372    }
373
374    #[test]
375    fn escape_preserves_existing_entities() {
376        assert_eq!(escape_bare_ampersands("Tom &amp; Jerry"), "Tom &amp; Jerry");
377    }
378
379    #[test]
380    fn escape_preserves_numeric_entities() {
381        assert_eq!(escape_bare_ampersands("&#169; 2026"), "&#169; 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> &amp; <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        // After ensure_body_wrap, the upper-case BODY remains; root check fails.
499        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 &amp; Jerry</body>");
529    }
530
531    #[test]
532    fn prepare_passes_valid_through() {
533        let input = "<body><strong>bold</strong> &amp; 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}