1use std::future::Future;
19
20tokio::task_local! {
21 static ACTOR: Option<String>;
22 static SUBJECT: Option<String>;
23}
24
25pub async fn scope<F: Future>(actor: Option<String>, fut: F) -> F::Output {
27 ACTOR.scope(actor, fut).await
28}
29
30pub fn current() -> Option<String> {
32 ACTOR.try_with(Clone::clone).ok().flatten()
33}
34
35pub async fn scope_with_subject<F: Future>(
38 actor: Option<String>,
39 subject: Option<String>,
40 fut: F,
41) -> F::Output {
42 SUBJECT.scope(subject, ACTOR.scope(actor, fut)).await
43}
44
45pub fn current_subject() -> Option<String> {
49 SUBJECT.try_with(Clone::clone).ok().flatten()
50}
51
52pub const MAX_SUBJECT: usize = 128;
55
56pub fn validate_subject(subject: &str) -> std::result::Result<&str, String> {
64 let trimmed = subject.trim();
65 if trimmed.is_empty() {
66 return Err("on-behalf-of must not be blank".to_string());
67 }
68 if trimmed.chars().count() > MAX_SUBJECT {
69 return Err(format!(
70 "on-behalf-of must be at most {MAX_SUBJECT} characters"
71 ));
72 }
73 if let Some(bad) = trimmed.chars().find(|c| c.is_control()) {
74 return Err(format!(
75 "on-behalf-of must not contain control characters (found {bad:?})"
76 ));
77 }
78 Ok(trimmed)
79}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
89 fn a_subject_that_could_forge_a_log_line_is_refused() {
90 assert!(validate_subject("discord:U123").is_ok());
91 assert_eq!(validate_subject(" U123 ").unwrap(), "U123", "trimmed");
92
93 assert!(validate_subject("").is_err(), "blank");
94 assert!(validate_subject(" ").is_err(), "whitespace only");
95 assert!(
96 validate_subject("alice\nactor=admin").is_err(),
97 "a newline must not be accepted"
98 );
99 assert!(validate_subject("alice\r\nx").is_err());
100 assert!(validate_subject("alice\tbob").is_err());
101 assert!(validate_subject("alice\u{0}").is_err());
102 assert!(
103 validate_subject(&"x".repeat(MAX_SUBJECT + 1)).is_err(),
104 "over-long must be refused"
105 );
106 assert!(validate_subject(&"x".repeat(MAX_SUBJECT)).is_ok());
107 assert!(validate_subject("Zoë Ω 日本").is_ok());
109 }
110
111 #[tokio::test]
114 async fn the_asserted_subject_is_separate_from_the_authenticated_actor() {
115 scope_with_subject(Some("ariel".into()), Some("discord:U123".into()), async {
116 assert_eq!(current().as_deref(), Some("ariel"));
117 assert_eq!(current_subject().as_deref(), Some("discord:U123"));
118 })
119 .await;
120
121 scope_with_subject(Some("ariel".into()), None, async {
123 assert_eq!(current().as_deref(), Some("ariel"));
124 assert_eq!(current_subject(), None);
125 })
126 .await;
127
128 assert_eq!(current_subject(), None, "does not leak past the scope");
129 }
130
131 #[tokio::test]
132 async fn a_spawned_task_inherits_neither_identity() {
133 scope_with_subject(Some("ariel".into()), Some("discord:U123".into()), async {
134 let seen = tokio::spawn(async { (current(), current_subject()) })
135 .await
136 .unwrap();
137 assert_eq!(
138 seen,
139 (None, None),
140 "background work must not be attributed to the request that started it"
141 );
142 })
143 .await;
144 }
145
146 #[tokio::test]
147 async fn current_is_none_outside_a_scope() {
148 assert_eq!(current(), None);
149 }
150
151 #[tokio::test]
152 async fn scope_is_visible_across_awaits_and_not_in_spawned_tasks() {
153 scope(Some("alice".into()), async {
154 tokio::task::yield_now().await;
155 assert_eq!(current().as_deref(), Some("alice"));
156 let spawned = tokio::spawn(async { current() }).await.unwrap();
157 assert_eq!(spawned, None, "spawned tasks must not inherit the actor");
158 })
159 .await;
160 assert_eq!(current(), None);
161 }
162}