返回 CodeWhale
citations.rs
根目录 / crates / tui / src / tools / web / citations.rs
1 //! Session-scoped web citation registry shared by every web-tool surface.
2 //!
3 //! Ref IDs are opaque, deterministic within one session, and useless in a
4 //! foreign session. The registry stores only a normalized HTTP(S) URL, an
5 //! optional title, and the retrieval timestamp needed by Work Graph evidence.
6
7 use std::collections::HashMap;
8 use std::sync::{Mutex, OnceLock};
9 use std::time::{Duration, Instant};
10
11 use chrono::{SecondsFormat, Utc};
12 use serde::{Deserialize, Serialize};
13
14 const CITATION_TTL: Duration = Duration::from_secs(30 * 60);
15 const MAX_CITATIONS: usize = 4_096;
16
17 static CITATIONS: OnceLock<Mutex<HashMap<CitationKey, CitationEntry>>> = OnceLock::new();
18
19 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
20 struct CitationKey {
21 namespace: String,
22 ref_id: String,
23 }
24
25 #[derive(Debug, Clone)]
26 struct CitationEntry {
27 citation: WebCitation,
28 touched_at: Instant,
29 }
30
31 /// Inspectable, secret-free web evidence metadata.
32 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33 pub(crate) struct WebCitation {
34 pub(crate) ref_id: String,
35 pub(crate) url: String,
36 #[serde(skip_serializing_if = "Option::is_none")]
37 pub(crate) title: Option<String>,
38 pub(crate) retrieved_at: String,
39 }
40
41 impl WebCitation {
42 pub(crate) fn evidence_ref(&self) -> Result<crate::work_graph::EvidenceRef, String> {
43 crate::work_graph::EvidenceRef::new(
44 crate::work_graph::EvidenceKind::WebCitation {
45 ref_id: self.ref_id.clone(),
46 url: self.url.clone(),
47 retrieved_at: self.retrieved_at.clone(),
48 },
49 self.ref_id.clone(),
50 None,
51 false,
52 )
53 .map_err(|error| error.to_string())
54 }
55 }
56
57 /// Register a URL under a deterministic, session-scoped ref ID.
58 pub(crate) fn register(namespace: &str, url: &str, title: Option<&str>) -> Option<WebCitation> {
59 let normalized = normalize_http_url(url)?;
60 let ref_id = ref_id_for(namespace, &normalized);
61 register_with_ref(namespace, &ref_id, &normalized, title)
62 }
63
64 /// Register a URL under a surface-owned ref ID (for example a `web.run` view).
65 pub(crate) fn register_with_ref(
66 namespace: &str,
67 ref_id: &str,
68 url: &str,
69 title: Option<&str>,
70 ) -> Option<WebCitation> {
71 if namespace.trim().is_empty()
72 || ref_id.trim().is_empty()
73 || ref_id
74 .chars()
75 .any(|ch| ch.is_whitespace() || ch.is_control())
76 {
77 return None;
78 }
79 let url = normalize_http_url(url)?;
80 let key = CitationKey {
81 namespace: namespace.to_string(),
82 ref_id: ref_id.to_string(),
83 };
84 let now = Instant::now();
85 let mut registry = CITATIONS
86 .get_or_init(|| Mutex::new(HashMap::new()))
87 .lock()
88 .unwrap_or_else(std::sync::PoisonError::into_inner);
89 cleanup(&mut registry, now);
90 if let Some(existing) = registry.get_mut(&key) {
91 if existing.citation.url != url {
92 return None;
93 }
94 existing.touched_at = now;
95 if existing.citation.title.is_none() {
96 existing.citation.title = normalized_title(title);
97 }
98 return Some(existing.citation.clone());
99 }
100 if registry.len() >= MAX_CITATIONS
101 && let Some(oldest) = registry
102 .iter()
103 .min_by_key(|(_, entry)| entry.touched_at)
104 .map(|(key, _)| key.clone())
105 {
106 registry.remove(&oldest);
107 }
108 let citation = WebCitation {
109 ref_id: ref_id.to_string(),
110 url,
111 title: normalized_title(title),
112 retrieved_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
113 };
114 citation.evidence_ref().ok()?;
115 registry.insert(
116 key,
117 CitationEntry {
118 citation: citation.clone(),
119 touched_at: now,
120 },
121 );
122 Some(citation)
123 }
124
125 /// Resolve a ref only inside the session that minted it.
126 pub(crate) fn resolve(namespace: &str, ref_id: &str) -> Option<WebCitation> {
127 let key = CitationKey {
128 namespace: namespace.to_string(),
129 ref_id: ref_id.to_string(),
130 };
131 let now = Instant::now();
132 let mut registry = CITATIONS
133 .get_or_init(|| Mutex::new(HashMap::new()))
134 .lock()
135 .unwrap_or_else(std::sync::PoisonError::into_inner);
136 cleanup(&mut registry, now);
137 let entry = registry.get_mut(&key)?;
138 entry.touched_at = now;
139 Some(entry.citation.clone())
140 }
141
142 fn ref_id_for(namespace: &str, url: &str) -> String {
143 let identity = format!("{namespace}\0{url}");
144 let digest = crate::hashing::sha256_hex(identity.as_bytes());
145 format!("web_{}", &digest[..16])
146 }
147
148 fn normalize_http_url(url: &str) -> Option<String> {
149 let mut parsed = reqwest::Url::parse(url.trim()).ok()?;
150 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
151 return None;
152 }
153 let query_pairs: Vec<(String, String)> = parsed
154 .query_pairs()
155 .filter(|(name, _)| !is_sensitive_query_name(name))
156 .map(|(name, value)| (name.into_owned(), value.into_owned()))
157 .collect();
158 let query_was_sanitized = parsed.query_pairs().count() != query_pairs.len();
159 if query_was_sanitized {
160 parsed.set_query(None);
161 if !query_pairs.is_empty() {
162 parsed.query_pairs_mut().extend_pairs(query_pairs);
163 }
164 }
165 parsed.set_fragment(None);
166 if !parsed.username().is_empty() {
167 parsed.set_username("").ok()?;
168 }
169 if parsed.password().is_some() {
170 parsed.set_password(None).ok()?;
171 }
172 Some(parsed.to_string())
173 }
174
175 fn is_sensitive_query_name(name: &str) -> bool {
176 let name = name.to_ascii_lowercase();
177 matches!(
178 name.as_str(),
179 "access_token"
180 | "api_key"
181 | "authorization"
182 | "auth"
183 | "credential"
184 | "key"
185 | "session"
186 | "session_id"
187 | "sig"
188 | "signature"
189 | "token"
190 | "x-amz-credential"
191 | "x-amz-signature"
192 | "x-goog-credential"
193 | "x-goog-signature"
194 ) || name.ends_with("_token")
195 || name.ends_with("_key")
196 }
197
198 fn normalized_title(title: Option<&str>) -> Option<String> {
199 title
200 .map(str::trim)
201 .filter(|title| !title.is_empty())
202 .map(|title| title.chars().take(240).collect())
203 }
204
205 fn cleanup(registry: &mut HashMap<CitationKey, CitationEntry>, now: Instant) {
206 registry.retain(|_, entry| now.duration_since(entry.touched_at) <= CITATION_TTL);
207 }
208
209 #[cfg(test)]
210 mod tests {
211 use super::*;
212
213 #[test]
214 fn refs_are_stable_within_a_session_and_scoped_between_sessions() {
215 let first = register(
216 "session-a",
217 "https://example.com/page#section",
218 Some("Page"),
219 )
220 .expect("valid citation");
221 let repeated =
222 register("session-a", "https://example.com/page", None).expect("same citation");
223 let foreign =
224 register("session-b", "https://example.com/page", None).expect("foreign citation");
225
226 assert_eq!(first.ref_id, repeated.ref_id);
227 assert_eq!(first.retrieved_at, repeated.retrieved_at);
228 assert_ne!(first.ref_id, foreign.ref_id);
229 assert_eq!(first.url, "https://example.com/page");
230 assert!(resolve("session-b", &first.ref_id).is_none());
231 let evidence = first.evidence_ref().expect("citation evidence");
232 assert_eq!(evidence.reference(), first.ref_id);
233 }
234
235 #[test]
236 fn registry_rejects_non_web_urls_and_strips_url_credentials() {
237 assert!(register("session", "javascript:alert(1)", None).is_none());
238 let citation = register(
239 "session",
240 "https://user:password@example.com/path#private",
241 None,
242 )
243 .expect("http citation");
244 assert_eq!(citation.url, "https://example.com/path");
245 assert!(!citation.url.contains("password"));
246 let signed = register(
247 "session",
248 "https://example.com/path?access_token=sensitive&view=full",
249 None,
250 )
251 .expect("credential values are removed from citation metadata");
252 assert_eq!(signed.url, "https://example.com/path?view=full");
253 assert!(!signed.url.contains("sensitive"));
254 }
255
256 #[test]
257 fn explicit_refs_are_validated_and_session_scoped() {
258 assert!(register_with_ref("session", "two words", "https://example.com", None).is_none());
259 let citation = register_with_ref(
260 "session",
261 "s1_turn0view1",
262 "https://example.com",
263 Some("Example"),
264 )
265 .expect("explicit ref");
266 assert_eq!(citation.ref_id, "s1_turn0view1");
267 assert!(resolve("other", "s1_turn0view1").is_none());
268 assert!(
269 register_with_ref(
270 "session",
271 "s1_turn0view1",
272 "https://other.example.com",
273 None
274 )
275 .is_none(),
276 "an existing ref must never be rebound to another URL"
277 );
278 }
279 }
280
280 lines RUST