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