Skip to main content

libfw_core/
capabilities.rs

1//! Server capability advertisement (`GET /capabilities` payload).
2//!
3//! The server *declares* what it supports (compression formats & levels,
4//! tuning-parameter ranges); the **client decides** the concrete values
5//! inside those ranges. This module holds the serde shapes, the libfw
6//! defaults and a stable capability hash so clients can detect when a
7//! server's advertisement changed under them (e.g. for cache invalidation).
8//!
9//! # Stability of `caps_hash`
10//!
11//! [`Capabilities::caps_hash`] hashes the canonical JSON encoding of the
12//! struct (serde_json preserves declared field order), so equal values
13//! always produce equal hashes across processes and versions.
14
15use crate::compress::{CompressionFormat, ZRIP_DEFAULT_LEVEL, ZRIP_MAX_LEVEL, ZRIP_MIN_LEVEL};
16use crate::constants::{
17    MAX_RETRIES,
18    protocol_header_value,
19};
20use sha2::{Digest, Sha256};
21
22/// Inclusive integer range with a server-chosen default.
23///
24/// `min`/`max` are hard bounds the server can enforce; `default` is what a
25/// client gets by not asking at all.
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
27pub struct IntRange {
28    pub default: i64,
29    pub max: i64,
30    pub min: i64,
31}
32
33impl IntRange {
34    /// Clamp `v` into `min..=max`.
35    ///
36    /// The bounds arrive from a peer we do not control, so an inverted (or
37    /// otherwise empty) range falls back to `default` instead of panicking
38    /// the way `i64::clamp` would — the same guard
39    /// [`ZripLevels::clamp_level`] applies.
40    pub fn clamp(&self, v: i64) -> i64 {
41        if self.min <= self.max {
42            v.clamp(self.min, self.max)
43        } else {
44            self.default
45        }
46    }
47
48    /// Whether `v` lies inside `min..=max`.
49    pub fn contains(&self, v: i64) -> bool {
50        (self.min..=self.max).contains(&v)
51    }
52}
53
54/// Zrip (zstd) level range advertised by the server.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
56pub struct ZripLevels {
57    pub default: i32,
58    pub max: i32,
59    pub min: i32,
60}
61
62impl Default for ZripLevels {
63    fn default() -> Self {
64        ZripLevels {
65            default: ZRIP_DEFAULT_LEVEL,
66            max: ZRIP_MAX_LEVEL,
67            min: ZRIP_MIN_LEVEL,
68        }
69    }
70}
71
72impl ZripLevels {
73    /// Clamp `level` into `min..=max` (falls back to `default` when the
74    /// server advertises an empty range).
75    pub fn clamp_level(&self, level: i32) -> i32 {
76        if self.min <= self.max {
77            level.clamp(self.min, self.max)
78        } else {
79            self.default
80        }
81    }
82
83    /// Whether `level` is inside the advertised range.
84    pub fn contains(&self, level: i32) -> bool {
85        (self.min..=self.max).contains(&level)
86    }
87}
88
89/// Compression capabilities: formats served and zrip level range.
90#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
91pub struct CompressionCaps {
92    /// Formats in preference order; `identity` is always implicitly allowed.
93    pub formats: Vec<CompressionFormat>,
94    /// Advertised zrip level range (meaningless when `zrip` ∉ `formats`).
95    #[serde(rename = "zripLevels")]
96    pub zrip_levels: ZripLevels,
97}
98
99impl Default for CompressionCaps {
100    fn default() -> Self {
101        CompressionCaps {
102            formats: vec![CompressionFormat::None, CompressionFormat::Zrip],
103            zrip_levels: ZripLevels::default(),
104        }
105    }
106}
107
108impl CompressionCaps {
109    /// Whether `format` is advertised.
110    pub fn supports(&self, format: CompressionFormat) -> bool {
111        self.formats.contains(&format)
112    }
113}
114
115/// Tuning-parameter ranges the server will tolerate.
116///
117/// `maxUploadSize`, `maxRetries` and `timeoutMs` are plain scalar limits; the
118/// rest are range hints with the server-chosen default first in the payload.
119#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
120#[serde(rename_all = "camelCase")]
121pub struct Limits {
122    /// Cross-file fan-out / global in-flight request cap.
123    pub concurrency: IntRange,
124    /// Per-file upload in-flight chunk window.
125    pub upload_window: IntRange,
126    /// Per-file download in-flight chunk window.
127    pub download_window: IntRange,
128    /// Upload chunk size (bytes).
129    pub chunk_size: IntRange,
130    /// Hard cap on a single upload body (bytes).
131    ///
132    /// Advisory and conservative by default — [`ServerState::capabilities`]
133    /// clamps it to the limit the server actually enforces, so the
134    /// advertisement can never overstate it.
135    ///
136    /// [`ServerState::capabilities`]: https://docs.rs/libfw-server/latest/libfw_server/struct.ServerState.html#method.capabilities
137    pub max_upload_size: u64,
138    /// Per-chunk retry budget.
139    pub max_retries: u64,
140    /// Request timeout the server will honor (ms).
141    pub timeout_ms: u64,
142}
143
144impl Default for Limits {
145    fn default() -> Self {
146        Limits {
147            concurrency: IntRange {
148                default: 4,
149                max: 4,
150                min: 1,
151            },
152            upload_window: IntRange {
153                default: 8,
154                max: 8,
155                min: 1,
156            },
157            download_window: IntRange {
158                default: 4,
159                max: 4,
160                min: 1,
161            },
162            chunk_size: IntRange {
163                default: 2 * 1024 * 1024,
164                max: 8 * 1024 * 1024,
165                min: 256 * 1024,
166            },
167            max_upload_size: 20 * 1024 * 1024 * 1024,
168            max_retries: MAX_RETRIES as u64,
169            timeout_ms: 60_000,
170        }
171    }
172}
173
174/// Full capability advertisement served at `GET /capabilities`.
175#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
176pub struct Capabilities {
177    /// Wire protocol name/version (e.g. `libfw/1`).
178    pub protocol: String,
179    pub compression: CompressionCaps,
180    pub limits: Limits,
181}
182
183impl Default for Capabilities {
184    fn default() -> Self {
185        Capabilities {
186            protocol: protocol_header_value().to_string(),
187            compression: CompressionCaps::default(),
188            limits: Limits::default(),
189        }
190    }
191}
192
193impl Capabilities {
194    /// Stable, content-addressed hash of this advertisement.
195    ///
196    /// Format: `sha256-<64 hex chars>`. Used by clients to invalidate cached
197    /// tuning state when the server changes what it advertises.
198    pub fn caps_hash(&self) -> String {
199        // serde_json::to_vec cannot fail for these plain data types.
200        let bytes = serde_json::to_vec(self).expect("capabilities serialization cannot fail");
201        let digest = Sha256::digest(&bytes);
202        format!("sha256-{}", hex(&digest))
203    }
204
205    /// Clamp a zrip level into the advertised range.
206    pub fn clamp_level(&self, level: i32) -> i32 {
207        self.compression.zrip_levels.clamp_level(level)
208    }
209
210    /// Whether `level` is inside the advertised range.
211    pub fn is_valid_level(&self, level: i32) -> bool {
212        self.compression.zrip_levels.contains(level)
213    }
214}
215
216/// Lowercase hex encoding of a digest (avoids pulling `hex` for one format).
217fn hex(bytes: &[u8]) -> String {
218    const HEX: &[u8; 16] = b"0123456789abcdef";
219    let mut out = String::with_capacity(bytes.len() * 2);
220    for &b in bytes {
221        out.push(HEX[(b >> 4) as usize] as char);
222        out.push(HEX[(b & 0x0f) as usize] as char);
223    }
224    out
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::compress::{compressor_with_level, is_valid_zrip_level, negotiate_level};
231
232    #[test]
233    fn default_caps_serde_roundtrip() {
234        let caps = Capabilities::default();
235        let json = serde_json::to_string(&caps).unwrap();
236        let back: Capabilities = serde_json::from_str(&json).unwrap();
237        assert_eq!(back, caps);
238        // Spot-check the wire shape a server will serve.
239        assert!(json.contains("\"protocol\":\"libfw/1\""));
240        assert!(json.contains("\"zripLevels\""));
241        assert!(json.contains("\"uploadWindow\""));
242        assert!(json.contains("\"maxUploadSize\""));
243        assert!(json.contains("\"identity\""));
244    }
245
246    #[test]
247    fn caps_hash_is_stable_and_content_addressed() {
248        let a = Capabilities::default();
249        let b = Capabilities::default();
250        assert_eq!(a.caps_hash(), b.caps_hash());
251        assert!(a.caps_hash().starts_with("sha256-"));
252        assert_eq!(a.caps_hash().len(), "sha256-".len() + 64);
253
254        let mut changed = a.clone();
255        changed.limits.concurrency.default = 6;
256        assert_ne!(a.caps_hash(), changed.caps_hash());
257
258        let mut changed2 = a.clone();
259        changed2.compression.zrip_levels.max = 3;
260        assert_ne!(a.caps_hash(), changed2.caps_hash());
261    }
262
263    #[test]
264    fn negotiate_level_clamps_and_defaults() {
265        // None → server default.
266        assert_eq!(negotiate_level(None, -8, 4, 1), 1);
267        // In-range passes through untouched.
268        assert_eq!(negotiate_level(Some(3), -8, 4, 1), 3);
269        // Out-of-range clamps.
270        assert_eq!(negotiate_level(Some(-20), -8, 4, 1), -8);
271        assert_eq!(negotiate_level(Some(99), -8, 4, 1), 4);
272        // Inverted range → default (never panic).
273        assert_eq!(negotiate_level(Some(1), 4, -8, 1), 1);
274        assert_eq!(negotiate_level(None, 4, -8, 1), 1);
275    }
276
277    #[test]
278    fn zrip_level_bounds() {
279        assert!(is_valid_zrip_level(ZRIP_MIN_LEVEL));
280        assert!(is_valid_zrip_level(ZRIP_MAX_LEVEL));
281        assert!(is_valid_zrip_level(0));
282        assert!(!is_valid_zrip_level(ZRIP_MIN_LEVEL - 1));
283        assert!(!is_valid_zrip_level(ZRIP_MAX_LEVEL + 1));
284        let lv = ZripLevels::default();
285        assert_eq!(lv.clamp_level(99), ZRIP_MAX_LEVEL);
286        assert_eq!(lv.clamp_level(-99), ZRIP_MIN_LEVEL);
287        assert_eq!(lv.clamp_level(2), 2);
288    }
289
290    #[test]
291    fn limits_clamp_and_contains() {
292        let r = IntRange { min: 1, max: 16, default: 4 };
293        assert_eq!(r.clamp(0), 1);
294        assert_eq!(r.clamp(100), 16);
295        assert_eq!(r.clamp(8), 8);
296        assert!(r.contains(16));
297        assert!(!r.contains(17));
298
299        // An inverted (empty) advertised range must fall back to `default`
300        // instead of panicking inside `i64::clamp`.
301        let inverted = IntRange {
302            min: 16,
303            max: 1,
304            default: 4,
305        };
306        assert_eq!(inverted.clamp(8), 4);
307        assert_eq!(inverted.clamp(1_000_000), 4);
308        assert!(!inverted.contains(8));
309    }
310
311    #[test]
312    fn compression_caps_supports() {
313        let caps = CompressionCaps::default();
314        assert!(caps.supports(CompressionFormat::Zrip));
315        assert!(caps.supports(CompressionFormat::None));
316
317        let identity_only = CompressionCaps {
318            formats: vec![CompressionFormat::None],
319            zrip_levels: ZripLevels::default(),
320        };
321        assert!(identity_only.supports(CompressionFormat::None));
322        assert!(!identity_only.supports(CompressionFormat::Zrip));
323    }
324
325    #[test]
326    fn compressor_with_level_roundtrip_across_range() {
327        let data: Vec<u8> = (0..200_000u32).map(|i| (i % 97) as u8).collect();
328        for level in [ZRIP_MIN_LEVEL, -1, 0, ZRIP_DEFAULT_LEVEL, 4] {
329            let mut c = compressor_with_level(CompressionFormat::Zrip, level).unwrap();
330            let mut compressed = Vec::new();
331            c.compress(&data, &mut compressed).unwrap();
332            c.finish(&mut compressed).unwrap();
333            assert!(!compressed.is_empty(), "level {level} produced no output");
334
335            let mut d = crate::compress::decompressor(CompressionFormat::Zrip);
336            let mut plain = Vec::new();
337            d.decompress(&compressed, &mut plain).unwrap();
338            d.finish(&mut plain).unwrap();
339            assert_eq!(plain, data, "roundtrip mismatch at level {level}");
340        }
341    }
342
343    #[test]
344    fn compressor_with_level_identity_ignores_level() {
345        let mut c = compressor_with_level(CompressionFormat::None, 99).unwrap();
346        let mut compressed = Vec::new();
347        c.compress(b"passthrough", &mut compressed).unwrap();
348        c.finish(&mut compressed).unwrap();
349        assert_eq!(compressed, b"passthrough");
350    }
351}