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}