Skip to main content

libdd_common/
tag.rs

1// Copyright 2021-Present Datadog, Inc. https://www.datadoghq.com/
2// SPDX-License-Identifier: Apache-2.0
3
4//! Datadog tag construction and validation.
5//!
6//! # Validation policy
7//!
8//! The Datadog documentation defines more tag rules than this module enforces:
9//! <https://docs.datadoghq.com/getting_started/tagging/#define-tags>. Enforcing rules that tracing
10//! and profiling do not apply consistently would produce a worse user experience, so runtime
11//! validation currently rejects only empty tags and likely colon-related mistakes. Compile-time
12//! validation by the [`tag!`] macro is intentionally stricter.
13
14use alloc::borrow::Cow;
15use core::fmt::{Debug, Display, Formatter};
16use serde::{Deserialize, Serialize};
17
18pub use static_assertions::{const_assert, const_assert_ne};
19
20/// Describes some reasons why a tag is invalid.
21#[allow(missing_docs, reason = "variant names are self-documenting")]
22#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
23#[non_exhaustive] // so we can add more cases without breaking semver
24pub enum TagValidationError {
25    #[error("tag is empty")]
26    Empty,
27    #[error("tag begins with a colon")]
28    BeginsWithColon,
29    #[error("tag ends with a colon")]
30    EndsWithColon,
31}
32
33/// A tag rejected while validating a tag.
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub struct InvalidTag<'a> {
36    /// The invalid serialized tag.
37    pub value: &'a str,
38    /// Why the tag is invalid.
39    pub error: TagValidationError,
40}
41
42impl Display for InvalidTag<'_> {
43    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
44        match self.error {
45            TagValidationError::Empty => f.write_str("tag is empty"),
46            TagValidationError::BeginsWithColon => {
47                write!(f, "tag '{}' begins with a colon", self.value)
48            }
49            TagValidationError::EndsWithColon => {
50                write!(f, "tag '{}' ends with a colon", self.value)
51            }
52        }
53    }
54}
55
56impl core::error::Error for InvalidTag<'_> {}
57
58#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Serialize, Deserialize)]
59#[serde(transparent)]
60pub struct Tag {
61    /// Many tags are made from literal strings, such as:
62    ///  - "language:native"
63    ///  - "src_library:libdatadog"
64    ///  - "type:timeout"
65    ///
66    /// So being able to save allocations is nice.
67    value: Cow<'static, str>,
68}
69
70impl Tag {
71    /// Used by the `tag!` macro. Not meant to be used directly, please use
72    /// the macro instead.
73    /// # Safety
74    /// Do not use directly, use through the `tag!` macro which enforces the
75    /// safety invariants at compile time.
76    pub const unsafe fn from_static_unchecked(value: &'static str) -> Self {
77        Self {
78            value: Cow::Borrowed(value),
79        }
80    }
81}
82
83/// Creates a tag from a key and value known at compile-time, and fails to
84/// compile if it's known to be invalid (it may still emit an invalid tag, not
85/// all tag validation is currently done client-side). If the key or value
86/// aren't known at compile-time, then use [Tag::new].
87// todo: what's a good way to keep these in-sync with Tag::from_value?
88// This can be a little more strict because it's compile-time evaluated.
89// https://docs.datadoghq.com/getting_started/tagging/#define-tags
90#[macro_export]
91macro_rules! tag {
92    ($key:expr, $val:expr) => {{
93        // Keys come in "value" or "key:value" format. This pattern is always
94        // the key:value format, which means the value should not be empty.
95        // todo: the implementation here differs subtly from Tag::from_value,
96        //       which checks that the whole thing doesn't end with a colon.
97        $crate::tag::const_assert!(!$val.is_empty());
98
99        const COMBINED: &'static str = $crate::const_format::concatcp!($key, ":", $val);
100
101        // Tags must start with a letter. This is more restrictive than is
102        // required (could be a unicode alphabetic char) and can be lifted
103        // if it's causing problems.
104        $crate::tag::const_assert!(COMBINED.as_bytes()[0].is_ascii_alphabetic());
105
106        // Tags can be up to 200 characters long and support Unicode letters
107        // (which includes most character sets, including languages such as
108        // Japanese).
109        // Presently, engineers interpretted this to be 200 bytes, not unicode
110        // characters. However, if the 200th character is unicode, it's
111        // allowed to spill over due to a historical bug. For now, we'll
112        // ignore this and hard-code 200 bytes.
113        $crate::tag::const_assert!(COMBINED.as_bytes().len() <= 200);
114
115        #[allow(unused_unsafe)]
116        let tag = unsafe { $crate::tag::Tag::from_static_unchecked(COMBINED) };
117        tag
118    }};
119}
120
121impl Debug for Tag {
122    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
123        f.debug_struct("Tag").field("value", &self.value).finish()
124    }
125}
126
127impl AsRef<str> for Tag {
128    fn as_ref(&self) -> &str {
129        self.value.as_ref()
130    }
131}
132
133// Any type which implements Display automatically has to_string.
134impl Display for Tag {
135    fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result {
136        write!(f, "{}", self.value)
137    }
138}
139
140impl Tag {
141    /// Validates a tag key and value pair.
142    #[inline]
143    pub fn validate(key: &str, value: &str) -> Result<(), TagValidationError> {
144        if key.is_empty() || key.starts_with(':') {
145            Err(TagValidationError::BeginsWithColon)
146        } else if value.is_empty() || value.ends_with(':') {
147            Err(TagValidationError::EndsWithColon)
148        } else {
149            Ok(())
150        }
151    }
152
153    /// Validates a tag that has already been serialized.
154    #[inline]
155    pub fn validate_value(value: &str) -> Result<(), TagValidationError> {
156        if value.is_empty() {
157            Err(TagValidationError::Empty)
158        } else if value.starts_with(':') {
159            Err(TagValidationError::BeginsWithColon)
160        } else if value.ends_with(':') {
161            Err(TagValidationError::EndsWithColon)
162        } else {
163            Ok(())
164        }
165    }
166
167    /// Validates a tag.
168    fn from_value<'a, IntoCow>(value: IntoCow) -> anyhow::Result<Self>
169    where
170        IntoCow: Into<Cow<'a, str>>,
171    {
172        let value = value.into();
173        match Self::validate_value(&value) {
174            Ok(()) => Ok(Self {
175                value: Cow::Owned(value.into_owned()),
176            }),
177            Err(error) => {
178                let invalid = InvalidTag {
179                    value: value.as_ref(),
180                    error,
181                };
182                anyhow::bail!("{invalid}")
183            }
184        }
185    }
186
187    /// Creates a tag from a key and value. It's preferred to use the `tag!`
188    /// macro when the key and value are both known at compile-time.
189    pub fn new<K, V>(key: K, value: V) -> anyhow::Result<Self>
190    where
191        K: AsRef<str>,
192        V: AsRef<str>,
193    {
194        let key = key.as_ref();
195        let value = value.as_ref();
196
197        Tag::from_value(format!("{key}:{value}"))
198    }
199}
200
201/// An allocation-free iterator over validated tags in a comma- or space-separated string.
202pub struct TagParser<'a> {
203    chunks: core::str::Split<'a, &'static [char]>,
204}
205
206impl<'a> TagParser<'a> {
207    /// Creates an iterator over the tags in `input`.
208    pub fn new(input: &'a str) -> Self {
209        const SEPARATORS: &[char] = &[',', ' '];
210        Self {
211            chunks: input.split(SEPARATORS),
212        }
213    }
214}
215
216impl<'a> Iterator for TagParser<'a> {
217    type Item = Result<&'a str, InvalidTag<'a>>;
218
219    fn next(&mut self) -> Option<Self::Item> {
220        let tag = self.chunks.find(|chunk| !chunk.is_empty())?;
221        Some(
222            Tag::validate_value(tag)
223                .map(|()| tag)
224                .map_err(|error| InvalidTag { value: tag, error }),
225        )
226    }
227}
228
229/// Parse a string of tags typically provided by environment variables
230/// The tags are expected to be either space or comma separated:
231///     "key1:value1,key2:value2"
232///     "key1:value1 key2:value2"
233/// Tag names and values are required and may not be empty.
234///
235/// Returns a tuple of the correctly parsed tags and an optional error message
236/// describing issues encountered during parsing.
237pub fn parse_tags(str: &str) -> (Vec<Tag>, Option<String>) {
238    let mut tags = vec![];
239    let mut error_message = String::new();
240    for result in TagParser::new(str) {
241        match result {
242            Ok(tag) => tags.push(Tag {
243                value: Cow::Owned(tag.to_owned()),
244            }),
245            Err(err) => {
246                if error_message.is_empty() {
247                    error_message += "Errors while parsing tags: ";
248                } else {
249                    error_message += ", ";
250                }
251                error_message += &err.to_string();
252            }
253        }
254    }
255
256    let error_message = if error_message.is_empty() {
257        None
258    } else {
259        Some(error_message)
260    };
261    (tags, error_message)
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use proptest::prelude::*;
268
269    proptest! {
270        #[test]
271        fn prop_component_and_serialized_validation_are_consistent(
272            key in any::<String>(),
273            value in any::<String>(),
274        ) {
275            let serialized = format!("{key}:{value}");
276
277            prop_assert_eq!(
278                Tag::validate(&key, &value),
279                Tag::validate_value(&serialized)
280            );
281        }
282
283        #[test]
284        fn prop_constructor_and_validation_are_consistent(
285            key in any::<String>(),
286            value in any::<String>(),
287        ) {
288            let serialized = format!("{key}:{value}");
289            let validation = Tag::validate(&key, &value);
290            let result = Tag::new(&key, &value);
291
292            prop_assert_eq!(result.is_ok(), validation.is_ok());
293            if let Ok(tag) = result {
294                prop_assert_eq!(tag.as_ref(), serialized);
295            }
296        }
297
298        #[test]
299        fn prop_serialized_constructor_and_validation_are_consistent(
300            value in any::<String>(),
301        ) {
302            prop_assert_eq!(
303                Tag::from_value(value.as_str()).is_ok(),
304                Tag::validate_value(&value).is_ok()
305            );
306        }
307    }
308
309    #[test]
310    fn test_is_send() {
311        // fails to compile if false
312        fn is_send<T: Send>(_t: T) -> bool {
313            true
314        }
315        assert!(is_send(tag!("src_library", "libdatadog")));
316    }
317
318    #[test]
319    fn test_validation() {
320        assert_eq!(Tag::validate("key", "value"), Ok(()));
321        assert_eq!(Tag::validate("key", "value:with:colons"), Ok(()));
322        assert_eq!(
323            Tag::validate("", "value"),
324            Err(TagValidationError::BeginsWithColon)
325        );
326        assert_eq!(
327            Tag::validate(":key", "value"),
328            Err(TagValidationError::BeginsWithColon)
329        );
330        assert_eq!(
331            Tag::validate("key", ""),
332            Err(TagValidationError::EndsWithColon)
333        );
334        assert_eq!(
335            Tag::validate("key", "value:"),
336            Err(TagValidationError::EndsWithColon)
337        );
338
339        assert_eq!(Tag::validate_value("key:value"), Ok(()));
340        assert_eq!(Tag::validate_value("tag"), Ok(()));
341        assert_eq!(Tag::validate_value(""), Err(TagValidationError::Empty));
342        assert_eq!(
343            Tag::validate_value(":value"),
344            Err(TagValidationError::BeginsWithColon)
345        );
346        assert_eq!(
347            Tag::validate_value("key:"),
348            Err(TagValidationError::EndsWithColon)
349        );
350    }
351
352    #[test]
353    fn test_empty_key() {
354        let error = Tag::new("", "woof").expect_err("empty key is not allowed");
355        assert_eq!(error.to_string(), "tag ':woof' begins with a colon");
356    }
357
358    #[test]
359    fn test_empty_value() {
360        let error = Tag::new("key1", "").expect_err("empty value is an error");
361        assert_eq!(error.to_string(), "tag 'key1:' ends with a colon");
362    }
363
364    #[test]
365    fn test_bad_utf8() {
366        // 0b1111_0xxx is the start of a 4-byte sequence, but there aren't any
367        // more chars, so it  will get converted into the utf8 replacement
368        // character. This results in a string with an "a" and a replacement
369        // char, so it should be an error (no valid chars). However, we don't
370        // enforce many things about tags yet client-side, so we let it slide.
371        let bytes = &[b'a', 0b1111_0111];
372        let key = String::from_utf8_lossy(bytes);
373        let t = Tag::new(key, "value").unwrap();
374        assert_eq!("a\u{FFFD}:value", t.to_string());
375    }
376
377    #[test]
378    fn test_value_has_colon() {
379        let result = Tag::new("env", "staging:east").expect("values can have colons");
380        assert_eq!("env:staging:east", result.to_string());
381
382        let result = tag!("env", "staging:east");
383        assert_eq!("env:staging:east", result.to_string());
384    }
385
386    #[test]
387    fn test_suspicious_tags() {
388        // Based on tag rules, these should all fail. However, there is a risk
389        // that profile tags will then differ or cause failures compared to
390        // trace tags. These require cross-team, cross-language collaboration.
391        let cases = [
392            ("_begins_with_non-letter".to_string(), "value"),
393            ("the-tag-length-is-over-200-characters".repeat(6), "value"),
394        ];
395
396        for case in cases {
397            let result = Tag::new(case.0, case.1);
398            // Again, these should fail, but it's not implemented yet
399            assert!(result.is_ok())
400        }
401    }
402
403    #[test]
404    fn test_missing_colon_parsing() {
405        let tag = Tag::from_value("tag").unwrap();
406        assert_eq!("tag", tag.to_string());
407    }
408
409    #[test]
410    fn test_leading_colon_parsing() {
411        let _ = Tag::from_value(":tag").expect_err("Cannot start with a colon");
412    }
413
414    #[test]
415    fn test_tailing_colon_parsing() {
416        let _ = Tag::from_value("tag:").expect_err("Cannot end with a colon");
417    }
418
419    #[test]
420    fn test_tag_parser() {
421        let parsed =
422            TagParser::new("key:value, :leading middle:colon trailing:  bare").collect::<Vec<_>>();
423        assert_eq!(
424            parsed,
425            vec![
426                Ok("key:value"),
427                Err(InvalidTag {
428                    value: ":leading",
429                    error: TagValidationError::BeginsWithColon,
430                }),
431                Ok("middle:colon"),
432                Err(InvalidTag {
433                    value: "trailing:",
434                    error: TagValidationError::EndsWithColon,
435                }),
436                Ok("bare"),
437            ]
438        );
439    }
440
441    #[test]
442    fn test_tag_parser_preserves_parse_tags_errors() {
443        let (tags, error) = parse_tags("valid:value,:leading,trailing:,also:valid");
444        assert_eq!(
445            tags,
446            vec![
447                Tag::new("valid", "value").unwrap(),
448                Tag::new("also", "valid").unwrap(),
449            ]
450        );
451        assert_eq!(
452            error.as_deref(),
453            Some(
454                "Errors while parsing tags: tag ':leading' begins with a colon, tag 'trailing:' ends with a colon"
455            )
456        );
457    }
458
459    #[test]
460    fn test_tags_parsing() {
461        let cases = [
462            ("", vec![]),
463            (",", vec![]),
464            (" , ", vec![]),
465            // Testing that values can contain colons
466            (
467                "env:staging:east,location:nyc:ny",
468                vec![
469                    Tag::new("env", "staging:east").unwrap(),
470                    Tag::new("location", "nyc:ny").unwrap(),
471                ],
472            ),
473            // Testing value format (no key)
474            ("value", vec![Tag::from_value("value").unwrap()]),
475            (
476                "state:utah,state:idaho",
477                vec![
478                    Tag::new("state", "utah").unwrap(),
479                    Tag::new("state", "idaho").unwrap(),
480                ],
481            ),
482            (
483                "key1:value1 key2:value2 key3:value3",
484                vec![
485                    Tag::new("key1", "value1").unwrap(),
486                    Tag::new("key2", "value2").unwrap(),
487                    Tag::new("key3", "value3").unwrap(),
488                ],
489            ),
490            (
491                // Testing consecutive separators being collapsed
492                "key1:value1, key2:value2 ,key3:value3 , key4:value4",
493                vec![
494                    Tag::new("key1", "value1").unwrap(),
495                    Tag::new("key2", "value2").unwrap(),
496                    Tag::new("key3", "value3").unwrap(),
497                    Tag::new("key4", "value4").unwrap(),
498                ],
499            ),
500        ];
501
502        for case in cases {
503            let expected = case.1;
504            let (actual, error_message) = parse_tags(case.0);
505            assert_eq!(expected, actual);
506            assert!(error_message.is_none());
507        }
508    }
509}