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    DEFAULT_CONCURRENCY, DEFAULT_DOWNLOAD_CHUNK_SIZE, DEFAULT_DOWNLOAD_WINDOW,
18    DEFAULT_MAX_UPLOAD_SIZE, DEFAULT_UPLOAD_WINDOW, MAX_RETRIES, 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 min: i64,
29    pub max: i64,
30    pub default: 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 min: i32,
49    pub max: i32,
50    pub default: i32,
51}
52
53impl Default for ZripLevels {
54    fn default() -> Self {
55        ZripLevels {
56            min: ZRIP_MIN_LEVEL,
57            max: ZRIP_MAX_LEVEL,
58            default: ZRIP_DEFAULT_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` is a plain byte cap (no tuning dimension); everything
109/// else is an [`IntRange`]. All defaults mirror `libfw-core` constants so a
110/// server that never overrides anything still advertises sane values.
111#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
112#[serde(rename_all = "camelCase")]
113pub struct Limits {
114    /// Cross-file fan-out / global in-flight request cap.
115    pub concurrency: IntRange,
116    /// Per-file upload in-flight chunk window.
117    pub upload_window: IntRange,
118    /// Per-file download in-flight chunk window.
119    pub download_window: IntRange,
120    /// Upload chunk size (bytes).
121    pub chunk_size: IntRange,
122    /// Download (byte-range) chunk size (bytes).
123    pub download_chunk_size: IntRange,
124    /// Hard cap on a single upload body (bytes).
125    pub max_upload_size: u64,
126    /// Per-chunk retry budget.
127    pub max_retries: IntRange,
128    /// Request timeout the server will honor (ms).
129    pub timeout_ms: IntRange,
130}
131
132impl Default for Limits {
133    fn default() -> Self {
134        Limits {
135            concurrency: IntRange {
136                min: 1,
137                max: 16,
138                default: DEFAULT_CONCURRENCY as i64,
139            },
140            upload_window: IntRange {
141                min: 1,
142                max: 8,
143                default: DEFAULT_UPLOAD_WINDOW as i64,
144            },
145            download_window: IntRange {
146                min: 1,
147                max: 8,
148                default: DEFAULT_DOWNLOAD_WINDOW as i64,
149            },
150            chunk_size: IntRange {
151                min: 256 * 1024,
152                max: 8 * 1024 * 1024,
153                default: crate::CHUNK_SIZE as i64,
154            },
155            download_chunk_size: IntRange {
156                min: 64 * 1024,
157                max: 4 * 1024 * 1024,
158                default: DEFAULT_DOWNLOAD_CHUNK_SIZE as i64,
159            },
160            max_upload_size: DEFAULT_MAX_UPLOAD_SIZE,
161            max_retries: IntRange {
162                min: 1,
163                max: 10,
164                default: MAX_RETRIES as i64,
165            },
166            timeout_ms: IntRange {
167                min: 30_000,
168                max: 1_800_000,
169                default: 600_000,
170            },
171        }
172    }
173}
174
175/// Full capability advertisement served at `GET /capabilities`.
176#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
177pub struct Capabilities {
178    /// Wire protocol name/version (e.g. `libfw/1`).
179    pub protocol: String,
180    pub compression: CompressionCaps,
181    pub limits: Limits,
182}
183
184impl Default for Capabilities {
185    fn default() -> Self {
186        Capabilities {
187            protocol: protocol_header_value().to_string(),
188            compression: CompressionCaps::default(),
189            limits: Limits::default(),
190        }
191    }
192}
193
194impl Capabilities {
195    /// Stable, content-addressed hash of this advertisement.
196    ///
197    /// Format: `sha256-<64 hex chars>`. Used by clients to invalidate cached
198    /// tuning state when the server changes what it advertises.
199    pub fn caps_hash(&self) -> String {
200        // serde_json::to_vec cannot fail for these plain data types.
201        let bytes = serde_json::to_vec(self).expect("capabilities serialization cannot fail");
202        let digest = Sha256::digest(&bytes);
203        format!("sha256-{}", hex(&digest))
204    }
205
206    /// Clamp a zrip level into the advertised range.
207    pub fn clamp_level(&self, level: i32) -> i32 {
208        self.compression.zrip_levels.clamp_level(level)
209    }
210
211    /// Whether `level` is inside the advertised range.
212    pub fn is_valid_level(&self, level: i32) -> bool {
213        self.compression.zrip_levels.contains(level)
214    }
215}
216
217/// Lowercase hex encoding of a digest (avoids pulling `hex` for one format).
218fn hex(bytes: &[u8]) -> String {
219    const HEX: &[u8; 16] = b"0123456789abcdef";
220    let mut out = String::with_capacity(bytes.len() * 2);
221    for &b in bytes {
222        out.push(HEX[(b >> 4) as usize] as char);
223        out.push(HEX[(b & 0x0f) as usize] as char);
224    }
225    out
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::compress::{compressor_with_level, is_valid_zrip_level, negotiate_level};
232
233    #[test]
234    fn default_caps_serde_roundtrip() {
235        let caps = Capabilities::default();
236        let json = serde_json::to_string(&caps).unwrap();
237        let back: Capabilities = serde_json::from_str(&json).unwrap();
238        assert_eq!(back, caps);
239        // Spot-check the wire shape a server will serve.
240        assert!(json.contains("\"protocol\":\"libfw/1\""));
241        assert!(json.contains("\"zripLevels\""));
242        assert!(json.contains("\"uploadWindow\""));
243        assert!(json.contains("\"maxUploadSize\""));
244        assert!(json.contains("\"identity\""));
245    }
246
247    #[test]
248    fn caps_hash_is_stable_and_content_addressed() {
249        let a = Capabilities::default();
250        let b = Capabilities::default();
251        assert_eq!(a.caps_hash(), b.caps_hash());
252        assert!(a.caps_hash().starts_with("sha256-"));
253        assert_eq!(a.caps_hash().len(), "sha256-".len() + 64);
254
255        let mut changed = a.clone();
256        changed.limits.concurrency.default = 6;
257        assert_ne!(a.caps_hash(), changed.caps_hash());
258
259        let mut changed2 = a.clone();
260        changed2.compression.zrip_levels.max = 3;
261        assert_ne!(a.caps_hash(), changed2.caps_hash());
262    }
263
264    #[test]
265    fn negotiate_level_clamps_and_defaults() {
266        // None → server default.
267        assert_eq!(negotiate_level(None, -8, 4, 1), 1);
268        // In-range passes through untouched.
269        assert_eq!(negotiate_level(Some(3), -8, 4, 1), 3);
270        // Out-of-range clamps.
271        assert_eq!(negotiate_level(Some(-20), -8, 4, 1), -8);
272        assert_eq!(negotiate_level(Some(99), -8, 4, 1), 4);
273    }
274
275    #[test]
276    fn zrip_level_bounds() {
277        assert!(is_valid_zrip_level(ZRIP_MIN_LEVEL));
278        assert!(is_valid_zrip_level(ZRIP_MAX_LEVEL));
279        assert!(is_valid_zrip_level(0));
280        assert!(!is_valid_zrip_level(ZRIP_MIN_LEVEL - 1));
281        assert!(!is_valid_zrip_level(ZRIP_MAX_LEVEL + 1));
282        let lv = ZripLevels::default();
283        assert_eq!(lv.clamp_level(99), ZRIP_MAX_LEVEL);
284        assert_eq!(lv.clamp_level(-99), ZRIP_MIN_LEVEL);
285        assert_eq!(lv.clamp_level(2), 2);
286    }
287
288    #[test]
289    fn limits_clamp_and_contains() {
290        let r = IntRange { min: 1, max: 16, default: 4 };
291        assert_eq!(r.clamp(0), 1);
292        assert_eq!(r.clamp(100), 16);
293        assert_eq!(r.clamp(8), 8);
294        assert!(r.contains(16));
295        assert!(!r.contains(17));
296    }
297
298    #[test]
299    fn compression_caps_supports() {
300        let caps = CompressionCaps::default();
301        assert!(caps.supports(CompressionFormat::Zrip));
302        assert!(caps.supports(CompressionFormat::None));
303
304        let identity_only = CompressionCaps {
305            formats: vec![CompressionFormat::None],
306            zrip_levels: ZripLevels::default(),
307        };
308        assert!(identity_only.supports(CompressionFormat::None));
309        assert!(!identity_only.supports(CompressionFormat::Zrip));
310    }
311
312    #[test]
313    fn compressor_with_level_roundtrip_across_range() {
314        let data: Vec<u8> = (0..200_000u32).map(|i| (i % 97) as u8).collect();
315        for level in [ZRIP_MIN_LEVEL, -1, 0, ZRIP_DEFAULT_LEVEL, 4] {
316            let mut c = compressor_with_level(CompressionFormat::Zrip, level).unwrap();
317            let mut compressed = Vec::new();
318            c.compress(&data, &mut compressed).unwrap();
319            c.finish(&mut compressed).unwrap();
320            assert!(!compressed.is_empty(), "level {level} produced no output");
321
322            let mut d = crate::compress::decompressor(CompressionFormat::Zrip);
323            let mut plain = Vec::new();
324            d.decompress(&compressed, &mut plain).unwrap();
325            d.finish(&mut plain).unwrap();
326            assert_eq!(plain, data, "roundtrip mismatch at level {level}");
327        }
328    }
329
330    #[test]
331    fn compressor_with_level_identity_ignores_level() {
332        let mut c = compressor_with_level(CompressionFormat::None, 99).unwrap();
333        let mut compressed = Vec::new();
334        c.compress(b"passthrough", &mut compressed).unwrap();
335        c.finish(&mut compressed).unwrap();
336        assert_eq!(compressed, b"passthrough");
337    }
338}