Skip to main content

icap_rs/server/
options.rs

1//! ICAP OPTIONS configuration.
2//!
3//! This module provides types to build an ICAP `OPTIONS` response for a given
4//! service. It includes:
5//! - [`Method`](crate::Method) — ICAP methods
6//! - [`TransferBehavior`] — per-extension transfer hints (Preview/Ignore/Complete)
7//! - [`ServiceOptions`] — a builder-like struct that serializes to an ICAP response
8//!   and supports a dynamic `ISTag` provider.
9//!
10//! ## Dynamic `ISTag` provider
11//! Some deployments need the ICAP `ISTag` to reflect a mutable policy (e.g. a
12//! filtering rule-set version). Use [`ServiceOptions::with_istag_provider`] to
13//! supply a closure that computes the `ISTag` *per request* (including `OPTIONS`).
14//! `ServiceOptions` intentionally has no default `ISTag`; services must provide
15//! a static tag or a dynamic provider explicitly.
16//!
17//! ### Example
18//! ```
19//! # use icap_rs::server::options::ServiceOptions;
20//! # use icap_rs::IncomingRequest;
21//! # let state = std::sync::Arc::new(std::sync::Mutex::new(String::from("respmod-1.0")));
22//! let opts = ServiceOptions::new()
23//!     .with_istag_provider({
24//!         let state = state.clone();
25//!         move |_: &IncomingRequest| state.lock().unwrap().clone()
26//!     })
27//!     .with_service("Response Modifier")
28//!     .with_options_ttl(60)
29//!     .allow_204();
30//! ```
31
32use std::sync::{Arc, RwLock};
33
34use crate::error::{Error, IcapResult};
35use crate::request::IncomingRequest;
36use crate::response::Response;
37use std::collections::HashMap;
38
39/// Transfer behavior for file extensions advertised via `Transfer-*` headers.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum TransferBehavior {
42    /// Files should be sent with preview.
43    Preview,
44    /// Files should be ignored.
45    Ignore,
46    /// Files should be sent fully without preview.
47    Complete,
48}
49
50/// Source of the `ISTag` value used in responses.
51///
52/// - `Static`: fixed at configuration time (backward compatible).
53/// - `Dynamic`: computed per incoming request via a user-provided closure.
54///   This allows the `ISTag` to track a mutable policy or any other runtime state.
55#[derive(Clone)]
56pub enum IstagSource {
57    Static(String),
58    Dynamic(Arc<dyn Fn(&IncomingRequest) -> String + Send + Sync>),
59}
60
61impl IstagSource {
62    /// Resolve the current `ISTag` for the given request.
63    #[inline]
64    pub fn current_for(&self, req: &IncomingRequest) -> String {
65        match self {
66            Self::Static(s) => s.clone(),
67            Self::Dynamic(f) => (f)(req),
68        }
69    }
70}
71
72/// A cloneable handle to a mutable `ISTag` value.
73///
74/// Create one with [`IsTagHandle::new`], pass clones to both
75/// [`ServiceOptions::with_dynamic_istag`] and your route handlers, then call
76/// [`IsTagHandle::set`] from a background task whenever the policy reloads.
77///
78/// `IsTagHandle::clone` is cheap — it clones the inner `Arc`, not the string.
79///
80/// # Example
81///
82/// ```rust,no_run
83/// use icap_rs::{IsTagHandle, IncomingRequest, Response, Server, HandlerResult};
84/// use icap_rs::server::options::ServiceOptions;
85/// use std::time::Duration;
86///
87/// #[tokio::main]
88/// async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
89///     let tag = IsTagHandle::new("policy-v1");
90///
91///     // Rotate the tag from a background task on policy reload.
92///     tokio::spawn({
93///         let tag = tag.clone();
94///         async move {
95///             loop {
96///                 tokio::time::sleep(Duration::from_secs(60)).await;
97///                 tag.set("policy-v2");
98///             }
99///         }
100///     });
101///
102///     let server = Server::builder()
103///         .bind("127.0.0.1:1344")
104///         .route_reqmod(
105///             "scan",
106///             move |req: IncomingRequest| {
107///                 // req.istag() returns the tag resolved before the handler was called.
108///                 async move { Ok(Response::no_content_with_istag(req.istag().unwrap_or(""))?) }
109///             },
110///             Some(ServiceOptions::new()
111///                 .with_dynamic_istag(tag)
112///                 .with_service("Scanner")
113///                 .allow_204()),
114///         )
115///         .build()
116///         .await?;
117///
118///     server.run().await?;
119///     Ok(())
120/// }
121/// ```
122#[derive(Clone)]
123pub struct IsTagHandle(Arc<RwLock<String>>);
124
125impl IsTagHandle {
126    /// Create a new handle with the given initial tag value.
127    pub fn new(initial: impl Into<String>) -> Self {
128        Self(Arc::new(RwLock::new(initial.into())))
129    }
130
131    /// Replace the current tag value.
132    ///
133    /// All `ServiceOptions` and handlers that share this handle will see the
134    /// new value on their next request.
135    pub fn set(&self, tag: impl Into<String>) {
136        *self.0.write().expect("IsTagHandle lock poisoned") = tag.into();
137    }
138
139    /// Read the current tag value.
140    pub fn current(&self) -> String {
141        self.0.read().expect("IsTagHandle lock poisoned").clone()
142    }
143}
144
145impl From<IsTagHandle> for IstagSource {
146    fn from(h: IsTagHandle) -> Self {
147        Self::Dynamic(Arc::new(move |_: &IncomingRequest| h.current()))
148    }
149}
150
151/// Configuration for generating an ICAP `OPTIONS` response.
152#[derive(Clone)]
153#[must_use]
154pub struct ServiceOptions {
155    /// Human-readable service description (optional).
156    pub(crate) service: Option<String>,
157    /// `ISTag` source (static or dynamic provider).
158    pub(crate) istag: Option<IstagSource>,
159    /// Max concurrent connections hint (optional).
160    pub(crate) max_connections: Option<usize>,
161    /// Maximum embedded HTTP object size advertised and enforced by the service.
162    pub(crate) max_object_size: Option<usize>,
163    /// TTL (seconds) for caching the OPTIONS response (optional).
164    pub(crate) options_ttl: Option<u32>,
165    /// Short service identifier (optional).
166    pub(crate) service_id: Option<String>,
167    /// Capabilities advertised in `Allow` (optional), e.g. `"204"`.
168    pub(crate) allow: Vec<String>,
169    /// `Preview` size in bytes (optional).
170    pub(crate) preview: Option<u32>,
171    /// Per-extension transfer behavior (`Transfer-*` headers).
172    pub(crate) transfer_rules: HashMap<String, TransferBehavior>,
173    /// Default transfer behavior applied when an extension is not matched.
174    pub(crate) default_transfer_behavior: Option<TransferBehavior>,
175    /// Extra custom headers to include as `Header: Value`.
176    pub(crate) custom_headers: HashMap<String, String>,
177    /// `Opt-body-type` (if `opt-body` is present).
178    pub(crate) opt_body_type: Option<String>,
179    /// Optional message body to advertise via `Encapsulated: opt-body=0`.
180    pub(crate) opt_body: Option<Vec<u8>>,
181}
182
183impl Default for ServiceOptions {
184    fn default() -> Self {
185        Self::new()
186    }
187}
188
189impl ServiceOptions {
190    /// Create a new OPTIONS config without an `ISTag`.
191    ///
192    /// ICAP success responses require an explicit `ISTag`. Call
193    /// [`with_static_istag`](Self::with_static_istag) or
194    /// [`with_istag_provider`](Self::with_istag_provider) before registering
195    /// this config on a server route.
196    pub fn new() -> Self {
197        Self {
198            service: None,
199            istag: None,
200            max_connections: None,
201            max_object_size: None,
202            options_ttl: None,
203            service_id: None,
204            allow: Vec::new(),
205            preview: None,
206            transfer_rules: HashMap::new(),
207            default_transfer_behavior: None,
208            custom_headers: HashMap::new(),
209            opt_body_type: None,
210            opt_body: None,
211        }
212    }
213
214    /// Provide a **dynamic `ISTag` provider** that will be invoked for **each request**
215    /// (including `OPTIONS`). The closure should be fast and lock-free if possible.
216    ///
217    /// The provider returns the logical tag value. It may return a raw token
218    /// such as `policy-1` or a base64-like value such as `QUJD+/8=`; generated
219    /// ICAP responses quote the value on the wire per RFC 3507.
220    ///
221    /// Typical sources include: a version string stored in an `Arc<RwLock<String>>`,
222    /// an atomic epoch counter, or a lightweight in-process cache.
223    ///
224    /// # Example
225    /// ```
226    /// # use std::sync::{Arc, RwLock};
227    /// # use icap_rs::server::options::ServiceOptions;
228    /// # use icap_rs::IncomingRequest;
229    /// let tag = Arc::new(RwLock::new(String::from("respmod-1.0")));
230    /// let opts = ServiceOptions::new()
231    ///     .with_istag_provider({
232    ///         let tag = tag.clone();
233    ///         move |_: &IncomingRequest| tag.read().unwrap().clone()
234    ///     });
235    /// ```
236    pub fn with_istag_provider<F>(mut self, f: F) -> Self
237    where
238        F: Fn(&IncomingRequest) -> String + Send + Sync + 'static,
239    {
240        self.istag = Some(IstagSource::Dynamic(Arc::new(f)));
241        self
242    }
243
244    /// Use a **static** `ISTag` for responses.
245    ///
246    /// The value may be passed as a raw token such as `policy-1` or
247    /// `QUJD+/8=`. Generated ICAP responses quote it on the wire per RFC 3507.
248    pub fn with_static_istag(mut self, istag: &str) -> Self {
249        self.istag = Some(IstagSource::Static(istag.to_string()));
250        self
251    }
252
253    /// Use an [`IsTagHandle`] as the `ISTag` source.
254    ///
255    /// This is the preferred way to wire up a dynamically-rotating tag.
256    /// The handle can be shared with route handlers via `Clone`; call
257    /// [`IsTagHandle::set`] from anywhere to rotate the tag atomically.
258    pub fn with_dynamic_istag(mut self, handle: IsTagHandle) -> Self {
259        self.istag = Some(handle.into());
260        self
261    }
262
263    /// Set the human-readable service description.
264    pub fn with_service(mut self, service: &str) -> Self {
265        self.service = Some(service.to_string());
266        self
267    }
268
269    /// Router-only: set Max-Connections from global advertised limit if not set.
270    pub(crate) const fn with_max_connections(&mut self, n: usize) {
271        self.max_connections = Some(n);
272    }
273
274    /// Set the maximum embedded HTTP object size, in bytes.
275    ///
276    /// The value is advertised in `OPTIONS` as `Max-Object-Size` and enforced by
277    /// the server for this service by counting decoded ICAP chunked body bytes.
278    /// Embedded HTTP `Content-Length` is not trusted for enforcement because
279    /// peers may send a value that differs from the actual body.
280    pub const fn with_max_object_size(mut self, bytes: usize) -> Self {
281        self.max_object_size = Some(bytes);
282        self
283    }
284
285    /// Set `Options-TTL` (seconds).
286    pub const fn with_options_ttl(mut self, ttl: u32) -> Self {
287        self.options_ttl = Some(ttl);
288        self
289    }
290
291    /// Set short service ID.
292    pub fn with_service_id(mut self, service_id: &str) -> Self {
293        self.service_id = Some(service_id.to_string());
294        self
295    }
296
297    /// Add a capability to `Allow` (e.g. `"204"`).
298    pub fn add_allow(mut self, capability: &str) -> Self {
299        self.allow.push(capability.to_string());
300        self
301    }
302
303    /// Advertise support for `204 No Content` no-modification responses.
304    ///
305    /// This is equivalent to `add_allow("204")`, but avoids stringly typed
306    /// capability values in normal service configuration.
307    pub fn allow_204(self) -> Self {
308        self.add_allow_once("204")
309    }
310
311    /// Advertise support for `206 Partial Content` no-modification responses.
312    ///
313    /// This is equivalent to `add_allow("206")`, but avoids stringly typed
314    /// capability values in normal service configuration.
315    pub fn allow_206(self) -> Self {
316        self.add_allow_once("206")
317    }
318
319    /// Set Preview size (bytes).
320    pub const fn with_preview(mut self, preview: u32) -> Self {
321        self.preview = Some(preview);
322        self
323    }
324
325    /// Add rule for a file extension (e.g. "pdf", "exe").
326    pub fn add_transfer_rule(mut self, extension: &str, behavior: TransferBehavior) -> Self {
327        self.transfer_rules.insert(extension.to_string(), behavior);
328        self
329    }
330
331    /// Set default transfer behavior (applied when an extension is not matched).
332    pub const fn with_default_transfer_behavior(mut self, behavior: TransferBehavior) -> Self {
333        self.default_transfer_behavior = Some(behavior);
334        self
335    }
336
337    /// Add a custom header.
338    pub fn add_custom_header(mut self, name: &str, value: &str) -> Self {
339        self.custom_headers
340            .insert(name.to_string(), value.to_string());
341        self
342    }
343
344    /// Advertise an opt-body in the service's `OPTIONS` response (RFC 3507 §4.10).
345    ///
346    /// The generated `OPTIONS` response sets `Encapsulated: opt-body=0`, adds an
347    /// `Opt-body-type: <body_type>` header, and serializes `body` as a single
348    /// ICAP chunk terminated by `0\r\n\r\n`. `body_type` describes the payload
349    /// (for example `"text/plain"` or a service-defined token); it is required
350    /// whenever an opt-body is present and is checked by
351    /// [`ServiceOptions::validate`] at server build time.
352    ///
353    /// A client reading the `OPTIONS` response receives the dechunked bytes via
354    /// `Response::body()`.
355    ///
356    /// # Examples
357    ///
358    /// ```
359    /// use icap_rs::server::options::ServiceOptions;
360    ///
361    /// let options = ServiceOptions::new()
362    ///     .with_static_istag("opt-1.0")
363    ///     .with_service("Scanner")
364    ///     .with_opt_body("text/plain", b"server info".to_vec());
365    /// ```
366    pub fn with_opt_body(mut self, body_type: &str, body: Vec<u8>) -> Self {
367        self.opt_body_type = Some(body_type.to_string());
368        self.opt_body = Some(body);
369        self
370    }
371
372    /// Resolve the `ISTag` for a specific request (static or dynamic).
373    #[inline]
374    pub(crate) fn istag_for(&self, req: &IncomingRequest) -> IcapResult<String> {
375        self.istag
376            .as_ref()
377            .map(|source| source.current_for(req))
378            .ok_or_else(|| Error::missing_header("ISTag"))
379    }
380
381    /// Validate invariants for this configuration.
382    ///
383    /// For dynamic `ISTag` providers it is not possible to validate non-emptiness
384    /// at configuration time; perform validation when computing the value if needed.
385    pub fn validate(&self) -> Result<(), String> {
386        if self.istag.is_none() {
387            return Err(
388                "ISTag must be configured explicitly with with_static_istag or with_istag_provider"
389                    .to_string(),
390            );
391        }
392        if !self.transfer_rules.is_empty() && self.default_transfer_behavior.is_none() {
393            return Err(
394                "Default transfer behavior must be set when transfer rules are defined".to_string(),
395            );
396        }
397        if self.opt_body.is_some() && self.opt_body_type.is_none() {
398            return Err("Opt-body-type must be set when opt-body is present".to_string());
399        }
400        Ok(())
401    }
402
403    fn add_allow_once(mut self, capability: &str) -> Self {
404        if !self.allow.iter().any(|c| c == capability) {
405            self.allow.push(capability.to_string());
406        }
407        self
408    }
409}
410
411/// Assembles an ICAP `OPTIONS` response from a [`ServiceOptions`] config and
412/// the set of methods the router registered for the service.
413///
414/// Kept `pub(crate)` — callers outside this crate interact only with
415/// [`ServiceOptions`] and never need to construct a response directly.
416pub(crate) struct OptionsResponseBuilder<'a> {
417    options: &'a ServiceOptions,
418    methods_str: &'a str,
419}
420
421impl<'a> OptionsResponseBuilder<'a> {
422    pub(crate) const fn new(options: &'a ServiceOptions, methods_str: &'a str) -> Self {
423        Self {
424            options,
425            methods_str,
426        }
427    }
428
429    /// Build the `OPTIONS` response for the given incoming request.
430    pub(crate) fn build(self, req: &IncomingRequest) -> IcapResult<Response> {
431        let istag_now = self.options.istag_for(req)?;
432        let mut response = Response::ok_with_istag(&istag_now)?;
433
434        response = response.add_header("Methods", self.methods_str);
435
436        let encapsulated_value = if self.options.opt_body.is_some() {
437            "opt-body=0"
438        } else {
439            "null-body=0"
440        };
441        response = response.add_header("Encapsulated", encapsulated_value);
442
443        if let Some(ref service) = self.options.service {
444            response = response.add_header("Service", service);
445        }
446        if let Some(max_conn) = self.options.max_connections {
447            response = response.add_header("Max-Connections", &max_conn.to_string());
448        }
449        if let Some(max_object_size) = self.options.max_object_size {
450            response = response.add_header("Max-Object-Size", &max_object_size.to_string());
451        }
452        if let Some(ttl) = self.options.options_ttl {
453            response = response.add_header("Options-TTL", &ttl.to_string());
454        }
455        if let Some(ref service_id) = self.options.service_id {
456            response = response.add_header("Service-ID", service_id);
457        }
458        if !self.options.allow.is_empty() {
459            response = response.add_header("Allow", &self.options.allow.join(", "));
460        }
461        if let Some(preview) = self.options.preview {
462            response = response.add_header("Preview", &preview.to_string());
463        }
464        if let Some(ref opt_body_type) = self.options.opt_body_type {
465            response = response.add_header("Opt-body-type", opt_body_type);
466        }
467
468        // Transfer-* headers
469        if !self.options.transfer_rules.is_empty() {
470            let mut preview_extensions = Vec::new();
471            let mut ignore_extensions = Vec::new();
472            let mut complete_extensions = Vec::new();
473
474            for (ext, behavior) in &self.options.transfer_rules {
475                match behavior {
476                    TransferBehavior::Preview => preview_extensions.push(ext.clone()),
477                    TransferBehavior::Ignore => ignore_extensions.push(ext.clone()),
478                    TransferBehavior::Complete => complete_extensions.push(ext.clone()),
479                }
480            }
481            if let Some(ref default_behavior) = self.options.default_transfer_behavior {
482                match default_behavior {
483                    TransferBehavior::Preview => preview_extensions.push("*".into()),
484                    TransferBehavior::Ignore => ignore_extensions.push("*".into()),
485                    TransferBehavior::Complete => complete_extensions.push("*".into()),
486                }
487            }
488            if !preview_extensions.is_empty() {
489                response = response.add_header("Transfer-Preview", &preview_extensions.join(", "));
490            }
491            if !ignore_extensions.is_empty() {
492                response = response.add_header("Transfer-Ignore", &ignore_extensions.join(", "));
493            }
494            if !complete_extensions.is_empty() {
495                response =
496                    response.add_header("Transfer-Complete", &complete_extensions.join(", "));
497            }
498        }
499
500        for (name, value) in &self.options.custom_headers {
501            response = response.add_header(name, value);
502        }
503        if let Some(ref opt_body) = self.options.opt_body {
504            response = response.with_body(opt_body);
505        }
506
507        Ok(response)
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    #[test]
516    fn typed_allow_helpers_advertise_no_modification_capabilities() {
517        let opts = ServiceOptions::new().allow_204().allow_206();
518
519        assert_eq!(opts.allow, ["204", "206"]);
520    }
521
522    #[test]
523    fn typed_allow_helpers_do_not_duplicate_capabilities() {
524        let opts = ServiceOptions::new()
525            .allow_204()
526            .allow_204()
527            .allow_206()
528            .allow_206();
529
530        assert_eq!(opts.allow, ["204", "206"]);
531    }
532}