Skip to main content

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}