Skip to main content

dre_protocol/
delivery.rs

1//! The delivery and network rules every file destination shares, written once.
2//!
3//! A destination implements a few primitives on its server ([`Store`]: write a file, rename,
4//! exists, delete, and what it can do atomically). [`deliver`] applies the rules on top:
5//!
6//! - `if_exists` ([`IfExists`]): replace a file already at the path, fail, or keep both by
7//!   numbering the new one (`report_2.xlsx`);
8//! - `atomic` and `temp_dir`: upload under a temporary name (`.<name>.dre-part`, in the same
9//!   folder or in `temp_dir`), then rename, so a half-written file never appears under its
10//!   final name;
11//! - `retries`: try again on a temporary error (a dropped connection, a 429 or 5xx), with
12//!   backoff and `Retry-After`;
13//! - `connect_timeout` and `timeout`, which the plugin applies to its client ([`Rules`] parses
14//!   them).
15//!
16//! The Go plugin module has the same rules (`go/plugin/delivery.go`). [`LocalStore`] is the local
17//! file system, used by core's built-in `local` destination.
18
19use std::path::Path;
20use std::time::Duration;
21
22use serde_json::{Map, Value};
23
24use crate::msg::{ConnectionField, FieldKind};
25use crate::options::{OptionField, OptionType};
26use crate::plugin::{ErrorKind, PluginError};
27
28/// What to do when a file already exists at the delivery path.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum IfExists {
31    /// Replace it (the default: a rerun replaces a bad file).
32    Overwrite,
33    /// Fail this delivery.
34    Error,
35    /// Keep both: the new file is saved as `<name>_2.<ext>`, `_3`, and so on.
36    Number,
37}
38
39impl IfExists {
40    pub fn parse(s: &str) -> Option<IfExists> {
41        match s {
42            "overwrite" => Some(IfExists::Overwrite),
43            "error" => Some(IfExists::Error),
44            "number" => Some(IfExists::Number),
45            _ => None,
46        }
47    }
48}
49
50/// The rules for one delivery.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct Rules {
53    pub if_exists: IfExists,
54    /// Write under a temporary name, then rename.
55    pub atomic: bool,
56    /// Where the temporary file goes: absolute, or relative to the final file's folder. `None`:
57    /// next to the final file.
58    pub temp_dir: Option<String>,
59    /// How many times to try again after a temporary error (0: never).
60    pub retries: u32,
61    /// The first wait before trying again, doubling each try; `None`: about 1s, then 4s, then
62    /// 16s. A server's `Retry-After` wins.
63    pub retry_wait: Option<Duration>,
64    /// The longest wait between tries, `Retry-After` included.
65    pub max_retry_wait: Duration,
66    /// For the plugin's client: how long to wait for a connection.
67    pub connect_timeout: Duration,
68    /// For the plugin's client: how long a read or write may make no progress.
69    pub timeout: Duration,
70}
71
72pub const DEFAULT_RETRIES: u32 = 3;
73pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);
74pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60);
75pub const DEFAULT_MAX_RETRY_WAIT: Duration = Duration::from_secs(600);
76
77impl Default for Rules {
78    fn default() -> Rules {
79        Rules {
80            if_exists: IfExists::Overwrite,
81            atomic: true,
82            temp_dir: None,
83            retries: DEFAULT_RETRIES,
84            retry_wait: None,
85            max_retry_wait: DEFAULT_MAX_RETRY_WAIT,
86            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
87            timeout: DEFAULT_TIMEOUT,
88        }
89    }
90}
91
92impl Rules {
93    /// How destinations delivered before these rules: replace, straight to the final name, no
94    /// retries.
95    pub fn legacy() -> Rules {
96        Rules {
97            atomic: false,
98            retries: 0,
99            ..Rules::default()
100        }
101    }
102
103    /// The rules from a profile target's `connection` (`connect_timeout`, `timeout`,
104    /// `retries`) and a destination entry's `options` (`if_exists`, `atomic`, `temp_dir`),
105    /// starting from `base`. `aliases` maps deprecated keys to these (`("upload_timeout",
106    /// "timeout")`): an alias still works, with a warning. Returns the rules and the warnings.
107    pub fn from_settings(
108        base: Rules,
109        connection: &Map<String, Value>,
110        options: &Map<String, Value>,
111        aliases: &[(&str, &str)],
112    ) -> Result<(Rules, Vec<String>), String> {
113        let mut warnings = Vec::new();
114        let get = |key: &str, warnings: &mut Vec<String>| -> Option<Value> {
115            for map in [options, connection] {
116                if let Some(v) = map.get(key).filter(|v| !v.is_null()) {
117                    return Some(v.clone());
118                }
119                for (old, new) in aliases {
120                    if *new == key
121                        && let Some(v) = map.get(*old).filter(|v| !v.is_null())
122                    {
123                        warnings.push(format!("`{old}` is deprecated; use `{new}`"));
124                        return Some(v.clone());
125                    }
126                }
127            }
128            None
129        };
130        let mut r = base;
131        if let Some(v) = get("if_exists", &mut warnings) {
132            r.if_exists = v.as_str().and_then(IfExists::parse).ok_or_else(|| {
133                format!("`if_exists` must be one of `overwrite`, `error`, `number`, got {v}")
134            })?;
135        }
136        if let Some(v) = get("atomic", &mut warnings) {
137            r.atomic = v
138                .as_bool()
139                .ok_or_else(|| format!("`atomic` must be true or false, got {v}"))?;
140        }
141        if let Some(v) = get("temp_dir", &mut warnings) {
142            let s = v
143                .as_str()
144                .ok_or_else(|| format!("`temp_dir` must be a path, got {v}"))?;
145            r.temp_dir = Some(s.to_string()).filter(|s| !s.is_empty());
146        }
147        if let Some(v) = get("retries", &mut warnings) {
148            r.retries = v
149                .as_u64()
150                .or_else(|| v.as_str().and_then(|s| s.trim().parse().ok()))
151                .and_then(|n| u32::try_from(n).ok())
152                .ok_or_else(|| format!("`retries` must be a whole number, got {v}"))?;
153        }
154        if let Some(v) = get("retry_wait", &mut warnings) {
155            r.retry_wait = Some(parse_duration(&v).map_err(|e| format!("`retry_wait` {e}"))?);
156        }
157        if let Some(v) = get("max_retry_wait", &mut warnings) {
158            r.max_retry_wait = parse_duration(&v).map_err(|e| format!("`max_retry_wait` {e}"))?;
159        }
160        if let Some(v) = get("connect_timeout", &mut warnings) {
161            r.connect_timeout = parse_duration(&v).map_err(|e| format!("`connect_timeout` {e}"))?;
162        }
163        if let Some(v) = get("timeout", &mut warnings) {
164            r.timeout = parse_duration(&v).map_err(|e| format!("`timeout` {e}"))?;
165        }
166        Ok((r, warnings))
167    }
168}
169
170impl Rules {
171    /// How long to wait before try `attempt + 1`: the server's `Retry-After`, else `retry_wait`
172    /// doubled each try (or the default backoff), never more than `max_retry_wait`.
173    pub fn wait(&self, attempt: u32, retry_after: Option<Duration>) -> Duration {
174        let w = retry_after.unwrap_or_else(|| match self.retry_wait {
175            Some(first) => first.saturating_mul(2u32.saturating_pow(attempt.saturating_sub(1))),
176            None => backoff(attempt),
177        });
178        w.min(self.max_retry_wait)
179    }
180}
181
182/// A duration: `30s`, `5m`, `2h`, `1500ms`, or a bare number of seconds.
183pub fn parse_duration(v: &Value) -> Result<Duration, String> {
184    let bad = || format!("must be a duration such as `30s`, `5m` or a number of seconds, got {v}");
185    if let Some(n) = v.as_f64() {
186        return (n >= 0.0 && n.is_finite())
187            .then(|| Duration::from_secs_f64(n))
188            .ok_or_else(bad);
189    }
190    let s = v.as_str().ok_or_else(bad)?.trim();
191    let (num, unit) = s.split_at(
192        s.find(|c: char| !c.is_ascii_digit() && c != '.')
193            .unwrap_or(s.len()),
194    );
195    let n: f64 = num.parse().map_err(|_| bad())?;
196    let secs = match unit.trim() {
197        "" | "s" => n,
198        "ms" => n / 1000.0,
199        "m" => n * 60.0,
200        "h" => n * 3600.0,
201        _ => return Err(bad()),
202    };
203    Ok(Duration::from_secs_f64(secs))
204}
205
206/// The timeouts every network plugin shares (`connect_timeout`, `timeout`), for its
207/// `connection_fields()`. Read them with [`Rules::from_settings`].
208pub fn timeout_fields() -> Vec<ConnectionField> {
209    connection_fields()
210        .into_iter()
211        .filter(|f| f.name != "retries")
212        .collect()
213}
214
215/// The connection fields every network plugin shares, for its `connection_fields()`.
216pub fn connection_fields() -> Vec<ConnectionField> {
217    vec![
218        ConnectionField::new(
219            "connect_timeout",
220            "how long to wait for a connection (`30s`, `2m`, or seconds)",
221        )
222        .default("30s")
223        .kind(FieldKind::Duration)
224        .manual(),
225        ConnectionField::new(
226            "timeout",
227            "how long a read or write may make no progress before it fails",
228        )
229        .default("60s")
230        .kind(FieldKind::Duration)
231        .manual(),
232        ConnectionField::new(
233            "retries",
234            "how many times to try again after a temporary error (0: never)",
235        )
236        .default(DEFAULT_RETRIES)
237        .kind(FieldKind::Integer)
238        .manual(),
239        ConnectionField::new(
240            "retry_wait",
241            "the first wait before trying again (`30s`), doubling each try; a server's Retry-After wins",
242        )
243        .kind(FieldKind::Duration)
244        .manual(),
245        ConnectionField::new("max_retry_wait", "the longest wait between tries (`10m`)")
246            .default("10m")
247            .kind(FieldKind::Duration)
248            .manual(),
249    ]
250}
251
252/// The retry options a destination entry may set over its profile's: `retries`, `retry_wait`
253/// and `max_retry_wait`, for the plugin's `options()` (file destinations get them through
254/// [`option_fields`]).
255pub fn retry_option_fields() -> Vec<OptionField> {
256    vec![
257        OptionField::new(
258            "retries",
259            OptionType::Integer,
260            "how many times to try again after a temporary error such as a rate limit (0: never)",
261        )
262        .range(Some(0.0), None),
263        OptionField::new(
264            "retry_wait",
265            OptionType::String,
266            "the first wait before trying again (`30s`), doubling each try; a server's Retry-After wins",
267        ),
268        OptionField::new(
269            "max_retry_wait",
270            OptionType::String,
271            "the longest wait between tries (`10m`)",
272        ),
273    ]
274}
275
276/// The options every file destination shares, for its `options()`.
277pub fn option_fields() -> Vec<OptionField> {
278    vec![
279        OptionField::new(
280            "if_exists",
281            OptionType::String,
282            "when a file is already at the path: `overwrite` it, fail with `error`, or `number` the new one",
283        )
284        .choices(&["overwrite", "error", "number"])
285        .default("overwrite"),
286        OptionField::new(
287            "atomic",
288            OptionType::Boolean,
289            "upload under a temporary name, then rename, so no half-written file appears",
290        )
291        .default(true),
292        OptionField::new(
293            "temp_dir",
294            OptionType::String,
295            "where the temporary file goes (on the same server), for receivers that pick up any new file",
296        ),
297    ]
298    .into_iter()
299    .chain(retry_option_fields())
300    .collect()
301}
302
303/// What a store can do in one step.
304#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
305pub struct Caps {
306    /// `write` with `exclusive` fails with [`StoreError::Exists`] in the same step.
307    pub create_exclusive: bool,
308    /// `rename` without `replace` fails with [`StoreError::Exists`] in the same step.
309    pub rename_no_replace: bool,
310    /// A file only appears once it's complete (object stores), so no temporary name is needed.
311    pub visible_when_complete: bool,
312}
313
314/// Why a store operation failed.
315#[derive(Debug, Clone, PartialEq, Eq)]
316pub enum StoreError {
317    /// Something is already at the path.
318    Exists,
319    /// Trying again may work: a dropped connection, a timeout before anything was accepted, a
320    /// 429 or a 5xx. `retry_after` is the server's `Retry-After`.
321    Temporary {
322        message: String,
323        retry_after: Option<Duration>,
324    },
325    /// Trying again won't help: refused credentials or permissions, a bad path.
326    Failed(String),
327}
328
329impl StoreError {
330    pub fn temporary(message: impl Into<String>) -> StoreError {
331        StoreError::Temporary {
332            message: message.into(),
333            retry_after: None,
334        }
335    }
336}
337
338impl std::fmt::Display for StoreError {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        match self {
341            StoreError::Exists => f.write_str("a file is already there"),
342            StoreError::Temporary { message, .. } | StoreError::Failed(message) => f.write_str(message),
343        }
344    }
345}
346
347/// A destination's primitives. Paths are the destination's own (`outbound/report.xlsx`).
348pub trait Store {
349    fn caps(&self) -> Caps;
350    /// Write `local` to `remote`, creating its folders. With `exclusive`, fail with
351    /// [`StoreError::Exists`] if something is there (only asked when `caps().create_exclusive`).
352    fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError>;
353    /// Rename `from` to `to`, creating `to`'s folders. Without `replace`, fail with
354    /// [`StoreError::Exists`] if something is there (only asked when `caps().rename_no_replace`).
355    fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError>;
356    fn exists(&mut self, remote: &str) -> Result<bool, StoreError>;
357    /// Remove `remote`; a missing file isn't an error.
358    fn delete(&mut self, remote: &str) -> Result<(), StoreError>;
359    /// Called before trying again after a temporary error: drop a broken connection, so the
360    /// next call makes a new one.
361    fn reset(&mut self) {}
362}
363
364/// The most tries any delivery step took in the request being served, for the `delivered`
365/// reply (the plugin SDK reads it).
366static ATTEMPTS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
367
368fn note_attempts(n: u32) {
369    ATTEMPTS.fetch_max(n, std::sync::atomic::Ordering::Relaxed);
370}
371
372/// The most tries a step took since the last call, at least 1; resets the count.
373pub fn take_attempts() -> u32 {
374    ATTEMPTS.swap(0, std::sync::atomic::Ordering::Relaxed).max(1)
375}
376
377/// What one try of a [`retry`] step gave.
378#[derive(Debug)]
379pub enum Retry<E> {
380    /// Trying again may work (a connection refused or reset, a 429 or 503); with the server's
381    /// `Retry-After`.
382    Temporary(E, Option<Duration>),
383    /// Trying again won't help, or might repeat something already done (a message the server
384    /// may have accepted).
385    Fail(E),
386}
387
388/// Run `f`, trying again on [`Retry::Temporary`] up to `retries` more times, with backoff
389/// (about 1s, then 4s, then 16s) or the server's `Retry-After`. Each retry is logged at info
390/// level, naming `what`; a step that needed several tries is reported to core.
391pub fn retry<T, E: std::fmt::Display>(
392    retries: u32,
393    what: &str,
394    f: impl FnMut() -> Result<T, Retry<E>>,
395) -> Result<T, E> {
396    retry_with(retries, what, &mut std::thread::sleep, f)
397}
398
399/// [`retry`] with a destination's [`Rules`]: its `retries`, `retry_wait` and `max_retry_wait`.
400pub fn retry_rules<T, E: std::fmt::Display>(
401    rules: &Rules,
402    what: &str,
403    mut f: impl FnMut() -> Result<T, Retry<E>>,
404) -> Result<T, E> {
405    let mut attempt = 1;
406    loop {
407        let r = f();
408        note_attempts(attempt);
409        match r {
410            Ok(v) => return Ok(v),
411            Err(Retry::Temporary(e, retry_after)) if attempt <= rules.retries => {
412                let wait = rules.wait(attempt, retry_after);
413                crate::log::info!(
414                    "{what}: {e}; trying again in {}s (attempt {} of {})",
415                    wait.as_secs(),
416                    attempt + 1,
417                    rules.retries + 1
418                );
419                std::thread::sleep(wait);
420                attempt += 1;
421            }
422            Err(Retry::Temporary(e, _) | Retry::Fail(e)) => return Err(e),
423        }
424    }
425}
426
427/// [`retry`], with the wait between tries supplied (tests don't wait).
428pub fn retry_with<T, E: std::fmt::Display>(
429    retries: u32,
430    what: &str,
431    sleep: &mut dyn FnMut(Duration),
432    mut f: impl FnMut() -> Result<T, Retry<E>>,
433) -> Result<T, E> {
434    let mut attempt = 1;
435    loop {
436        let r = f();
437        note_attempts(attempt);
438        match r {
439            Ok(v) => return Ok(v),
440            Err(Retry::Temporary(e, retry_after)) if attempt <= retries => {
441                let wait = retry_after.unwrap_or_else(|| backoff(attempt));
442                crate::log::info!(
443                    "{what}: {e}; trying again in {}s (attempt {} of {})",
444                    wait.as_secs(),
445                    attempt + 1,
446                    retries + 1
447                );
448                sleep(wait);
449                attempt += 1;
450            }
451            Err(Retry::Temporary(e, _) | Retry::Fail(e)) => return Err(e),
452        }
453    }
454}
455
456/// Whether an I/O error is a connection failing (refused, reset, aborted, timed out, closed
457/// early), which trying again may fix.
458pub fn is_connection_error(e: &std::io::Error) -> bool {
459    use std::io::ErrorKind::*;
460    matches!(
461        e.kind(),
462        ConnectionRefused
463            | ConnectionReset
464            | ConnectionAborted
465            | NotConnected
466            | BrokenPipe
467            | TimedOut
468            | UnexpectedEof
469            | HostUnreachable
470            | NetworkUnreachable
471            | NetworkDown
472    )
473}
474
475/// The `Retry-After` of a 429 or 503, in seconds (an HTTP date isn't read).
476pub fn retry_after(header: Option<&str>) -> Option<Duration> {
477    header?.trim().parse::<u64>().ok().map(Duration::from_secs)
478}
479
480/// Where a delivery landed.
481#[derive(Debug, Clone, PartialEq, Eq)]
482pub struct Delivered {
483    /// The final path (with `number`, possibly `report_2.xlsx`).
484    pub path: String,
485    /// How many tries it took (1 without a retry).
486    pub attempts: u32,
487}
488
489/// A failed delivery.
490#[derive(Debug, Clone, PartialEq, Eq)]
491pub struct DeliveryError {
492    pub message: String,
493    /// It failed because a file was already at the path (`if_exists: error`).
494    pub exists: bool,
495    pub attempts: u32,
496}
497
498impl std::fmt::Display for DeliveryError {
499    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
500        f.write_str(&self.message)
501    }
502}
503
504impl std::error::Error for DeliveryError {}
505
506impl From<DeliveryError> for PluginError {
507    fn from(e: DeliveryError) -> PluginError {
508        let pe = PluginError::new(ErrorKind::Delivery, e.message);
509        if e.exists { pe.code("file-exists") } else { pe }
510    }
511}
512
513/// Deliver `local` to `remote` under `rules`, sleeping between retries.
514pub fn deliver(
515    store: &mut dyn Store,
516    local: &Path,
517    remote: &str,
518    rules: &Rules,
519) -> Result<Delivered, DeliveryError> {
520    deliver_with(store, local, remote, rules, &mut std::thread::sleep)
521}
522
523/// [`deliver`], with the wait between retries supplied (tests don't wait).
524pub fn deliver_with(
525    store: &mut dyn Store,
526    local: &Path,
527    remote: &str,
528    rules: &Rules,
529    sleep: &mut dyn FnMut(Duration),
530) -> Result<Delivered, DeliveryError> {
531    let mut attempt = 1;
532    loop {
533        let r = try_once(store, local, remote, rules);
534        note_attempts(attempt);
535        match r {
536            Ok(path) => {
537                return Ok(Delivered {
538                    path,
539                    attempts: attempt,
540                });
541            }
542            Err(StoreError::Temporary { message, retry_after }) if attempt <= rules.retries => {
543                let wait = rules.wait(attempt, retry_after);
544                crate::log::info!(
545                    "{remote}: {message}; trying again in {}s (attempt {} of {})",
546                    wait.as_secs(),
547                    attempt + 1,
548                    rules.retries + 1
549                );
550                store.reset();
551                sleep(wait);
552                attempt += 1;
553            }
554            Err(e) => {
555                let exists = e == StoreError::Exists;
556                let message = if exists {
557                    format!("{remote} already exists (`if_exists: error`)")
558                } else if attempt > 1 {
559                    format!("{e} (after {attempt} tries)")
560                } else {
561                    e.to_string()
562                };
563                return Err(DeliveryError {
564                    message,
565                    exists,
566                    attempts: attempt,
567                });
568            }
569        }
570    }
571}
572
573/// About 1s, then 4s, then 16s, give or take a quarter.
574fn backoff(attempt: u32) -> Duration {
575    let base = 4f64.powi(attempt as i32 - 1);
576    let nanos = std::time::SystemTime::now()
577        .duration_since(std::time::UNIX_EPOCH)
578        .map(|d| d.subsec_nanos())
579        .unwrap_or(0);
580    let jitter = 0.75 + (nanos % 1000) as f64 / 2000.0;
581    Duration::from_secs_f64(base * jitter)
582}
583
584/// One try: the final path, or why it failed.
585fn try_once(store: &mut dyn Store, local: &Path, remote: &str, rules: &Rules) -> Result<String, StoreError> {
586    let caps = store.caps();
587    let candidates = |i: u32| numbered(remote, i);
588    let limit = if rules.if_exists == IfExists::Number {
589        1000
590    } else {
591        1
592    };
593    if rules.atomic && !caps.visible_when_complete {
594        let temp = temp_path(remote, rules.temp_dir.as_deref());
595        if let Err(e) = store.write(local, &temp, false) {
596            let _ = store.delete(&temp);
597            return Err(e);
598        }
599        let replace = rules.if_exists == IfExists::Overwrite;
600        for i in 1..=limit {
601            let to = candidates(i);
602            let r = if !replace && !caps.rename_no_replace {
603                // No single-step check here (FTP): look, then rename.
604                match store.exists(&to) {
605                    Ok(true) => Err(StoreError::Exists),
606                    Ok(false) => store.rename(&temp, &to, false),
607                    Err(e) => Err(e),
608                }
609            } else {
610                store.rename(&temp, &to, replace)
611            };
612            match r {
613                Ok(()) => return Ok(to),
614                Err(StoreError::Exists) if i < limit => continue,
615                Err(e) => {
616                    let _ = store.delete(&temp);
617                    return Err(e);
618                }
619            }
620        }
621        unreachable!("the loop returns on its last candidate")
622    }
623    let exclusive = rules.if_exists != IfExists::Overwrite;
624    for i in 1..=limit {
625        let to = candidates(i);
626        let r = if exclusive && !caps.create_exclusive {
627            match store.exists(&to) {
628                Ok(true) => Err(StoreError::Exists),
629                Ok(false) => store.write(local, &to, false),
630                Err(e) => Err(e),
631            }
632        } else {
633            store.write(local, &to, exclusive)
634        };
635        match r {
636            Ok(()) => return Ok(to),
637            Err(StoreError::Exists) if i < limit => continue,
638            Err(e) => return Err(e),
639        }
640    }
641    unreachable!("the loop returns on its last candidate")
642}
643
644/// `remote`, or for `i` > 1 the numbered name: `out/report_2.xlsx`.
645fn numbered(remote: &str, i: u32) -> String {
646    if i == 1 {
647        return remote.to_string();
648    }
649    let (dir, file) = split(remote);
650    let (stem, ext) = match file.rfind('.') {
651        Some(p) if p > 0 => (&file[..p], &file[p..]),
652        _ => (file, ""),
653    };
654    format!("{dir}{stem}_{i}{ext}")
655}
656
657/// The temporary name for `remote`: `.<name>.dre-part`, next to it or in `temp_dir`.
658fn temp_path(remote: &str, temp_dir: Option<&str>) -> String {
659    let (dir, file) = split(remote);
660    let name = format!(".{file}.dre-part");
661    match temp_dir {
662        None => format!("{dir}{name}"),
663        Some(t) if t.starts_with('/') || Path::new(t).is_absolute() => {
664            format!("{}/{name}", t.trim_end_matches(['/', '\\']))
665        }
666        Some(t) => format!("{dir}{}/{name}", t.trim_end_matches(['/', '\\'])),
667    }
668}
669
670/// `("out/", "report.xlsx")`: the folder (with its separator) and the file name.
671fn split(remote: &str) -> (&str, &str) {
672    match remote.rfind(['/', '\\']) {
673        Some(p) => (&remote[..=p], &remote[p + 1..]),
674        None => ("", remote),
675    }
676}
677
678/// The local file system, for core's `local` destination. Paths are file system paths.
679#[derive(Debug, Default)]
680pub struct LocalStore;
681
682fn io_failed(what: &str, path: &str, e: std::io::Error) -> StoreError {
683    if e.kind() == std::io::ErrorKind::AlreadyExists {
684        StoreError::Exists
685    } else {
686        StoreError::Failed(format!("can't {what} {path}: {e}"))
687    }
688}
689
690fn make_parent(path: &str) -> Result<(), StoreError> {
691    match Path::new(path).parent() {
692        Some(p) if !p.as_os_str().is_empty() => std::fs::create_dir_all(p)
693            .map_err(|e| StoreError::Failed(format!("can't create {}: {e}", p.display()))),
694        _ => Ok(()),
695    }
696}
697
698impl Store for LocalStore {
699    fn caps(&self) -> Caps {
700        Caps {
701            create_exclusive: true,
702            rename_no_replace: true,
703            visible_when_complete: false,
704        }
705    }
706
707    fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError> {
708        make_parent(remote)?;
709        if !exclusive {
710            return std::fs::copy(local, remote)
711                .map(|_| ())
712                .map_err(|e| io_failed("write", remote, e));
713        }
714        let mut src = std::fs::File::open(local)
715            .map_err(|e| StoreError::Failed(format!("can't read {}: {e}", local.display())))?;
716        let mut dst = std::fs::OpenOptions::new()
717            .write(true)
718            .create_new(true)
719            .open(remote)
720            .map_err(|e| io_failed("write", remote, e))?;
721        std::io::copy(&mut src, &mut dst).map_err(|e| {
722            let _ = std::fs::remove_file(remote);
723            io_failed("write", remote, e)
724        })?;
725        Ok(())
726    }
727
728    fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError> {
729        make_parent(to)?;
730        if replace {
731            return std::fs::rename(from, to).map_err(|e| io_failed("rename to", to, e));
732        }
733        // A hard link fails if `to` exists, in one step; then drop the old name.
734        std::fs::hard_link(from, to).map_err(|e| io_failed("rename to", to, e))?;
735        std::fs::remove_file(from).map_err(|e| io_failed("remove", from, e))
736    }
737
738    fn exists(&mut self, remote: &str) -> Result<bool, StoreError> {
739        Ok(Path::new(remote).exists())
740    }
741
742    fn delete(&mut self, remote: &str) -> Result<(), StoreError> {
743        match std::fs::remove_file(remote) {
744            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(io_failed("remove", remote, e)),
745            _ => Ok(()),
746        }
747    }
748}
749
750#[cfg(test)]
751mod tests {
752    use super::*;
753    use serde_json::json;
754    use std::collections::BTreeMap;
755
756    /// An in-memory store that can fail on cue.
757    #[derive(Default)]
758    struct Fake {
759        caps: Caps,
760        files: BTreeMap<String, String>,
761        /// Temporary failures to give before writes succeed.
762        flaky: u32,
763        /// Fail the next rename (a dropped connection after the upload).
764        break_rename: bool,
765        log: Vec<String>,
766    }
767
768    impl Store for Fake {
769        fn caps(&self) -> Caps {
770            self.caps
771        }
772        fn write(&mut self, local: &Path, remote: &str, exclusive: bool) -> Result<(), StoreError> {
773            self.log
774                .push(format!("write {remote}{}", if exclusive { " excl" } else { "" }));
775            if self.flaky > 0 {
776                self.flaky -= 1;
777                return Err(StoreError::temporary("connection reset"));
778            }
779            if exclusive && self.files.contains_key(remote) {
780                return Err(StoreError::Exists);
781            }
782            self.files.insert(remote.into(), local.display().to_string());
783            Ok(())
784        }
785        fn rename(&mut self, from: &str, to: &str, replace: bool) -> Result<(), StoreError> {
786            self.log.push(format!(
787                "rename {from} {to}{}",
788                if replace { " replace" } else { "" }
789            ));
790            if std::mem::take(&mut self.break_rename) {
791                return Err(StoreError::Failed("connection dropped".into()));
792            }
793            if !replace && self.files.contains_key(to) {
794                return Err(StoreError::Exists);
795            }
796            let v = self
797                .files
798                .remove(from)
799                .ok_or(StoreError::Failed("no such file".into()))?;
800            self.files.insert(to.into(), v);
801            Ok(())
802        }
803        fn exists(&mut self, remote: &str) -> Result<bool, StoreError> {
804            self.log.push(format!("exists {remote}"));
805            Ok(self.files.contains_key(remote))
806        }
807        fn delete(&mut self, remote: &str) -> Result<(), StoreError> {
808            self.files.remove(remote);
809            Ok(())
810        }
811    }
812
813    fn all_caps() -> Caps {
814        Caps {
815            create_exclusive: true,
816            rename_no_replace: true,
817            visible_when_complete: false,
818        }
819    }
820
821    fn run(store: &mut Fake, rules: &Rules) -> Result<Delivered, DeliveryError> {
822        deliver_with(store, Path::new("new"), "out/r.xlsx", rules, &mut |_| {})
823    }
824
825    fn rules(if_exists: IfExists, atomic: bool) -> Rules {
826        Rules {
827            if_exists,
828            atomic,
829            ..Rules::default()
830        }
831    }
832
833    #[test]
834    fn legacy_rules_write_straight_to_the_final_name() {
835        let mut s = Fake::default();
836        let d = run(&mut s, &Rules::legacy()).unwrap();
837        assert_eq!((d.path.as_str(), d.attempts), ("out/r.xlsx", 1));
838        assert_eq!(s.log, ["write out/r.xlsx"]);
839    }
840
841    #[test]
842    fn atomic_uploads_under_a_temporary_name_then_renames() {
843        let mut s = Fake {
844            caps: all_caps(),
845            ..Fake::default()
846        };
847        s.files.insert("out/r.xlsx".into(), "old".into());
848        run(&mut s, &rules(IfExists::Overwrite, true)).unwrap();
849        assert_eq!(
850            s.log,
851            [
852                "write out/.r.xlsx.dre-part",
853                "rename out/.r.xlsx.dre-part out/r.xlsx replace"
854            ]
855        );
856        assert_eq!(s.files.keys().collect::<Vec<_>>(), ["out/r.xlsx"]);
857        assert_eq!(s.files["out/r.xlsx"], "new");
858    }
859
860    #[test]
861    fn a_dropped_rename_leaves_no_file_at_the_final_name_or_temporary_one() {
862        let mut s = Fake {
863            caps: all_caps(),
864            break_rename: true,
865            ..Fake::default()
866        };
867        assert!(run(&mut s, &rules(IfExists::Overwrite, true)).is_err());
868        assert!(s.files.is_empty(), "{:?}", s.files);
869    }
870
871    #[test]
872    fn temp_dir_holds_the_temporary_file() {
873        let mut s = Fake {
874            caps: all_caps(),
875            ..Fake::default()
876        };
877        let mut r = rules(IfExists::Overwrite, true);
878        r.temp_dir = Some("../staging".into());
879        run(&mut s, &r).unwrap();
880        assert_eq!(s.log[0], "write out/../staging/.r.xlsx.dre-part");
881        r.temp_dir = Some("/tmp/".into());
882        s.log.clear();
883        run(&mut s, &r).unwrap();
884        assert_eq!(s.log[0], "write /tmp/.r.xlsx.dre-part");
885    }
886
887    #[test]
888    fn error_fails_when_the_file_exists_and_cleans_up() {
889        for atomic in [false, true] {
890            let mut s = Fake {
891                caps: all_caps(),
892                ..Fake::default()
893            };
894            s.files.insert("out/r.xlsx".into(), "old".into());
895            let e = run(&mut s, &rules(IfExists::Error, atomic)).unwrap_err();
896            assert!(e.exists && e.message.contains("already exists"), "{e:?}");
897            assert_eq!(s.files.len(), 1, "atomic={atomic}: {:?}", s.files);
898            assert_eq!(s.files["out/r.xlsx"], "old");
899            let pe = PluginError::from(e);
900            assert_eq!(
901                (pe.kind, pe.code.as_deref()),
902                (ErrorKind::Delivery, Some("file-exists"))
903            );
904        }
905    }
906
907    #[test]
908    fn number_keeps_both_files() {
909        for (atomic, caps) in [
910            (false, all_caps()),
911            (true, all_caps()),
912            (false, Caps::default()),
913            (true, Caps::default()),
914        ] {
915            let mut s = Fake {
916                caps,
917                ..Fake::default()
918            };
919            s.files.insert("out/r.xlsx".into(), "old".into());
920            s.files.insert("out/r_2.xlsx".into(), "old2".into());
921            let d = run(&mut s, &rules(IfExists::Number, atomic)).unwrap();
922            assert_eq!(d.path, "out/r_3.xlsx", "atomic={atomic} caps={caps:?}");
923            assert_eq!(s.files["out/r_3.xlsx"], "new");
924            assert_eq!(s.files.len(), 3, "{:?}", s.files);
925        }
926    }
927
928    #[test]
929    fn without_single_step_checks_it_looks_first() {
930        let mut s = Fake::default();
931        s.files.insert("out/r.xlsx".into(), "old".into());
932        assert!(run(&mut s, &rules(IfExists::Error, false)).unwrap_err().exists);
933        assert_eq!(s.log, ["exists out/r.xlsx"]);
934    }
935
936    #[test]
937    fn temporary_errors_are_retried_then_give_up() {
938        let mut s = Fake {
939            flaky: 2,
940            ..Fake::default()
941        };
942        let mut waits = Vec::new();
943        let d = deliver_with(&mut s, Path::new("new"), "r.csv", &Rules::default(), &mut |w| {
944            waits.push(w)
945        })
946        .unwrap();
947        assert_eq!(d.attempts, 3);
948        assert_eq!(waits.len(), 2);
949        assert!(waits[0] < waits[1]);
950        let mut s = Fake {
951            flaky: 9,
952            ..Fake::default()
953        };
954        let e = deliver_with(&mut s, Path::new("new"), "r.csv", &Rules::default(), &mut |_| {}).unwrap_err();
955        assert_eq!(e.attempts, 4);
956        assert!(e.message.contains("after 4 tries"), "{e}");
957        let no_retry = Rules {
958            retries: 0,
959            ..Rules::default()
960        };
961        let mut s = Fake {
962            flaky: 1,
963            ..Fake::default()
964        };
965        assert_eq!(run(&mut s, &no_retry).unwrap_err().attempts, 1);
966    }
967
968    #[test]
969    fn object_stores_need_no_temporary_name() {
970        let mut s = Fake {
971            caps: Caps {
972                create_exclusive: true,
973                visible_when_complete: true,
974                ..Caps::default()
975            },
976            ..Fake::default()
977        };
978        run(&mut s, &rules(IfExists::Error, true)).unwrap();
979        assert_eq!(s.log, ["write out/r.xlsx excl"]);
980    }
981
982    #[test]
983    fn settings_parse_with_aliases_and_errors() {
984        let conn = json!({"connect_timeout": "2m", "upload_timeout": 90, "retries": "0"});
985        let opts = json!({"if_exists": "number", "atomic": false, "temp_dir": "../in"});
986        let (r, warnings) = Rules::from_settings(
987            Rules::default(),
988            conn.as_object().unwrap(),
989            opts.as_object().unwrap(),
990            &[("upload_timeout", "timeout")],
991        )
992        .unwrap();
993        assert_eq!(r.if_exists, IfExists::Number);
994        assert!(!r.atomic);
995        assert_eq!(r.temp_dir.as_deref(), Some("../in"));
996        assert_eq!(r.retries, 0);
997        assert_eq!(r.connect_timeout, Duration::from_secs(120));
998        assert_eq!(r.timeout, Duration::from_secs(90));
999        assert_eq!(warnings, ["`upload_timeout` is deprecated; use `timeout`"]);
1000        let bad = json!({"if_exists": "keep"});
1001        assert!(
1002            Rules::from_settings(Rules::default(), &Map::new(), bad.as_object().unwrap(), &[])
1003                .unwrap_err()
1004                .contains("if_exists")
1005        );
1006    }
1007
1008    #[test]
1009    fn durations_take_units_or_seconds() {
1010        for (v, secs) in [
1011            (json!("30s"), 30.0),
1012            (json!("5m"), 300.0),
1013            (json!("1h"), 3600.0),
1014            (json!("1500ms"), 1.5),
1015            (json!(45), 45.0),
1016            (json!("7"), 7.0),
1017        ] {
1018            assert_eq!(parse_duration(&v).unwrap(), Duration::from_secs_f64(secs), "{v}");
1019        }
1020        for v in [json!("soon"), json!("5 days"), json!(-1), json!(true)] {
1021            assert!(parse_duration(&v).is_err(), "{v}");
1022        }
1023    }
1024
1025    #[test]
1026    fn names_number_and_hide_the_temporary_file() {
1027        assert_eq!(numbered("out/r.xlsx", 2), "out/r_2.xlsx");
1028        assert_eq!(numbered("r", 3), "r_3");
1029        assert_eq!(numbered("a.b/.env", 2), "a.b/.env_2");
1030        assert_eq!(numbered("x.tar.gz", 2), "x.tar_2.gz");
1031        assert_eq!(temp_path("r.csv", None), ".r.csv.dre-part");
1032    }
1033
1034    #[test]
1035    fn the_local_store_follows_every_rule() {
1036        let dir = tempfile::tempdir().unwrap();
1037        let src = dir.path().join("src.csv");
1038        std::fs::write(&src, "new").unwrap();
1039        let dst = dir.path().join("out/r.csv");
1040        let dst = dst.to_str().unwrap();
1041        let mut s = LocalStore;
1042        deliver(&mut s, &src, dst, &Rules::legacy()).unwrap();
1043        assert_eq!(std::fs::read_to_string(dst).unwrap(), "new");
1044        std::fs::write(&src, "newer").unwrap();
1045        deliver(&mut s, &src, dst, &rules(IfExists::Overwrite, true)).unwrap();
1046        assert_eq!(std::fs::read_to_string(dst).unwrap(), "newer");
1047        let e = deliver(&mut s, &src, dst, &rules(IfExists::Error, true)).unwrap_err();
1048        assert!(e.exists);
1049        let d = deliver(&mut s, &src, dst, &rules(IfExists::Number, true)).unwrap();
1050        assert!(d.path.ends_with("r_2.csv"), "{d:?}");
1051        let d = deliver(&mut s, &src, dst, &rules(IfExists::Number, false)).unwrap();
1052        assert!(d.path.ends_with("r_3.csv"), "{d:?}");
1053        let mut names: Vec<String> = std::fs::read_dir(dir.path().join("out"))
1054            .unwrap()
1055            .map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
1056            .collect();
1057        names.sort();
1058        assert_eq!(names, ["r.csv", "r_2.csv", "r_3.csv"]);
1059    }
1060}
1061
1062/// The media type of a delivered file, by its extension: what an email attachment or an
1063/// uploaded object is labelled, so the recipient's mail client or browser opens it.
1064pub fn content_type(name: &str) -> &'static str {
1065    let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase());
1066    match ext.as_deref() {
1067        Some("csv") => "text/csv",
1068        Some("txt" | "dat" | "tsv" | "aba") => "text/plain",
1069        Some("md") => "text/markdown",
1070        Some("html" | "htm") => "text/html",
1071        Some("pdf") => "application/pdf",
1072        Some("xlsx") => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
1073        Some("parquet") => "application/vnd.apache.parquet",
1074        Some("json") => "application/json",
1075        _ => "application/octet-stream",
1076    }
1077}
1078
1079#[cfg(test)]
1080mod content_type_tests {
1081    #[test]
1082    fn documents_have_their_types() {
1083        assert_eq!(super::content_type("s/101.PDF"), "application/pdf");
1084        assert_eq!(super::content_type("a.html"), "text/html");
1085        assert_eq!(super::content_type("a.bin"), "application/octet-stream");
1086    }
1087}
1088
1089#[cfg(test)]
1090mod retry_wait_tests {
1091    use std::time::Duration;
1092
1093    use serde_json::json;
1094
1095    use super::Rules;
1096
1097    #[test]
1098    fn waits_double_from_retry_wait_capped_and_retry_after_wins() {
1099        let opts = json!({"retry_wait": "30s", "max_retry_wait": "2m"});
1100        let (r, _) = Rules::from_settings(
1101            Rules::default(),
1102            &Default::default(),
1103            opts.as_object().unwrap(),
1104            &[],
1105        )
1106        .unwrap();
1107        assert_eq!(r.wait(1, None), Duration::from_secs(30));
1108        assert_eq!(r.wait(2, None), Duration::from_secs(60));
1109        assert_eq!(r.wait(3, None), Duration::from_secs(120));
1110        assert_eq!(r.wait(4, None), Duration::from_secs(120));
1111        assert_eq!(r.wait(1, Some(Duration::from_secs(5))), Duration::from_secs(5));
1112        assert_eq!(
1113            r.wait(1, Some(Duration::from_secs(900))),
1114            Duration::from_secs(120)
1115        );
1116    }
1117}