Skip to main content

bee/api/
options.rs

1//! Upload / download options. Mirrors bee-go's `pkg/api/options.go`.
2//!
3//! Option fields use [`Option<bool>`] to distinguish "unset" from
4//! "explicitly false" — matching bee-go's `*bool` semantics. `None`
5//! omits the header; `Some(false)` sends the literal string `"false"`.
6
7use std::fmt;
8use std::sync::Arc;
9
10use crate::swarm::{BatchId, PublicKey, Reference};
11
12/// Per-entry signal emitted by `upload_collection` /
13/// `upload_collection_entries` when an [`UploadProgress`] callback is
14/// configured. Mirrors bee-js `streamDirectory` `onUploadProgress`.
15#[derive(Debug, Clone, Copy)]
16pub struct UploadProgress<'a> {
17    /// Relative path of the entry inside the collection.
18    pub path: &'a str,
19    /// Size of the entry in bytes.
20    pub size: u64,
21    /// 0-based index of the entry within the collection.
22    pub index: usize,
23    /// Total number of entries in the collection.
24    pub total: usize,
25}
26
27/// Boxed callback invoked once per entry when uploading a collection.
28pub type OnEntryFn = Arc<dyn for<'a> Fn(UploadProgress<'a>) + Send + Sync>;
29
30/// Data redundancy level applied at upload time. Mirrors bee-js
31/// `RedundancyLevel`.
32#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
33#[repr(u8)]
34pub enum RedundancyLevel {
35    /// No redundancy.
36    Off = 0,
37    /// Medium redundancy.
38    Medium = 1,
39    /// Strong redundancy.
40    Strong = 2,
41    /// Insane redundancy.
42    Insane = 3,
43    /// Paranoid redundancy.
44    Paranoid = 4,
45}
46
47impl RedundancyLevel {
48    /// Numeric value used as the `Swarm-Redundancy-Level` header.
49    pub fn as_u8(self) -> u8 {
50        self as u8
51    }
52}
53
54/// Chunk-prefetch policy used when downloading erasure-coded data.
55/// Mirrors bee-js `RedundancyStrategy`.
56#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
57#[repr(u8)]
58pub enum RedundancyStrategy {
59    /// No prefetch.
60    None = 0,
61    /// Data-only prefetch.
62    Data = 1,
63    /// Proximity-based prefetch.
64    Proximity = 2,
65    /// Race strategies.
66    Race = 3,
67}
68
69impl RedundancyStrategy {
70    /// Numeric value used as the `Swarm-Redundancy-Strategy` header.
71    pub fn as_u8(self) -> u8 {
72        self as u8
73    }
74}
75
76/// Base set of options accepted by every upload endpoint. Mirrors
77/// bee-js / bee-go `UploadOptions`.
78#[derive(Clone, Debug, Default)]
79pub struct UploadOptions {
80    /// When `Some(true)`, instruct Bee to wrap the upload in an Access
81    /// Control Trie (ACT). The history root is returned via
82    /// `Swarm-Act-History-Address`.
83    pub act: Option<bool>,
84    /// Existing ACT history root for re-upload under the same access
85    /// policy.
86    pub act_history_address: Option<Reference>,
87    /// Pin the uploaded data on the local Bee node.
88    pub pin: Option<bool>,
89    /// Encrypt chunks; the returned reference is 64 bytes (CAC ||
90    /// encryption key).
91    pub encrypt: Option<bool>,
92    /// Existing tag UID to attach for sync tracking. `0` omits the
93    /// header (matching bee-go's `uint32(0)` zero-value semantics).
94    pub tag: u32,
95    /// Toggle "Bee waits for full sync" (`Some(false)`) vs
96    /// "Bee accepts and syncs in background" (`Some(true)`, the Bee
97    /// default).
98    pub deferred: Option<bool>,
99}
100
101/// `UploadOptions` plus a redundancy level. Mirrors bee-go
102/// `RedundantUploadOptions`.
103#[derive(Clone, Debug, Default)]
104pub struct RedundantUploadOptions {
105    /// Inherited base options.
106    pub base: UploadOptions,
107    /// Redundancy level (`Off` omits the header).
108    pub redundancy_level: Option<RedundancyLevel>,
109}
110
111/// File-specific upload options for `POST /bzz`. Mirrors bee-go
112/// `FileUploadOptions`.
113#[derive(Clone, Debug, Default)]
114pub struct FileUploadOptions {
115    /// Inherited base options.
116    pub base: UploadOptions,
117    /// Explicit `Content-Length` (use when uploading from a stream of
118    /// unknown length).
119    pub size: Option<u64>,
120    /// Explicit `Content-Type`.
121    pub content_type: Option<String>,
122    /// Redundancy level (`Off` omits the header).
123    pub redundancy_level: Option<RedundancyLevel>,
124}
125
126/// Collection upload options for tar `POST /bzz`. Mirrors bee-go
127/// `CollectionUploadOptions` and bee-js `streamDirectory` opts.
128#[derive(Clone, Default)]
129pub struct CollectionUploadOptions {
130    /// Inherited base options.
131    pub base: UploadOptions,
132    /// Document served when the collection root is requested.
133    pub index_document: Option<String>,
134    /// Document served when a path inside the collection is missing.
135    pub error_document: Option<String>,
136    /// Redundancy level (`Off` omits the header).
137    pub redundancy_level: Option<RedundancyLevel>,
138    /// Per-entry progress callback. Invoked once per entry before the
139    /// collection is packed and uploaded — useful for surfacing what
140    /// is about to be sent without waiting for completion.
141    pub on_entry: Option<OnEntryFn>,
142}
143
144impl CollectionUploadOptions {
145    /// Set the per-entry progress callback. Builder convenience for
146    /// callers that don't want to construct the [`OnEntryFn`] type
147    /// themselves.
148    pub fn with_on_entry<F>(mut self, f: F) -> Self
149    where
150        F: for<'a> Fn(UploadProgress<'a>) + Send + Sync + 'static,
151    {
152        self.on_entry = Some(Arc::new(f));
153        self
154    }
155}
156
157impl fmt::Debug for CollectionUploadOptions {
158    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159        f.debug_struct("CollectionUploadOptions")
160            .field("base", &self.base)
161            .field("index_document", &self.index_document)
162            .field("error_document", &self.error_document)
163            .field("redundancy_level", &self.redundancy_level)
164            .field("on_entry", &self.on_entry.as_ref().map(|_| "<callback>"))
165            .finish()
166    }
167}
168
169/// Download options. All fields are optional; `Default::default()`
170/// keeps Bee defaults. Mirrors bee-go `DownloadOptions`.
171#[derive(Clone, Debug, Default)]
172pub struct DownloadOptions {
173    /// Erasure-coded prefetch policy.
174    pub redundancy_strategy: Option<RedundancyStrategy>,
175    /// Allow strategy fallback. Bee default is `true`.
176    pub fallback: Option<bool>,
177    /// Per-chunk retrieval timeout in milliseconds.
178    pub timeout_ms: Option<u64>,
179    /// ACT publisher public key.
180    pub act_publisher: Option<PublicKey>,
181    /// ACT history root for permission resolution.
182    pub act_history_address: Option<Reference>,
183    /// Unix timestamp at which to evaluate ACT permissions.
184    pub act_timestamp: Option<i64>,
185}
186
187/// Postage stamp creation options. Mirrors bee-js / bee-go
188/// `PostageBatchOptions`.
189#[derive(Clone, Debug, Default)]
190pub struct PostageBatchOptions {
191    /// Human-readable label.
192    pub label: Option<String>,
193    /// Whether the batch is immutable.
194    pub immutable: Option<bool>,
195    /// Override the gas price (decimal string).
196    pub gas_price: Option<String>,
197    /// Override the gas limit (decimal string).
198    pub gas_limit: Option<String>,
199}
200
201// ---- header preparation -------------------------------------------------
202
203/// Header pairs: name + value. Used to push headers into a request
204/// builder in the order they were added.
205pub type HeaderPairs = Vec<(&'static str, String)>;
206
207fn bool_str(b: bool) -> &'static str {
208    if b { "true" } else { "false" }
209}
210
211fn push_upload_options(out: &mut HeaderPairs, opts: &UploadOptions) {
212    if let Some(v) = opts.pin {
213        out.push(("Swarm-Pin", bool_str(v).to_string()));
214    }
215    if let Some(v) = opts.encrypt {
216        out.push(("Swarm-Encrypt", bool_str(v).to_string()));
217    }
218    if opts.tag > 0 {
219        out.push(("Swarm-Tag", opts.tag.to_string()));
220    }
221    if let Some(v) = opts.deferred {
222        out.push(("Swarm-Deferred-Upload", bool_str(v).to_string()));
223    }
224    if let Some(v) = opts.act {
225        out.push(("Swarm-Act", bool_str(v).to_string()));
226    }
227    if let Some(ref r) = opts.act_history_address {
228        out.push(("Swarm-Act-History-Address", r.to_hex()));
229    }
230}
231
232/// Build the header set for a base upload. The batch is required.
233pub fn prepare_upload_headers(batch_id: &BatchId, opts: Option<&UploadOptions>) -> HeaderPairs {
234    let mut out = vec![("Swarm-Postage-Batch-Id", batch_id.to_hex())];
235    if let Some(o) = opts {
236        push_upload_options(&mut out, o);
237    }
238    out
239}
240
241/// Build the header set for a redundant upload.
242pub fn prepare_redundant_upload_headers(
243    batch_id: &BatchId,
244    opts: Option<&RedundantUploadOptions>,
245) -> HeaderPairs {
246    match opts {
247        None => prepare_upload_headers(batch_id, None),
248        Some(o) => {
249            let mut out = prepare_upload_headers(batch_id, Some(&o.base));
250            if let Some(level) = o.redundancy_level {
251                if !matches!(level, RedundancyLevel::Off) {
252                    out.push(("Swarm-Redundancy-Level", level.as_u8().to_string()));
253                }
254            }
255            out
256        }
257    }
258}
259
260/// Build the header set for a `POST /bzz` file upload.
261pub fn prepare_file_upload_headers(
262    batch_id: &BatchId,
263    opts: Option<&FileUploadOptions>,
264) -> HeaderPairs {
265    match opts {
266        None => prepare_upload_headers(batch_id, None),
267        Some(o) => {
268            let mut out = prepare_upload_headers(batch_id, Some(&o.base));
269            if let Some(size) = o.size {
270                out.push(("Content-Length", size.to_string()));
271            }
272            if let Some(ref ct) = o.content_type {
273                out.push(("Content-Type", ct.clone()));
274            }
275            if let Some(level) = o.redundancy_level {
276                if !matches!(level, RedundancyLevel::Off) {
277                    out.push(("Swarm-Redundancy-Level", level.as_u8().to_string()));
278                }
279            }
280            out
281        }
282    }
283}
284
285/// Validate that the user-controlled string fields of
286/// [`CollectionUploadOptions`] (`index_document`, `error_document`)
287/// don't contain CR / LF / NUL bytes. reqwest itself rejects these at
288/// request-build time, but the resulting error is a generic
289/// `InvalidHeaderValue` deep in the I/O path; surfacing it early as
290/// [`Error::Argument`] is cleaner.
291pub fn validate_collection_upload_options(
292    opts: Option<&CollectionUploadOptions>,
293) -> Result<(), crate::swarm::Error> {
294    fn check(field: &str, value: &str) -> Result<(), crate::swarm::Error> {
295        for b in value.bytes() {
296            if b == b'\r' || b == b'\n' || b == 0 {
297                return Err(crate::swarm::Error::argument(format!(
298                    "CollectionUploadOptions.{field} contains a forbidden byte (CR / LF / NUL)"
299                )));
300            }
301        }
302        Ok(())
303    }
304    if let Some(o) = opts {
305        if let Some(ref s) = o.index_document {
306            check("index_document", s)?;
307        }
308        if let Some(ref s) = o.error_document {
309            check("error_document", s)?;
310        }
311    }
312    Ok(())
313}
314
315/// Build the header set for a tar `POST /bzz` collection upload.
316///
317/// Caller should run [`validate_collection_upload_options`] first to
318/// fail fast on header-injection payloads in `index_document` /
319/// `error_document`; this function silently drops values containing
320/// CR / LF / NUL as defense in depth.
321pub fn prepare_collection_upload_headers(
322    batch_id: &BatchId,
323    opts: Option<&CollectionUploadOptions>,
324) -> HeaderPairs {
325    fn safe(s: &str) -> bool {
326        !s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
327    }
328    match opts {
329        None => prepare_upload_headers(batch_id, None),
330        Some(o) => {
331            let mut out = prepare_upload_headers(batch_id, Some(&o.base));
332            if let Some(ref idx) = o.index_document {
333                if safe(idx) {
334                    out.push(("Swarm-Index-Document", idx.clone()));
335                }
336            }
337            if let Some(ref err) = o.error_document {
338                if safe(err) {
339                    out.push(("Swarm-Error-Document", err.clone()));
340                }
341            }
342            if let Some(level) = o.redundancy_level {
343                if !matches!(level, RedundancyLevel::Off) {
344                    out.push(("Swarm-Redundancy-Level", level.as_u8().to_string()));
345                }
346            }
347            out
348        }
349    }
350}
351
352/// Build the header set for a download. Setting any of `act_publisher`
353/// / `act_history_address` / `act_timestamp` implicitly turns
354/// `Swarm-Act` on.
355pub fn prepare_download_headers(opts: Option<&DownloadOptions>) -> HeaderPairs {
356    let mut out = HeaderPairs::new();
357    let Some(o) = opts else { return out };
358
359    if let Some(s) = o.redundancy_strategy {
360        out.push(("Swarm-Redundancy-Strategy", s.as_u8().to_string()));
361    }
362    if let Some(v) = o.fallback {
363        out.push(("Swarm-Redundancy-Fallback-Mode", bool_str(v).to_string()));
364    }
365    if let Some(ms) = o.timeout_ms {
366        if ms > 0 {
367            out.push(("Swarm-Chunk-Retrieval-Timeout", ms.to_string()));
368        }
369    }
370    let mut act = false;
371    if let Some(ref pk) = o.act_publisher {
372        if let Ok(hex) = pk.compressed_hex() {
373            out.push(("Swarm-Act-Publisher", hex));
374            act = true;
375        }
376    }
377    if let Some(ref r) = o.act_history_address {
378        out.push(("Swarm-Act-History-Address", r.to_hex()));
379        act = true;
380    }
381    if let Some(ts) = o.act_timestamp {
382        if ts > 0 {
383            out.push(("Swarm-Act-Timestamp", ts.to_string()));
384            act = true;
385        }
386    }
387    if act {
388        out.push(("Swarm-Act", "true".to_string()));
389    }
390    out
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    fn batch() -> BatchId {
398        BatchId::new(&[0xab; 32]).unwrap()
399    }
400
401    fn header<'a>(h: &'a [(&'static str, String)], name: &str) -> Option<&'a str> {
402        h.iter().find(|(k, _)| *k == name).map(|(_, v)| v.as_str())
403    }
404
405    #[test]
406    fn upload_headers_omit_unset_fields() {
407        let h = prepare_upload_headers(&batch(), None);
408        assert_eq!(
409            header(&h, "Swarm-Postage-Batch-Id"),
410            Some("ab".repeat(32).as_str())
411        );
412        assert!(header(&h, "Swarm-Pin").is_none());
413        assert!(header(&h, "Swarm-Encrypt").is_none());
414    }
415
416    #[test]
417    fn upload_headers_distinguish_none_from_some_false() {
418        let opts = UploadOptions {
419            pin: Some(false),
420            ..Default::default()
421        };
422        let h = prepare_upload_headers(&batch(), Some(&opts));
423        assert_eq!(header(&h, "Swarm-Pin"), Some("false"));
424    }
425
426    #[test]
427    fn redundancy_level_off_is_omitted() {
428        let opts = RedundantUploadOptions {
429            redundancy_level: Some(RedundancyLevel::Off),
430            ..Default::default()
431        };
432        let h = prepare_redundant_upload_headers(&batch(), Some(&opts));
433        assert!(header(&h, "Swarm-Redundancy-Level").is_none());
434    }
435
436    #[test]
437    fn redundancy_level_medium_emits_header() {
438        let opts = RedundantUploadOptions {
439            redundancy_level: Some(RedundancyLevel::Medium),
440            ..Default::default()
441        };
442        let h = prepare_redundant_upload_headers(&batch(), Some(&opts));
443        assert_eq!(header(&h, "Swarm-Redundancy-Level"), Some("1"));
444    }
445
446    #[test]
447    fn collection_upload_uses_swarm_index_document_header() {
448        let opts = CollectionUploadOptions {
449            index_document: Some("index.html".into()),
450            ..Default::default()
451        };
452        let h = prepare_collection_upload_headers(&batch(), Some(&opts));
453        assert_eq!(header(&h, "Swarm-Index-Document"), Some("index.html"));
454    }
455
456    #[test]
457    fn download_act_implies_swarm_act_true() {
458        let opts = DownloadOptions {
459            act_history_address: Some(Reference::from_hex(&"00".repeat(32)).unwrap()),
460            ..Default::default()
461        };
462        let h = prepare_download_headers(Some(&opts));
463        assert_eq!(header(&h, "Swarm-Act"), Some("true"));
464    }
465
466    #[test]
467    fn download_no_options_no_headers() {
468        assert!(prepare_download_headers(None).is_empty());
469        assert!(prepare_download_headers(Some(&DownloadOptions::default())).is_empty());
470    }
471}