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}