libfw_server/lib.rs
1//! Embeddable `libfw` server: axum routing, bearer-token authorization,
2//! HTTP range handling and streaming upload/download.
3//!
4//! # Example
5//!
6//! ```no_run
7//! use std::sync::Arc;
8//! use axum::Router;
9//! use libfw_core::auth::{Action, PathValidator, TokenVerifier, Validator};
10//! use libfw_core::claims::{Permission, TokenClaims};
11//! use libfw_server::{router, ServerState};
12//!
13//! // 1. Token verifier: parse & verify bearer tokens into claims.
14//! #[derive(Clone)]
15//! struct MyVerifier;
16//! impl TokenVerifier for MyVerifier {
17//! fn verify(&self, token: &str) -> Result<TokenClaims, libfw_core::auth::AuthError> {
18//! Ok(TokenClaims {
19//! sub: token.to_string(),
20//! exp: None,
21//! permissions: vec![Permission::Read, Permission::Write],
22//! allowed_paths: vec!["/".to_string()],
23//! })
24//! }
25//! }
26//!
27//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
28//! let state = Arc::new(ServerState::builder()
29//! .storage(libfw_server::FsStorage::new("/srv/files"))
30//! .verifier(MyVerifier)
31//! .validator(PathValidator::new())
32//! .build());
33//!
34//! let app: Router = router(state);
35//! // ... serve `app` with your preferred hyper/tokio setup
36//! # Ok(())
37//! # }
38//! ```
39
40mod auth;
41mod handlers;
42mod http;
43mod storage;
44
45pub use auth::{AuthRejection, BearerClaims};
46pub use http::{
47 content_range_none_value, content_range_value, etag_matches_if_none_match, if_range_matches,
48 parse_range_header, ParsedRange, RangeParseError,
49};
50pub use storage::{FsStorage, FsSink};
51
52use std::sync::Arc;
53
54use axum::extract::Request;
55use axum::http::StatusCode;
56use axum::middleware::Next;
57use axum::response::{IntoResponse, Response};
58use axum::Router;
59use libfw_core::auth::{AuthError, TokenVerifier, Validator};
60use libfw_core::capabilities::{Capabilities, Limits, ZripLevels};
61use libfw_core::claims::TokenClaims;
62use libfw_core::compress::CompressionFormat;
63use libfw_core::pathmap::{IdentityPathCodec, PathCodec, PathCodecError};
64use libfw_core::storage::StorageBackend;
65pub use libfw_core::pathmap::MountPathCodec;
66#[cfg(feature = "path-encrypt")]
67pub use libfw_core::pathmap::EncryptedPathCodec;
68use libfw_core::{protocol_compatible, protocol_header_value, DEFAULT_MAX_UPLOAD_SIZE, HEADER_PROTOCOL};
69pub use libfw_core::{
70 HEADER_COMPRESS, HEADER_COMPRESS_LEVEL, HEADER_FILE_META, HEADER_FINAL, HEADER_OFFSET,
71 HEADER_SESSION, HEADER_SESSION_STATUS,
72};
73
74/// Error resolving a client-supplied (shadow) path to a real storage path.
75///
76/// Returned by [`ServerState::resolve_client_path`]; map it to HTTP as:
77/// - [`Invalid`](PathResolveError::Invalid) / [`Codec`](PathResolveError::Codec)
78/// → `400 Bad Request` (malformed or tampered shadow path),
79/// - [`Auth`](PathResolveError::Auth) → `401` / `403` as usual.
80#[derive(Debug, thiserror::Error)]
81pub enum PathResolveError {
82 /// The shadow path failed basic shape validation (absolute, `..`,
83 /// NUL byte, empty segments).
84 #[error("invalid path: {0}")]
85 Invalid(&'static str),
86 /// The codec could not decode the shadow (unknown version, bad
87 /// base64, tampered ciphertext, unmapped namespace).
88 #[error("cannot decode path: {0}")]
89 Codec(#[from] PathCodecError),
90 /// The decoded real path is not authorized for this token.
91 #[error(transparent)]
92 Auth(#[from] AuthError),
93}
94
95/// Immutable server configuration shared by all handlers.
96pub struct ServerState {
97 /// The storage backend serving file content.
98 pub storage: Arc<dyn StorageBackend>,
99 /// Turns bearer tokens into claims.
100 pub verifier: Arc<dyn TokenVerifier>,
101 /// Decides whether claims may access a path.
102 pub validator: Arc<dyn Validator>,
103 /// Real storage paths ↔ client-visible shadow paths.
104 ///
105 /// Defaults to [`IdentityPathCodec`] (no translation).
106 pub path_codec: Arc<dyn PathCodec>,
107 /// Compression applied to downloads when the client asks for it.
108 pub compression: CompressionFormat,
109 /// Upper bound for a single upload body.
110 pub max_upload_size: u64,
111 /// Advertised tuning-parameter ranges; `None` exports the libfw
112 /// built-in defaults (and enforces nothing beyond existing hard caps).
113 pub limits: Option<Limits>,
114 /// Advertised zrip level range; `None` keeps the legacy behavior:
115 /// the level header is ignored and downloads always use
116 /// [`ZRIP_DEFAULT_LEVEL`](libfw_core::ZRIP_DEFAULT_LEVEL).
117 pub zrip_levels: Option<ZripLevels>,
118 /// Sweep period of the stale session-temp cleaner (tus `Expiration`),
119 /// see [`ServerState::spawn_stale_session_cleanup`].
120 ///
121 /// Defaults to 1 hour; `Duration::ZERO` disables the sweeper.
122 pub cleanup_interval: std::time::Duration,
123 /// Max age of an abandoned session upload temp before it is removed.
124 ///
125 /// Defaults to [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)
126 /// (24 hours).
127 pub session_ttl: std::time::Duration,
128}
129
130impl ServerState {
131 /// Start building a server state.
132 pub fn builder() -> ServerStateBuilder {
133 ServerStateBuilder::default()
134 }
135
136 /// Check whether `claims` may perform `action` on `path`.
137 pub fn authorize(
138 &self,
139 claims: &libfw_core::claims::TokenClaims,
140 path: &str,
141 action: libfw_core::auth::Action,
142 ) -> Result<(), AuthError> {
143 self.validator.validate(claims, path, action)
144 }
145}
146
147impl ServerState {
148 /// Resolve a client-supplied (shadow) path to the canonical real path.
149 ///
150 /// Pipeline: shape-validate the shadow → [`PathCodec::decode`] →
151 /// shape-validate the decoded real path (defense in depth) →
152 /// authorize `action` **against the real path**, so
153 /// `allowed_paths` keeps its exact pre-translation semantics.
154 ///
155 /// The returned real path is safe to hand to the storage backend;
156 /// it must never be echoed back to the client — responses go through
157 /// [`ServerState::expose_path`].
158 pub fn resolve_client_path(
159 &self,
160 claims: &TokenClaims,
161 shadow: &str,
162 action: libfw_core::auth::Action,
163 ) -> Result<String, PathResolveError> {
164 let shadow = validate_rel_path(shadow).map_err(PathResolveError::Invalid)?;
165 // The canonical root (``) is not a shadow: it maps to itself
166 // (the root handler `GET /dir` passes it directly). Everything
167 // else must round-trip through the codec.
168 let real = if shadow.is_empty() {
169 shadow
170 } else {
171 self.path_codec.decode(&shadow)?
172 };
173 let real = validate_rel_path(&real).map_err(PathResolveError::Invalid)?;
174 self.authorize(claims, &real, action)?;
175 Ok(real)
176 }
177
178 /// Translate a real storage path for client consumption (shadow).
179 pub fn expose_path(&self, real: &str) -> String {
180 self.path_codec.encode(real)
181 }
182}
183
184/// Builder for [`ServerState`].
185pub struct ServerStateBuilder {
186 storage: Option<Arc<dyn StorageBackend>>,
187 verifier: Option<Arc<dyn TokenVerifier>>,
188 validator: Option<Arc<dyn Validator>>,
189 path_codec: Option<Arc<dyn PathCodec>>,
190 compression: CompressionFormat,
191 max_upload_size: u64,
192 limits: Option<Limits>,
193 zrip_levels: Option<ZripLevels>,
194 cleanup_interval: std::time::Duration,
195 session_ttl: std::time::Duration,
196}
197
198impl Default for ServerStateBuilder {
199 fn default() -> Self {
200 ServerStateBuilder {
201 storage: None,
202 verifier: None,
203 validator: None,
204 path_codec: None,
205 compression: CompressionFormat::Zrip,
206 max_upload_size: DEFAULT_MAX_UPLOAD_SIZE,
207 limits: None,
208 zrip_levels: None,
209 cleanup_interval: std::time::Duration::from_secs(3600),
210 session_ttl: libfw_core::DEFAULT_SESSION_TTL,
211 }
212 }
213}
214
215impl ServerStateBuilder {
216 /// Required: the storage backend.
217 pub fn storage(mut self, storage: impl StorageBackend) -> Self {
218 self.storage = Some(Arc::new(storage));
219 self
220 }
221
222 /// Required: the token verifier.
223 pub fn verifier(mut self, verifier: impl TokenVerifier) -> Self {
224 self.verifier = Some(Arc::new(verifier));
225 self
226 }
227
228 /// Required: the path/permission validator.
229 pub fn validator(mut self, validator: impl Validator) -> Self {
230 self.validator = Some(Arc::new(validator));
231 self
232 }
233
234 /// Path translator between real storage paths and client-visible
235 /// shadow paths (default: identity — no translation).
236 ///
237 /// Handlers resolve inbound shadow paths through
238 /// [`ServerState::resolve_client_path`] (which also authorizes the
239 /// real path) and encode outbound paths via
240 /// [`ServerState::expose_path`]. See [`MountPathCodec`] for readable
241 /// aliases and [`EncryptedPathCodec`] for opaque encrypted paths
242 /// (`libfw-core/path-encrypt` feature).
243 pub fn path_codec(mut self, codec: impl PathCodec) -> Self {
244 self.path_codec = Some(Arc::new(codec));
245 self
246 }
247
248 /// Compression for downloads (default: `Zrip`).
249 pub fn compression(mut self, format: CompressionFormat) -> Self {
250 self.compression = format;
251 self
252 }
253
254 /// Maximum upload size in bytes (default: 100 GiB).
255 pub fn max_upload_size(mut self, size: u64) -> Self {
256 self.max_upload_size = size;
257 self
258 }
259
260 /// Tuning-parameter ranges advertised at `GET /capabilities`.
261 ///
262 /// `None` (default) exports the libfw built-in defaults. The server
263 /// never hard-enforces these beyond existing caps (frame size, upload
264 /// body limit); they are an advisory contract for adaptive clients.
265 pub fn limits(mut self, limits: Limits) -> Self {
266 self.limits = Some(limits);
267 self
268 }
269
270 /// Zrip level range for downloads: validates/clamps the
271 /// `x-libfw-compress-level` request header and echoes the actual level.
272 ///
273 /// `None` (default) keeps the legacy behavior — the header is ignored
274 /// and every zrip download uses [`ZRIP_DEFAULT_LEVEL`].
275 pub fn zrip_levels(mut self, levels: ZripLevels) -> Self {
276 self.zrip_levels = Some(levels);
277 self
278 }
279
280 /// Sweep period of the stale session-temp cleaner (default: 1 hour;
281 /// `Duration::ZERO` disables the sweeper).
282 pub fn cleanup_interval(mut self, interval: std::time::Duration) -> Self {
283 self.cleanup_interval = interval;
284 self
285 }
286
287 /// Max age of an abandoned session upload temp before removal
288 /// (default: 24 hours, [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)).
289 pub fn session_ttl(mut self, ttl: std::time::Duration) -> Self {
290 self.session_ttl = ttl;
291 self
292 }
293
294 /// Build the state, panicking if required fields are missing.
295 pub fn build(self) -> ServerState {
296 ServerState {
297 storage: self.storage.expect("storage is required"),
298 verifier: self.verifier.expect("verifier is required"),
299 validator: self.validator.expect("validator is required"),
300 path_codec: self.path_codec.unwrap_or_else(|| Arc::new(IdentityPathCodec)),
301 compression: self.compression,
302 max_upload_size: self.max_upload_size,
303 limits: self.limits,
304 zrip_levels: self.zrip_levels,
305 cleanup_interval: self.cleanup_interval,
306 session_ttl: self.session_ttl,
307 }
308 }
309}
310
311impl ServerState {
312 /// The capability advertisement served at `GET /capabilities`.
313 ///
314 /// Filters the compression formats by the configured download
315 /// compression and overlays the optional builder overrides; unset
316 /// dimensions fall back to the libfw built-in defaults.
317 pub fn capabilities(&self) -> Capabilities {
318 let mut caps = Capabilities::default();
319 if let Some(limits) = &self.limits {
320 caps.limits = limits.clone();
321 }
322 if let Some(levels) = &self.zrip_levels {
323 caps.compression.zrip_levels = *levels;
324 }
325 caps.compression.formats = match self.compression {
326 CompressionFormat::Zrip => {
327 vec![CompressionFormat::None, CompressionFormat::Zrip]
328 }
329 CompressionFormat::None => vec![CompressionFormat::None],
330 };
331 caps
332 }
333
334 /// Spawn the stale session-temp cleaner as a background task.
335 ///
336 /// Every [`cleanup_interval`](ServerState::cleanup_interval) it sweeps
337 /// the storage for abandoned session-upload temps and removes the ones
338 /// whose last write is older than [`session_ttl`](ServerState::session_ttl)
339 /// (tus `Expiration`; a client that vanished mid-upload never leaves its
340 /// `.libfw-sess-*` temp / `.blocks` sidecar behind forever).
341 ///
342 /// A zero `cleanup_interval` disables the sweeper: the returned handle
343 /// completes immediately. Backends that keep no long-lived session temps
344 /// (the [`StorageBackend`] trait default) simply report `0` removals.
345 pub fn spawn_stale_session_cleanup(
346 self: &Arc<Self>,
347 ) -> tokio::task::JoinHandle<()> {
348 let interval = self.cleanup_interval;
349 if interval.is_zero() {
350 return tokio::spawn(async {});
351 }
352 let storage = self.storage.clone();
353 let ttl = self.session_ttl;
354 let mut tick = tokio::time::interval(interval);
355 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
356 tokio::spawn(async move {
357 loop {
358 tick.tick().await;
359 match storage.cleanup_stale_sessions(ttl).await {
360 Ok(n) if n > 0 => {
361 tracing::info!("cleaned {n} stale upload session temp(s)")
362 }
363 _ => {}
364 }
365 }
366 })
367 }
368}
369
370/// Reject requests that explicitly advertise an incompatible protocol
371/// version with `426 Upgrade Required`.
372///
373/// Requests *without* the handshake header are allowed, so raw HTTP clients
374/// (curl, tests, older builds) keep working; the WASM/SDK client always
375/// sends the header so it is guaranteed to be matched with this server.
376async fn validate_protocol(req: Request, next: Next) -> Response {
377 if let Some(value) = req
378 .headers()
379 .get(HEADER_PROTOCOL)
380 .and_then(|v| v.to_str().ok())
381 {
382 if !protocol_compatible(value) {
383 return (
384 StatusCode::UPGRADE_REQUIRED,
385 format!(
386 "unsupported protocol `{value}`; expected `{}`",
387 protocol_header_value()
388 ),
389 )
390 .into_response();
391 }
392 }
393 next.run(req).await
394}
395
396/// Build the axum router with the libfw routes mounted.
397///
398/// Routes:
399/// - `GET /file/{*path}` — download with Range / ETag / compression
400/// - `HEAD /file/{*path}` — metadata only
401/// - `POST /file/{*path}` — streaming upload (headers: `x-libfw-file-meta`,
402/// optional `x-libfw-offset`, optional `x-libfw-compress`)
403/// - `GET /dir/{*path}` — directory listing (JSON)
404/// - `GET /capabilities` — capability advertisement (JSON, **public**:
405/// no auth — the payload is a non-sensitive contract for adaptive clients)
406///
407/// All routes first pass through [`validate_protocol`], which enforces the
408/// `x-libfw-protocol` handshake shared with the WASM client.
409pub fn router(state: Arc<ServerState>) -> Router {
410 use axum::routing::{get, post};
411
412 Router::new()
413 .route("/file/{*path}", get(handlers::download).head(handlers::head_file))
414 .route("/file/{*path}", post(handlers::upload))
415 .route("/dir", get(handlers::list_dir_root))
416 .route("/dir/{*path}", get(handlers::list_dir))
417 .route("/capabilities", get(handlers::capabilities))
418 .layer(axum::middleware::from_fn(validate_protocol))
419 .with_state(state)
420}
421
422/// Normalize and validate a virtual path from the URL.
423///
424/// Rejects absolute paths, `..` segments, NUL bytes and empty segments.
425pub fn validate_rel_path(path: &str) -> Result<String, &'static str> {
426 if path.contains('\0') {
427 return Err("path contains NUL byte");
428 }
429 if path.starts_with('/') {
430 return Err("path must be relative");
431 }
432 let mut out = String::with_capacity(path.len());
433 for segment in path.split('/') {
434 match segment {
435 "" | "." => {}
436 ".." => return Err("path escapes the mount root"),
437 seg => {
438 if !out.is_empty() {
439 out.push('/');
440 }
441 out.push_str(seg);
442 }
443 }
444 }
445 Ok(out)
446}