Skip to main content

snapdir_stores/
router.rs

1//! Store routing: scheme → adapter / binary-name resolution.
2//!
3//! Implements the frozen `_snapdir_get_store_bin_path` dispatch: a `--store`
4//! URL's protocol (the text before the first `:`) selects the storage adapter.
5//! The protocol must be lowercase alphanumeric (`grep -q "^[a-z0-9]*$"`); a
6//! protocol of `gs` is a **hardcoded special case** routed to the `gcs` adapter
7//! (scheme `gs`), and every other protocol `<proto>` routes to an adapter named
8//! `<proto>` (binary `snapdir-<proto>-store`).
9//!
10//! ```text
11//! gs://bucket/x   -> adapter "gcs"   (special case)        in-process
12//! s3://bucket/x   -> adapter "s3"                          in-process
13//! b2://bucket/x   -> adapter "b2"                          in-process
14//! file:///x       -> adapter "file"                        in-process
15//! foo://bar       -> adapter "foo"  (external/3rd-party)   snapdir-foo-store
16//! ```
17//!
18//! The Rust port ships the `file`, `s3`, `b2`, and `gcs` adapters in-process;
19//! any other (third-party) adapter is dispatched out-of-process to a
20//! `snapdir-<name>-store` binary on `PATH` via the emit-command shim
21//! ([`crate::shim`]). This module only *resolves* the route; it performs no
22//! I/O and does not spawn anything.
23
24use thiserror::Error;
25
26/// Errors produced while resolving a store URL to an adapter.
27#[derive(Debug, Error, PartialEq, Eq)]
28#[non_exhaustive]
29pub enum RouteError {
30    /// The store URL had no protocol/scheme (no text before the first `:`),
31    /// or the protocol contained characters outside `[a-z0-9]`.
32    ///
33    /// Mirrors the oracle's `grep -q "^[a-z0-9]*$"` rejection
34    /// (`Invalid store protocol: '<proto>'`).
35    #[error("invalid store protocol: '{protocol}'")]
36    InvalidProtocol {
37        /// The offending protocol text extracted from the store URL.
38        protocol: String,
39    },
40}
41
42/// Which snapdir storage adapter a store URL resolves to.
43///
44/// The four named variants are the adapters shipped **in-process** by the Rust
45/// port (no subprocess). [`Adapter::External`] is any third-party adapter,
46/// dispatched out-of-process to a `snapdir-<name>-store` binary via the
47/// emit-command shim.
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum Adapter {
50    /// Built-in `file://` backend (local directory).
51    File,
52    /// Built-in `s3://` backend (native AWS SDK).
53    S3,
54    /// Built-in `b2://` backend (native AWS SDK against Backblaze).
55    B2,
56    /// Built-in `gs://` backend (native Google Cloud Storage SDK). Scheme `gs`,
57    /// adapter name `gcs`.
58    Gcs,
59    /// A third-party adapter resolved to a `snapdir-<name>-store` binary on
60    /// `PATH`. `name` is the adapter name (the store protocol verbatim).
61    External {
62        /// The adapter name (equal to the store URL's protocol).
63        name: String,
64    },
65}
66
67impl Adapter {
68    /// The adapter's canonical name (`gs` resolves to `gcs`, matching the
69    /// oracle's special case).
70    #[must_use]
71    pub fn name(&self) -> &str {
72        match self {
73            Adapter::File => "file",
74            Adapter::S3 => "s3",
75            Adapter::B2 => "b2",
76            Adapter::Gcs => "gcs",
77            Adapter::External { name } => name,
78        }
79    }
80
81    /// The `snapdir-<name>-store` binary this adapter corresponds to.
82    ///
83    /// For the built-in adapters this is the helper binary the original
84    /// implementation would have shelled out to (one per `file`/`s3`/`b2`
85    /// adapter, plus the `gcs` adapter via the `gs`→`gcs` special case). The
86    /// Rust port serves the built-ins in-process and only spawns the binary for
87    /// [`Adapter::External`].
88    #[must_use]
89    pub fn store_binary(&self) -> String {
90        format!("snapdir-{}-store", self.name())
91    }
92
93    /// Whether this adapter is served in-process by the Rust port (`true`) or
94    /// dispatched to a third-party binary via the shim (`false`).
95    #[must_use]
96    pub fn is_builtin(&self) -> bool {
97        !matches!(self, Adapter::External { .. })
98    }
99}
100
101/// Extracts the protocol (scheme) from a store URL: the text before the first
102/// `:`, validated against `^[a-z0-9]*$` like the oracle.
103///
104/// # Errors
105///
106/// Returns [`RouteError::InvalidProtocol`] if the protocol contains any
107/// character outside `[a-z0-9]` (this also rejects an empty protocol from a
108/// URL like `://x` and a missing-colon URL, whose whole text becomes the
109/// candidate protocol and almost always contains an illegal character).
110pub fn store_protocol(store_url: &str) -> Result<&str, RouteError> {
111    // `cut -d':' -f1`: everything up to (not incl.) the first ':'. If there is
112    // no ':', `cut` returns the whole string, which is then validated.
113    let proto = store_url.split(':').next().unwrap_or("");
114    if proto.is_empty()
115        || !proto
116            .bytes()
117            .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit())
118    {
119        return Err(RouteError::InvalidProtocol {
120            protocol: proto.to_owned(),
121        });
122    }
123    Ok(proto)
124}
125
126/// Resolves a store URL to the [`Adapter`] that should serve it.
127///
128/// Implements `_snapdir_get_store_bin_path`'s protocol dispatch, including the
129/// hardcoded `gs`→`gcs` special case for the Google Cloud Storage adapter.
130///
131/// # Errors
132///
133/// Returns [`RouteError::InvalidProtocol`] if the store URL's protocol is not
134/// a non-empty `[a-z0-9]` string (see [`store_protocol`]).
135///
136/// # Examples
137///
138/// ```
139/// use snapdir_stores::router::{resolve_adapter, Adapter};
140///
141/// // gs:// is the hardcoded special case -> the "gcs" adapter
142/// let gcs = resolve_adapter("gs://bucket/x").unwrap();
143/// assert_eq!(gcs, Adapter::Gcs);
144/// assert_eq!(gcs.name(), "gcs");
145/// assert_eq!(gcs.store_binary(), format!("snapdir-{}-store", "gcs"));
146///
147/// assert_eq!(
148///     resolve_adapter("s3://b/x").unwrap().store_binary(),
149///     format!("snapdir-{}-store", "s3"),
150/// );
151/// assert_eq!(resolve_adapter("file:///x").unwrap(), Adapter::File);
152/// ```
153pub fn resolve_adapter(store_url: &str) -> Result<Adapter, RouteError> {
154    let proto = store_protocol(store_url)?;
155    Ok(match proto {
156        // Hardcoded special case: the `gs` scheme maps to the `gcs` adapter.
157        "gs" => Adapter::Gcs,
158        "file" => Adapter::File,
159        "s3" => Adapter::S3,
160        "b2" => Adapter::B2,
161        other => Adapter::External {
162            name: other.to_owned(),
163        },
164    })
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    #[test]
172    fn shim_router_gs_is_special_cased_to_gcs() {
173        let a = resolve_adapter("gs://bucket/x").unwrap();
174        assert_eq!(a, Adapter::Gcs);
175        assert_eq!(a.name(), "gcs");
176        assert_eq!(a.store_binary(), format!("snapdir-{}-store", "gcs"));
177        assert!(a.is_builtin());
178    }
179
180    #[test]
181    fn shim_router_s3_resolves_to_s3_store() {
182        let a = resolve_adapter("s3://bucket/path/to/dir").unwrap();
183        assert_eq!(a, Adapter::S3);
184        assert_eq!(a.store_binary(), format!("snapdir-{}-store", "s3"));
185        assert!(a.is_builtin());
186    }
187
188    #[test]
189    fn shim_router_b2_resolves_to_b2_store() {
190        let a = resolve_adapter("b2://bucket/x").unwrap();
191        assert_eq!(a, Adapter::B2);
192        assert_eq!(a.store_binary(), format!("snapdir-{}-store", "b2"));
193    }
194
195    #[test]
196    fn shim_router_file_is_builtin() {
197        let a = resolve_adapter("file:///long/term/storage/").unwrap();
198        assert_eq!(a, Adapter::File);
199        assert_eq!(a.store_binary(), format!("snapdir-{}-store", "file"));
200        assert!(a.is_builtin());
201    }
202
203    #[test]
204    fn shim_router_unknown_protocol_is_external_binary() {
205        let a = resolve_adapter("mock://bucket/x").unwrap();
206        assert_eq!(
207            a,
208            Adapter::External {
209                name: "mock".to_owned()
210            }
211        );
212        assert_eq!(a.name(), "mock");
213        assert_eq!(a.store_binary(), "snapdir-mock-store");
214        assert!(!a.is_builtin());
215    }
216
217    #[test]
218    fn shim_router_numeric_protocols_are_allowed() {
219        // The oracle's filter is `^[a-z0-9]*$`, so digits are legal (e.g. s3, b2).
220        assert_eq!(store_protocol("s3://b").unwrap(), "s3");
221        assert_eq!(store_protocol("b2://b").unwrap(), "b2");
222        assert_eq!(store_protocol("0a1://b").unwrap(), "0a1");
223    }
224
225    #[test]
226    fn shim_router_rejects_invalid_protocols() {
227        // Uppercase, missing scheme, and punctuation are all rejected by the
228        // oracle's `^[a-z0-9]*$` filter.
229        assert_eq!(
230            resolve_adapter("S3://b"),
231            Err(RouteError::InvalidProtocol {
232                protocol: "S3".to_owned()
233            })
234        );
235        assert!(matches!(
236            resolve_adapter("://b"),
237            Err(RouteError::InvalidProtocol { .. })
238        ));
239        assert!(matches!(
240            resolve_adapter("/just/a/path"),
241            Err(RouteError::InvalidProtocol { .. })
242        ));
243    }
244
245    #[test]
246    fn shim_router_protocol_is_text_before_first_colon() {
247        // `cut -d':' -f1` semantics: stop at the first colon even with more.
248        assert_eq!(store_protocol("gs://bucket/a:b:c").unwrap(), "gs");
249    }
250}