icap_rs/client/options_cache.rs
1//! Client-side OPTIONS response cache (RFC 3507 §4.10 / §5).
2//!
3//! When enabled on a [`ClientBuilder`](crate::ClientBuilder), the client may
4//! fetch an `OPTIONS` response for a service once and reuse it for subsequent
5//! `REQMOD`/`RESPMOD` requests until it expires. Per RFC 3507 §4.10.2 the
6//! lifetime is taken from the `Options-TTL` header; when the server omits it,
7//! the configured [`OptionsCacheConfig::default_ttl`] is used instead. With
8//! neither a header nor a configured fallback the response is **not** cached.
9//!
10//! RFC 3507 §5 requires the client to invalidate a cached entry when the
11//! `ISTag` observed on a later `REQMOD`/`RESPMOD` response differs from the one
12//! captured at `OPTIONS` time; `OptionsCache::reconcile_istag` implements
13//! that rule.
14//!
15//! RFC 3507 §4.10.2 defines `Transfer-Preview`, `Transfer-Ignore`, and
16//! `Transfer-Complete` headers that tell the client how to handle objects by
17//! file extension. `OptionsCache::resolve_transfer` looks up the action for a
18//! given extension from the cached OPTIONS response.
19
20use crate::response::ParsedResponse;
21use std::collections::HashMap;
22use std::time::{Duration, Instant};
23use tokio::sync::RwLock;
24
25/// Configuration for the client-side OPTIONS cache.
26///
27/// The cache is opt-in: a [`Client`](crate::Client) only caches `OPTIONS`
28/// responses when a configuration is supplied via
29/// [`ClientBuilder::with_options_cache`](crate::ClientBuilder::with_options_cache).
30///
31/// # Examples
32///
33/// ```
34/// use std::time::Duration;
35/// use icap_rs::OptionsCacheConfig;
36///
37/// // Cache OPTIONS for 5 minutes when the server does not send `Options-TTL`.
38/// let config = OptionsCacheConfig::new().with_default_ttl(Duration::from_secs(300));
39/// assert_eq!(config.default_ttl(), Some(Duration::from_secs(300)));
40/// ```
41#[derive(Debug, Clone, Default)]
42#[must_use]
43pub struct OptionsCacheConfig {
44 default_ttl: Option<Duration>,
45}
46
47impl OptionsCacheConfig {
48 /// Create a configuration with no fallback TTL.
49 ///
50 /// With no fallback, only responses that carry an `Options-TTL` header are
51 /// cached.
52 pub const fn new() -> Self {
53 Self { default_ttl: None }
54 }
55
56 /// Set the fallback lifetime used when a response has no `Options-TTL`.
57 pub const fn with_default_ttl(mut self, ttl: Duration) -> Self {
58 self.default_ttl = Some(ttl);
59 self
60 }
61
62 /// Return the configured fallback lifetime, if any.
63 #[must_use]
64 pub const fn default_ttl(&self) -> Option<Duration> {
65 self.default_ttl
66 }
67}
68
69// ---------------------------------------------------------------------------
70// Transfer-* policy (RFC 3507 §4.10.2)
71// ---------------------------------------------------------------------------
72
73/// Action the client should take for a request based on the server's
74/// `Transfer-Preview`, `Transfer-Ignore`, and `Transfer-Complete` OPTIONS
75/// response headers (RFC 3507 §4.10.2).
76///
77/// Priority (highest first): `Full` > `Skip` > `Preview`.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub(crate) enum TransferAction {
80 /// Send the complete body without preview (`Transfer-Complete`).
81 Full,
82 /// Skip the ICAP transaction; return a synthetic 204 without contacting
83 /// the server (`Transfer-Ignore`).
84 Skip,
85 /// Send the first `n` bytes as a preview and wait for `100 Continue`
86 /// (`Transfer-Preview`). `n` is taken from the OPTIONS `Preview` header.
87 Preview(usize),
88}
89
90// ---------------------------------------------------------------------------
91// CachedOptions
92// ---------------------------------------------------------------------------
93
94/// A cached `OPTIONS` result for a single service endpoint.
95#[derive(Debug, Clone)]
96pub(crate) struct CachedOptions {
97 /// `ISTag` captured at `OPTIONS` time.
98 istag: Option<String>,
99 /// Instant after which the entry is considered stale.
100 expires_at: Instant,
101 /// File extensions for which the server requests preview bytes.
102 transfer_preview: Vec<String>,
103 /// File extensions that the server wants bypassed (no ICAP scan).
104 transfer_ignore: Vec<String>,
105 /// File extensions for which the server wants the full body (no preview).
106 transfer_complete: Vec<String>,
107 /// Preview size (bytes) advertised in the OPTIONS `Preview` header.
108 /// Used as the preview length when `Transfer-Preview` matches.
109 preview_size: Option<usize>,
110}
111
112impl CachedOptions {
113 /// Build a cache entry from a parsed `OPTIONS` response.
114 ///
115 /// Returns `None` when no lifetime can be determined: the `Options-TTL`
116 /// header (in seconds) takes precedence, otherwise the configured
117 /// [`OptionsCacheConfig::default_ttl`]. With neither, the response is not
118 /// cacheable. Also returns `None` if the resulting expiry instant would
119 /// overflow.
120 pub(crate) fn from_response(
121 response: &ParsedResponse,
122 config: &OptionsCacheConfig,
123 ) -> Option<Self> {
124 let ttl = parse_options_ttl(response).or_else(|| config.default_ttl())?;
125 let expires_at = Instant::now().checked_add(ttl)?;
126 let istag = response
127 .get_header("ISTag")
128 .and_then(|value| value.to_str().ok())
129 .map(str::to_string);
130 Some(Self {
131 istag,
132 expires_at,
133 transfer_preview: parse_extensions(response, "Transfer-Preview"),
134 transfer_ignore: parse_extensions(response, "Transfer-Ignore"),
135 transfer_complete: parse_extensions(response, "Transfer-Complete"),
136 preview_size: parse_preview_size(response),
137 })
138 }
139
140 /// Whether the entry has not yet expired.
141 fn is_fresh(&self) -> bool {
142 Instant::now() < self.expires_at
143 }
144
145 /// Determine the transfer action for a request based on its file extension.
146 ///
147 /// Returns `None` when the extension does not match any of the server's
148 /// `Transfer-*` policies and the request should proceed with its own
149 /// preview settings.
150 ///
151 /// Priority (RFC 3507 §4.10.2): `Full` > `Skip` > `Preview`.
152 fn transfer_action(&self, file_ext: &str) -> Option<TransferAction> {
153 let matches = |list: &[String]| list.iter().any(|e| e == "*" || e == file_ext);
154
155 if matches(&self.transfer_complete) {
156 return Some(TransferAction::Full);
157 }
158 if matches(&self.transfer_ignore) {
159 return Some(TransferAction::Skip);
160 }
161 if matches(&self.transfer_preview) {
162 return Some(TransferAction::Preview(self.preview_size.unwrap_or(0)));
163 }
164 None
165 }
166}
167
168// ---------------------------------------------------------------------------
169// Header parsing helpers
170// ---------------------------------------------------------------------------
171
172/// Parse the `Options-TTL` header (integer seconds) into a [`Duration`].
173///
174/// Returns `None` when the header is absent, non-numeric, or zero.
175/// `Options-TTL: 0` is treated as "do not cache" (analogous to HTTP
176/// `Cache-Control: max-age=0`), so the caller falls back to
177/// [`OptionsCacheConfig::default_ttl`] and, if that is also absent, skips
178/// caching entirely.
179fn parse_options_ttl(response: &ParsedResponse) -> Option<Duration> {
180 let raw = response.get_header("Options-TTL")?.to_str().ok()?;
181 let seconds: u64 = raw.trim().parse().ok()?;
182 // 0 means "immediately expired" — don't cache.
183 if seconds == 0 {
184 return None;
185 }
186 Some(Duration::from_secs(seconds))
187}
188
189/// Parse the `Preview` header (integer bytes) for use as a preview size.
190fn parse_preview_size(response: &ParsedResponse) -> Option<usize> {
191 let raw = response.get_header("Preview")?.to_str().ok()?;
192 raw.trim().parse().ok()
193}
194
195/// Parse a `Transfer-*` header as a comma-separated list of lowercase
196/// file extensions (without leading dot). `"*"` is preserved as-is.
197fn parse_extensions(response: &ParsedResponse, header: &str) -> Vec<String> {
198 let Some(value) = response.get_header(header) else {
199 return Vec::new();
200 };
201 let Ok(s) = value.to_str() else {
202 return Vec::new();
203 };
204 s.split(',')
205 .map(|e| e.trim().to_lowercase())
206 .filter(|e| !e.is_empty())
207 .collect()
208}
209
210// ---------------------------------------------------------------------------
211// OptionsCache
212// ---------------------------------------------------------------------------
213
214/// Cache key: target host, port, and normalized service path.
215type CacheKey = (String, u16, String);
216
217/// Concurrent store of cached `OPTIONS` results keyed by service endpoint.
218#[derive(Debug)]
219pub(crate) struct OptionsCache {
220 config: OptionsCacheConfig,
221 entries: RwLock<HashMap<CacheKey, CachedOptions>>,
222}
223
224impl OptionsCache {
225 /// Create an empty cache with the given configuration.
226 pub(crate) fn new(config: OptionsCacheConfig) -> Self {
227 Self {
228 config,
229 entries: RwLock::new(HashMap::new()),
230 }
231 }
232
233 /// Return the cache configuration.
234 pub(crate) const fn config(&self) -> &OptionsCacheConfig {
235 &self.config
236 }
237
238 /// Whether a fresh (non-expired) entry exists for the endpoint.
239 pub(crate) async fn has_fresh(&self, host: &str, port: u16, path: &str) -> bool {
240 let key = (host.to_string(), port, path.to_string());
241 let entries = self.entries.read().await;
242 entries.get(&key).is_some_and(CachedOptions::is_fresh)
243 }
244
245 /// Insert or replace the cached entry for the endpoint.
246 pub(crate) async fn store(&self, host: &str, port: u16, path: &str, entry: CachedOptions) {
247 let key = (host.to_string(), port, path.to_string());
248 let mut entries = self.entries.write().await;
249 entries.insert(key, entry);
250 }
251
252 /// Return the server-requested transfer action for `file_ext` based on
253 /// the cached OPTIONS response (RFC 3507 §4.10.2).
254 ///
255 /// Returns `None` when there is no fresh cache entry or when the extension
256 /// does not match any `Transfer-*` policy. In that case the caller should
257 /// use the request's own preview settings unchanged.
258 pub(crate) async fn resolve_transfer(
259 &self,
260 host: &str,
261 port: u16,
262 path: &str,
263 file_ext: &str,
264 ) -> Option<TransferAction> {
265 let key = (host.to_string(), port, path.to_string());
266 self.entries
267 .read()
268 .await
269 .get(&key)
270 .filter(|entry| entry.is_fresh())
271 .and_then(|entry| entry.transfer_action(file_ext))
272 }
273
274 /// Invalidate the cached entry when an observed `ISTag` differs from the
275 /// captured one.
276 ///
277 /// A missing `observed` value carries no information and leaves the cache
278 /// untouched. This implements the RFC 3507 §5 rule that a changed `ISTag`
279 /// on a `REQMOD`/`RESPMOD` response makes the cached `OPTIONS` stale.
280 pub(crate) async fn reconcile_istag(
281 &self,
282 host: &str,
283 port: u16,
284 path: &str,
285 observed: Option<&str>,
286 ) {
287 let Some(observed) = observed else {
288 return;
289 };
290 let key = (host.to_string(), port, path.to_string());
291
292 // Fast path (warm cache, no ISTag change): read lock only.
293 let matches_observed = self
294 .entries
295 .read()
296 .await
297 .get(&key)
298 .map(|entry| entry.istag.as_deref() == Some(observed));
299 match matches_observed {
300 Some(true) | None => return,
301 Some(false) => {}
302 }
303
304 // Slow path: ISTag changed — acquire write lock and remove the entry.
305 let mut entries = self.entries.write().await;
306 if let Some(entry) = entries.get(&key)
307 && entry.istag.as_deref() != Some(observed)
308 {
309 entries.remove(&key);
310 }
311 }
312
313 /// Drop every cached entry, forcing a re-fetch on the next request.
314 pub(crate) async fn clear(&self) {
315 let mut entries = self.entries.write().await;
316 entries.clear();
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use crate::response::parse_icap_response;
324
325 fn parsed(raw: &[u8]) -> ParsedResponse {
326 parse_icap_response(raw).expect("test response must parse")
327 }
328
329 fn response_with_ttl(ttl: &str) -> ParsedResponse {
330 parsed(
331 format!(
332 "ICAP/1.0 200 OK\r\nISTag: \"x\"\r\nOptions-TTL: {ttl}\r\nEncapsulated: null-body=0\r\n\r\n"
333 )
334 .as_bytes(),
335 )
336 }
337
338 #[test]
339 fn positive_ttl_is_parsed() {
340 assert_eq!(
341 parse_options_ttl(&response_with_ttl("60")),
342 Some(Duration::from_mins(1))
343 );
344 }
345
346 #[test]
347 fn zero_ttl_returns_none() {
348 // Options-TTL: 0 means "do not cache" — same as HTTP Cache-Control: max-age=0.
349 assert_eq!(parse_options_ttl(&response_with_ttl("0")), None);
350 }
351
352 #[test]
353 fn missing_options_ttl_header_returns_none() {
354 let resp = parsed(b"ICAP/1.0 200 OK\r\nISTag: \"x\"\r\nEncapsulated: null-body=0\r\n\r\n");
355 assert_eq!(parse_options_ttl(&resp), None);
356 }
357
358 #[test]
359 fn non_numeric_ttl_returns_none() {
360 assert_eq!(parse_options_ttl(&response_with_ttl("abc")), None);
361 }
362}