Skip to main content

webserver_base/telegram/
token.rs

1use std::fmt::{Debug, Display, Formatter};
2
3use super::error::TelegramError;
4
5/// A Telegram bot token.
6///
7/// The token is a credential which grants full control of the bot, and it is
8/// sent to Telegram as a **path segment** of every request URL. That makes it
9/// unusually easy to leak: any error type, log line, or panic message which
10/// includes the request URL includes the token.
11///
12/// This type therefore refuses to print itself. Both [`Debug`] and [`Display`]
13/// render `***`, so a `{:?}` of any struct holding a `BotToken` is safe. The
14/// raw value is reachable only via the crate-private [`BotToken::expose`].
15#[derive(Clone, PartialEq, Eq)]
16pub struct BotToken(String);
17
18impl BotToken {
19    /// Creates a new [`BotToken`], validating that it is plausibly a token.
20    ///
21    /// Blank-but-present is treated as missing: a server which boots with an
22    /// empty token looks healthy while silently notifying nobody.
23    ///
24    /// # Errors
25    ///
26    /// Returns [`TelegramError::InvalidToken`] if the token is empty after
27    /// trimming, or if it does not contain the `<bot_id>:<secret>` separator.
28    pub fn new(token: impl Into<String>) -> Result<Self, TelegramError> {
29        let token: String = token.into().trim().to_string();
30
31        if token.is_empty() {
32            return Err(TelegramError::InvalidToken(String::from(
33                "token is empty (or only whitespace)",
34            )));
35        }
36
37        // A real token looks like `123456789:AAF...`. Checking for the separator
38        // catches the common misconfigurations (a chat id pasted into the token
39        // slot, a quoted empty value) without hard-coding Telegram's exact format.
40        let Some((bot_id, secret)) = token.split_once(':') else {
41            return Err(TelegramError::InvalidToken(String::from(
42                "token is missing the `<bot_id>:<secret>` separator",
43            )));
44        };
45
46        if bot_id.is_empty() || !bot_id.chars().all(|c: char| c.is_ascii_digit()) {
47            return Err(TelegramError::InvalidToken(String::from(
48                "token's bot id is not numeric",
49            )));
50        }
51
52        if secret.is_empty() {
53            return Err(TelegramError::InvalidToken(String::from(
54                "token's secret is empty",
55            )));
56        }
57
58        Ok(Self(token))
59    }
60
61    /// Returns the raw token.
62    ///
63    /// Crate-private on purpose: the only legitimate use is building a request
64    /// URL. Everything else should use the redacting [`Debug`]/[`Display`].
65    pub(crate) fn expose(&self) -> &str {
66        &self.0
67    }
68}
69
70impl Debug for BotToken {
71    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
72        write!(f, "BotToken(***)")
73    }
74}
75
76impl Display for BotToken {
77    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
78        write!(f, "***")
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::BotToken;
85    use crate::telegram::error::TelegramError;
86
87    const VALID: &str = "123456789:AAFNpHzr6wq4YimAMwIjqVrFU8TO5kcayEI";
88
89    #[test]
90    fn valid_token_is_accepted() {
91        let token: BotToken = BotToken::new(VALID).expect("valid token should be accepted");
92
93        let expected: &str = VALID;
94        let actual: &str = token.expose();
95        assert_eq!(expected, actual);
96    }
97
98    #[test]
99    fn surrounding_whitespace_is_trimmed() {
100        let token: BotToken =
101            BotToken::new(format!("  {VALID}\n")).expect("valid token should be accepted");
102
103        let expected: &str = VALID;
104        let actual: &str = token.expose();
105        assert_eq!(expected, actual);
106    }
107
108    #[test]
109    fn empty_token_is_rejected() {
110        let error: TelegramError = BotToken::new("").expect_err("empty token should be rejected");
111
112        assert!(matches!(error, TelegramError::InvalidToken(_)));
113    }
114
115    #[test]
116    fn whitespace_only_token_is_rejected() {
117        let error: TelegramError =
118            BotToken::new("   \n\t").expect_err("blank token should be rejected");
119
120        assert!(matches!(error, TelegramError::InvalidToken(_)));
121    }
122
123    #[test]
124    fn token_without_separator_is_rejected() {
125        let error: TelegramError =
126            BotToken::new("nosemicolonhere").expect_err("malformed token should be rejected");
127
128        assert!(matches!(error, TelegramError::InvalidToken(_)));
129    }
130
131    #[test]
132    fn token_with_non_numeric_bot_id_is_rejected() {
133        let error: TelegramError =
134            BotToken::new("abcdef:AAFsecret").expect_err("malformed token should be rejected");
135
136        assert!(matches!(error, TelegramError::InvalidToken(_)));
137    }
138
139    #[test]
140    fn token_with_empty_secret_is_rejected() {
141        let error: TelegramError =
142            BotToken::new("123456789:").expect_err("malformed token should be rejected");
143
144        assert!(matches!(error, TelegramError::InvalidToken(_)));
145    }
146
147    #[test]
148    fn debug_does_not_leak_the_token() {
149        let token: BotToken = BotToken::new(VALID).expect("valid token should be accepted");
150
151        let expected: String = String::from("BotToken(***)");
152        let actual: String = format!("{token:?}");
153        assert_eq!(expected, actual);
154        assert!(!actual.contains("AAFNpHzr"));
155    }
156
157    #[test]
158    fn display_does_not_leak_the_token() {
159        let token: BotToken = BotToken::new(VALID).expect("valid token should be accepted");
160
161        let expected: String = String::from("***");
162        let actual: String = format!("{token}");
163        assert_eq!(expected, actual);
164        assert!(!actual.contains("AAFNpHzr"));
165    }
166
167    #[test]
168    fn debug_of_a_containing_struct_does_not_leak_the_token() {
169        #[derive(Debug)]
170        struct Holder {
171            #[expect(dead_code, reason = "only exercised through the derived Debug impl")]
172            token: BotToken,
173        }
174
175        let holder: Holder = Holder {
176            token: BotToken::new(VALID).expect("valid token should be accepted"),
177        };
178
179        let actual: String = format!("{holder:?}");
180        assert!(!actual.contains("AAFNpHzr"));
181        assert!(actual.contains("***"));
182    }
183}