rainier_filesystem/lib.rs
1//! # rainier-filesystem
2//!
3//! File storage behind one port, with one point:
4//! an application that writes an upload should not know whether it lands on a
5//! disk, in a bucket, or in a test's memory.
6//!
7//! ```
8//! use rainier_filesystem::{Filesystem, FilesystemExt, MemoryFilesystem};
9//!
10//! # #[tokio::main] async fn main() -> rainier_support::Result<()> {
11//! let disk = MemoryFilesystem::new();
12//!
13//! disk.put_string("uploads/report.csv", "a,b,c").await?;
14//! assert_eq!(disk.get_string("uploads/report.csv").await?.as_deref(), Some("a,b,c"));
15//! assert_eq!(disk.size("uploads/report.csv").await?, Some(5));
16//! # Ok(()) }
17//! ```
18//!
19//! ## Drivers
20//!
21//! | Driver | Feature | Notes |
22//! |---|---|---|
23//! | [`LocalFilesystem`] | — | one directory on this machine |
24//! | [`MemoryFilesystem`] | — | tests |
25//! | `S3Filesystem` | `s3` | S3, **Cloudflare R2**, MinIO, B2, Wasabi |
26//!
27//! S3-compatible is not a special case. R2 and MinIO speak SigV4 against a
28//! different endpoint, so the difference is entirely in
29//! `AwsConfig`, from `rainier-drivers`:
30//!
31//! ```ignore
32//! // S3
33//! S3Filesystem::new(AwsClient::new(AwsConfig::from_env()?), "bucket")
34//!
35//! // R2 — the same driver
36//! S3Filesystem::new(
37//! AwsClient::new(
38//! AwsConfig::new(id, secret, "auto")
39//! .endpoint("https://account.r2.cloudflarestorage.com"),
40//! ),
41//! "bucket",
42//! )
43//! ```
44//!
45//! ## A missing file is not an error
46//!
47//! [`get`](Filesystem::get) returns `Ok(None)` for an absent file and `Err` only
48//! when the storage itself failed. "Read it if it is there" is the common case,
49//! and making a caller distinguish absence from failure by parsing a message is
50//! how absence ends up treated as an outage.
51//!
52//! [`FilesystemExt::get_or_fail`] is the other half, for a controller that wants
53//! the `404` in one `?`.
54//!
55//! ## Paths are checked by every driver
56//!
57//! Always `/`-separated, always relative, and `..` is **refused** rather than
58//! resolved:
59//!
60//! ```
61//! # use rainier_filesystem::{Filesystem, MemoryFilesystem};
62//! # #[tokio::main] async fn main() {
63//! let disk = MemoryFilesystem::new();
64//! assert!(disk.get("../../etc/passwd").await.is_err());
65//! # }
66//! ```
67//!
68//! Refusing beats resolving: a resolved traversal silently addresses a different
69//! file, so the caller believes it wrote where it asked. The rule is enforced in
70//! the **memory** driver too, so a test cannot pass with a path production would
71//! reject.
72//!
73//! The local driver adds a second guard — the resolved parent must canonicalise
74//! to somewhere inside the root — because a symlink inside the root can point
75//! out of it and `..` alone does not catch that.
76
77#![deny(missing_docs)]
78#![deny(rustdoc::broken_intra_doc_links)]
79
80pub mod driver;
81pub mod filesystem;
82pub mod local;
83pub mod memory;
84
85#[cfg(feature = "s3")]
86pub mod s3;
87
88pub use driver::FilesystemDriver;
89pub use filesystem::{normalise_path, normalise_prefix, Filesystem, FilesystemExt, Metadata};
90pub use local::LocalFilesystem;
91pub use memory::MemoryFilesystem;
92
93#[cfg(feature = "s3")]
94pub use s3::S3Filesystem;
95
96use std::sync::Arc;
97
98/// The application's storage, as one container-storable value.
99///
100/// A newtype over the port rather than binding `Arc<dyn Filesystem>` directly,
101/// so swapping a driver does not change the type every call site names — the
102/// same shape as `rainier-framework`'s `Views`.
103#[derive(Clone)]
104pub struct Storage {
105 disk: Arc<dyn Filesystem>,
106}
107
108impl Storage {
109 /// Wrap a filesystem.
110 pub fn new(disk: Arc<dyn Filesystem>) -> Self {
111 Self { disk }
112 }
113
114 /// Files under a local directory.
115 pub fn local(root: impl Into<std::path::PathBuf>) -> Self {
116 Self::new(Arc::new(LocalFilesystem::new(root)))
117 }
118
119 /// Files in memory — for tests.
120 pub fn memory() -> Self {
121 Self::new(Arc::new(MemoryFilesystem::new()))
122 }
123
124 /// The filesystem underneath.
125 pub fn disk(&self) -> &Arc<dyn Filesystem> {
126 &self.disk
127 }
128
129 /// The driver's name — `"local"`, `"s3"`, `"memory"`.
130 pub fn driver(&self) -> &str {
131 self.disk.name()
132 }
133}
134
135impl std::ops::Deref for Storage {
136 type Target = Arc<dyn Filesystem>;
137
138 /// So `Storage::instance().get(..)` works without naming `disk()`.
139 fn deref(&self) -> &Self::Target {
140 &self.disk
141 }
142}
143
144impl std::fmt::Debug for Storage {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 f.debug_struct("Storage").field("driver", &self.driver()).finish()
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use bytes::Bytes;
154
155 #[tokio::test]
156 async fn the_facade_value_delegates_to_its_disk() {
157 let storage = Storage::memory();
158
159 storage.put_string("a.txt", "hello").await.unwrap();
160 assert_eq!(storage.get_string("a.txt").await.unwrap().as_deref(), Some("hello"));
161 assert_eq!(storage.driver(), "memory");
162 }
163
164 /// Every driver has to behave the same from the outside — which is the point
165 /// of the port, and is where a driver-specific quirk would otherwise hide.
166 #[tokio::test]
167 async fn every_local_driver_behaves_identically() {
168 let root =
169 std::env::temp_dir().join("rainier-fs-parity").join(format!("{}", std::process::id()));
170 let _ = std::fs::remove_dir_all(&root);
171 std::fs::create_dir_all(&root).unwrap();
172
173 let disks: Vec<(&str, Arc<dyn Filesystem>)> = vec![
174 ("memory", Arc::new(MemoryFilesystem::new())),
175 ("local", Arc::new(LocalFilesystem::new(&root))),
176 ];
177
178 for (name, disk) in disks {
179 // Absence.
180 assert_eq!(disk.get("absent.txt").await.unwrap(), None, "{name}");
181 assert!(!disk.exists("absent.txt").await.unwrap(), "{name}");
182 assert!(!disk.delete("absent.txt").await.unwrap(), "{name}");
183
184 // Round trip.
185 disk.put("a/b.txt", Bytes::from_static(b"hello")).await.unwrap();
186 assert_eq!(disk.get("a/b.txt").await.unwrap().unwrap(), "hello", "{name}");
187 assert_eq!(disk.size("a/b.txt").await.unwrap(), Some(5), "{name}");
188
189 // Normalisation.
190 assert_eq!(disk.get("/a//b.txt").await.unwrap().unwrap(), "hello", "{name}");
191
192 // Traversal.
193 assert!(disk.get("../escape").await.is_err(), "{name}");
194
195 // Shallow listing.
196 disk.put("a/deeper/c.txt", Bytes::from_static(b"x")).await.unwrap();
197 let listed: Vec<String> =
198 disk.list("a").await.unwrap().into_iter().map(|m| m.path).collect();
199 assert_eq!(listed, vec!["a/b.txt".to_string()], "{name}");
200
201 // Copy and move.
202 disk.copy("a/b.txt", "copied.txt").await.unwrap();
203 assert!(disk.exists("a/b.txt").await.unwrap(), "{name}");
204 disk.rename("copied.txt", "moved.txt").await.unwrap();
205 assert!(!disk.exists("copied.txt").await.unwrap(), "{name}");
206 assert_eq!(disk.get("moved.txt").await.unwrap().unwrap(), "hello", "{name}");
207
208 // A 404 from the failing variants.
209 assert_eq!(disk.get_or_fail("absent.txt").await.unwrap_err().status(), 404, "{name}");
210 assert_eq!(disk.copy("absent.txt", "x.txt").await.unwrap_err().status(), 404, "{name}");
211 }
212
213 let _ = std::fs::remove_dir_all(&root);
214 }
215}