1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
use PathBuf;
use SystemTime;
use ;
use fs;
use crate;
use crateKEY_SEPARATOR;
use cratevalidate_blob_key;
pub
/// Convert a `SystemTime` to `DateTime<Utc>`.
/// Filesystem-backed blob store.
///
/// Blobs are stored as individual files under a root directory.
///
/// ## Key nesting — identical behaviour to S3
///
/// Keys containing `/` are mapped to nested directories. For example,
/// key `a/b/c.txt` is stored as `{root}/a/b/c.txt`. This is equivalent
/// to how S3 interprets `/` as a path separator — the FS backend and
/// the S3 backend are interchangeable in this regard.
///
/// ## Root path normalisation
///
/// The root path is **always** normalised to end with `/` (the
/// `KEY_SEPARATOR`). This ensures that the `prefix_subdir` helper
/// can correctly split prefix hints into directory + basename
/// components. For example:
///
/// - `"/tmp/my-blobs"` → `"/tmp/my-blobs/"` (trailing `/` added)
/// - `"/tmp/my-blobs/"` → `"/tmp/my-blobs/"` (unchanged)
///
/// Without this normalisation, a prefix hint like `"a/b"` would be
/// joined with the root to produce `"/tmp/my-blobs/a/b"` which does
/// **not** end with `/`, so `prefix_subdir` would strip the last
/// component and walk `"/tmp/my-blobs/"` instead — which is correct
/// for the basename case.
///
/// ## Key validation
///
/// All keys are validated by [`validate_blob_key`] before any storage
/// operation:
///
/// | Pattern | Behaviour |
/// |---------|----------|
/// | `".."` or `"."` component | Rejected — `Err(InvalidInput)` |
/// | Leading `"/"` | Rejected — would resolve to absolute path |
/// | Empty key | Rejected — no backend can store it |
/// | Contains `"\"` (backslash) | Rejected — ambiguous cross-platform |
/// | Contains empty component `"//"` | Rejected — ambiguous across backends |
/// | Trailing `"/"` | Rejected — ambiguous file vs directory semantics |
/// | Valid key like `a/b/c.txt` | Allowed — stored as `{root}/a/b/c.txt` |
///
/// ## Security note
///
/// Path traversal via `..` and `.` components is prevented by key validation.
/// However, **symlink attacks within the root directory are not mitigated** —
/// if an untrusted party can create a symlink inside the root directory or
/// replace a file with a symlink, a TOCTOU attack could cause data to be
/// read from or written to an unexpected location.
///
/// The root directory **must be trusted**. Do not use a root directory where
/// untrusted users can create files or symlinks.
///
/// No `canonicalize()` is performed — the path is computed as
/// `root.join(component).join(component)…` without resolving symlinks.
/// This avoids race conditions inherent to TOCTOU-style `canonicalize` calls
/// but means symlink-based attacks within the root are the caller's
/// responsibility.
///
/// ## Listing (`list`, `list_with_metadata`)
///
/// Both methods walk the root directory **recursively** (breadth-first).
/// Every file found is included; empty directories are ignored.
///
/// - The relative path from root is used as the blob key, with the
/// platform path separator (`/` on Linux, `\` on Windows) replaced
/// by `/` to produce a consistent, platform-independent key.
/// - The caller-supplied [`ListFilter`](crate::ListFilter) is applied to each key. Only
/// files whose keys pass the filter are returned.
/// - Results are sorted alphabetically by key.
///
/// ## Metadata (`get_with_metadata`, `list_with_metadata`)
///
/// - `stored_size` — populated from `fs::metadata::len()` (exact byte count).
/// - `modified_at` — populated from `fs::metadata::modified()` (filesystem
/// mtime, best-effort — see [`BlobMeta`](crate::BlobMeta) docs).
/// - `etag` — always `None` (filesystems have no native ETag).
///
/// # Example
///
/// ```rust,no_run
/// use xtax_blob_storage::{BlobStore, BlobInput, BlobStoreBuilder};
///
/// # #[cfg(feature = "fs")]
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # #[cfg(feature = "fs")]
/// # {
/// let store = BlobStoreBuilder::new()
/// .with_fs("/tmp/my-blobs")
/// .build()
/// .await?;
///
/// store.put(vec![BlobInput::new("greeting.txt", b"hello".as_slice())]).await?;
///
/// use tokio::io::AsyncReadExt;
/// let mut reader = store.get("greeting.txt").await?;
/// let mut buf = Vec::new();
/// reader.read_to_end(&mut buf).await?;
/// assert_eq!(buf, b"hello");
/// # Ok(())
/// # }
/// # }
/// # #[cfg(not(feature = "fs"))]
/// # fn main() {}
/// ```
///
/// Requires `fs` feature (enabled by default).