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` ignores the level header, so
115 /// every zrip download uses
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 /// **Hierarchical composition:** when the whole string is not a valid
156 /// shadow (e.g. a directory shadow with literal child segments
157 /// appended, `{dirShadow}/sub/file.txt`), the *longest* decodable
158 /// segment prefix is decoded and the remaining segments are appended
159 /// verbatim to the decoded real path before authorization. A directory
160 /// shadow therefore covers its whole subtree — uploads into
161 /// not-yet-listed children, downloads of freshly created files — with
162 /// no per-path shadow minting. The fallback only ever succeeds on a
163 /// prefix the codec itself accepts, so tampered or unknown shadows
164 /// still surface the original codec error (→ `400`); authorization
165 /// still runs on the combined **real** path, so `allowed_paths`
166 /// prefix semantics are unchanged.
167 ///
168 /// The returned real path is safe to hand to the storage backend;
169 /// it must never be echoed back to the client — responses go through
170 /// [`ServerState::expose_path`].
171 pub fn resolve_client_path(
172 &self,
173 claims: &TokenClaims,
174 shadow: &str,
175 action: libfw_core::auth::Action,
176 ) -> Result<String, PathResolveError> {
177 let shadow = validate_rel_path(shadow).map_err(PathResolveError::Invalid)?;
178 // The canonical root (``) is not a shadow: it maps to itself
179 // (the root handler `GET /dir` passes it directly). Everything
180 // else must round-trip through the codec.
181 let real = if shadow.is_empty() {
182 shadow
183 } else {
184 match self.path_codec.decode(&shadow) {
185 Ok(real) => real,
186 // Whole-string decode failed → try hierarchical
187 // composition (`{dirShadow}/{literal…}`); keep the
188 // original error when no prefix decodes.
189 Err(err) => match decode_compound(&*self.path_codec, &shadow) {
190 Some(real) => real,
191 None => return Err(PathResolveError::Codec(err)),
192 },
193 }
194 };
195 let real = validate_rel_path(&real).map_err(PathResolveError::Invalid)?;
196 self.authorize(claims, &real, action)?;
197 Ok(real)
198 }
199
200 /// Translate a real storage path for client consumption (shadow).
201 pub fn expose_path(&self, real: &str) -> String {
202 self.path_codec.encode(real)
203 }
204}
205
206/// Decode the **longest** decodable segment prefix of `shadow` and append
207/// the trailing literal segments to the decoded real path.
208///
209/// This is what gives shadow paths hierarchical composition: a shadow for
210/// `docs` used as `{shadow}/a.txt` resolves to `docs/a.txt`, so a single
211/// directory shadow covers its whole subtree. The search is
212/// longest-prefix-first (`a/b/c` tries `a/b`, then `a`), so a path mixing
213/// several shadow-like prefixes still picks the most specific one, and a
214/// full shadow — which the caller already tried as a whole — is never
215/// re-touched here. Returns `None` when no prefix decodes (tampered or
216/// unknown shadows; the caller keeps the original error).
217///
218/// The prefix must end on a segment boundary: composition splits at `/`
219/// only, so a decoded root can never swallow part of a literal segment.
220fn decode_compound(codec: &dyn PathCodec, shadow: &str) -> Option<String> {
221 let mut end = shadow.len();
222 while let Some(slash) = shadow[..end].rfind('/') {
223 end = slash;
224 let prefix = &shadow[..slash];
225 if let Ok(real) = codec.decode(prefix) {
226 let rest = &shadow[slash + 1..];
227 return Some(if real.is_empty() {
228 rest.to_string()
229 } else {
230 format!("{real}/{rest}")
231 });
232 }
233 }
234 None
235}
236
237/// Builder for [`ServerState`].
238pub struct ServerStateBuilder {
239 storage: Option<Arc<dyn StorageBackend>>,
240 verifier: Option<Arc<dyn TokenVerifier>>,
241 validator: Option<Arc<dyn Validator>>,
242 path_codec: Option<Arc<dyn PathCodec>>,
243 compression: CompressionFormat,
244 max_upload_size: u64,
245 limits: Option<Limits>,
246 zrip_levels: Option<ZripLevels>,
247 cleanup_interval: std::time::Duration,
248 session_ttl: std::time::Duration,
249}
250
251impl Default for ServerStateBuilder {
252 fn default() -> Self {
253 ServerStateBuilder {
254 storage: None,
255 verifier: None,
256 validator: None,
257 path_codec: None,
258 compression: CompressionFormat::Zrip,
259 max_upload_size: DEFAULT_MAX_UPLOAD_SIZE,
260 limits: None,
261 zrip_levels: None,
262 cleanup_interval: std::time::Duration::from_secs(3600),
263 session_ttl: libfw_core::DEFAULT_SESSION_TTL,
264 }
265 }
266}
267
268impl ServerStateBuilder {
269 /// Required: the storage backend.
270 pub fn storage(mut self, storage: impl StorageBackend) -> Self {
271 self.storage = Some(Arc::new(storage));
272 self
273 }
274
275 /// Required: the token verifier.
276 pub fn verifier(mut self, verifier: impl TokenVerifier) -> Self {
277 self.verifier = Some(Arc::new(verifier));
278 self
279 }
280
281 /// Required: the path/permission validator.
282 pub fn validator(mut self, validator: impl Validator) -> Self {
283 self.validator = Some(Arc::new(validator));
284 self
285 }
286
287 /// Path translator between real storage paths and client-visible
288 /// shadow paths (default: identity — no translation).
289 ///
290 /// Handlers resolve inbound shadow paths through
291 /// [`ServerState::resolve_client_path`] (which also authorizes the
292 /// real path) and encode outbound paths via
293 /// [`ServerState::expose_path`]. See [`MountPathCodec`] for readable
294 /// aliases and [`EncryptedPathCodec`] for opaque encrypted paths
295 /// (`libfw-core/path-encrypt` feature).
296 pub fn path_codec(mut self, codec: impl PathCodec) -> Self {
297 self.path_codec = Some(Arc::new(codec));
298 self
299 }
300
301 /// Compression for downloads (default: `Zrip`).
302 pub fn compression(mut self, format: CompressionFormat) -> Self {
303 self.compression = format;
304 self
305 }
306
307 /// Maximum upload size in bytes (default: 100 GiB).
308 pub fn max_upload_size(mut self, size: u64) -> Self {
309 self.max_upload_size = size;
310 self
311 }
312
313 /// Tuning-parameter ranges advertised at `GET /capabilities`.
314 ///
315 /// `None` (default) exports the libfw built-in defaults. The server
316 /// never hard-enforces these beyond existing caps (frame size, upload
317 /// body limit); they are an advisory contract for adaptive clients.
318 pub fn limits(mut self, limits: Limits) -> Self {
319 self.limits = Some(limits);
320 self
321 }
322
323 /// Zrip level range for downloads: validates/clamps the
324 /// `x-libfw-compress-level` request header and echoes the actual level.
325 ///
326 /// `None` (default) ignores the header, so every zrip download uses
327 /// [`ZRIP_DEFAULT_LEVEL`](libfw_core::ZRIP_DEFAULT_LEVEL).
328 pub fn zrip_levels(mut self, levels: ZripLevels) -> Self {
329 self.zrip_levels = Some(levels);
330 self
331 }
332
333 /// Sweep period of the stale session-temp cleaner (default: 1 hour;
334 /// `Duration::ZERO` disables the sweeper).
335 pub fn cleanup_interval(mut self, interval: std::time::Duration) -> Self {
336 self.cleanup_interval = interval;
337 self
338 }
339
340 /// Max age of an abandoned session upload temp before removal
341 /// (default: 24 hours, [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)).
342 pub fn session_ttl(mut self, ttl: std::time::Duration) -> Self {
343 self.session_ttl = ttl;
344 self
345 }
346
347 /// Build the state, panicking if required fields are missing.
348 pub fn build(self) -> ServerState {
349 ServerState {
350 storage: self.storage.expect("storage is required"),
351 verifier: self.verifier.expect("verifier is required"),
352 validator: self.validator.expect("validator is required"),
353 path_codec: self.path_codec.unwrap_or_else(|| Arc::new(IdentityPathCodec)),
354 compression: self.compression,
355 max_upload_size: self.max_upload_size,
356 limits: self.limits,
357 zrip_levels: self.zrip_levels,
358 cleanup_interval: self.cleanup_interval,
359 session_ttl: self.session_ttl,
360 }
361 }
362}
363
364impl ServerState {
365 /// The capability advertisement served at `GET /capabilities`.
366 ///
367 /// Filters the compression formats by the configured download
368 /// compression and overlays the optional builder overrides; unset
369 /// dimensions fall back to the libfw built-in defaults.
370 pub fn capabilities(&self) -> Capabilities {
371 let mut caps = Capabilities::default();
372 if let Some(limits) = &self.limits {
373 caps.limits = limits.clone();
374 }
375 // The advertised upload cap must never exceed what this server
376 // actually enforces: a client schedules/chunks uploads by it, so
377 // overstating it turns every large transfer into a 413 loop. The
378 // enforced limit (which includes `ServerStateBuilder::max_upload_size`)
379 // therefore always wins when it is the smaller of the two.
380 caps.limits.max_upload_size = caps.limits.max_upload_size.min(self.max_upload_size);
381 if let Some(levels) = &self.zrip_levels {
382 caps.compression.zrip_levels = *levels;
383 }
384 caps.compression.formats = match self.compression {
385 CompressionFormat::Zrip => {
386 vec![CompressionFormat::None, CompressionFormat::Zrip]
387 }
388 CompressionFormat::None => vec![CompressionFormat::None],
389 };
390 caps
391 }
392
393 /// Spawn the stale session-temp cleaner as a background task.
394 ///
395 /// Every [`cleanup_interval`](ServerState::cleanup_interval) it sweeps
396 /// the storage for abandoned session-upload temps and removes the ones
397 /// whose last write is older than [`session_ttl`](ServerState::session_ttl)
398 /// (tus `Expiration`; a client that vanished mid-upload never leaves its
399 /// `.libfw-sess-*` temp / `.blocks` sidecar behind forever).
400 ///
401 /// A zero `cleanup_interval` disables the sweeper: the returned handle
402 /// completes immediately. Backends that keep no long-lived session temps
403 /// (the [`StorageBackend`] trait default) simply report `0` removals.
404 pub fn spawn_stale_session_cleanup(
405 self: &Arc<Self>,
406 ) -> tokio::task::JoinHandle<()> {
407 let interval = self.cleanup_interval;
408 if interval.is_zero() {
409 return tokio::spawn(async {});
410 }
411 let storage = self.storage.clone();
412 let ttl = self.session_ttl;
413 let mut tick = tokio::time::interval(interval);
414 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
415 tokio::spawn(async move {
416 loop {
417 tick.tick().await;
418 match storage.cleanup_stale_sessions(ttl).await {
419 Ok(n) if n > 0 => {
420 tracing::info!("cleaned {n} stale upload session temp(s)")
421 }
422 _ => {}
423 }
424 }
425 })
426 }
427}
428
429/// Reject requests that explicitly advertise an incompatible protocol
430/// version with `426 Upgrade Required`.
431///
432/// Requests *without* the handshake header are allowed, so raw HTTP clients
433/// (curl, hand-written scripts) work without one; the WASM/SDK client always
434/// sends the header so it is guaranteed to be matched with this server.
435async fn validate_protocol(req: Request, next: Next) -> Response {
436 if let Some(value) = req
437 .headers()
438 .get(HEADER_PROTOCOL)
439 .and_then(|v| v.to_str().ok())
440 {
441 if !protocol_compatible(value) {
442 return (
443 StatusCode::UPGRADE_REQUIRED,
444 format!(
445 "unsupported protocol `{value}`; expected `{}`",
446 protocol_header_value()
447 ),
448 )
449 .into_response();
450 }
451 }
452 next.run(req).await
453}
454
455/// Build the axum router with the libfw routes mounted.
456///
457/// Routes:
458/// - `GET /file/{*path}` — download with Range / ETag / compression
459/// - `HEAD /file/{*path}` — metadata only
460/// - `POST /file/{*path}` — streaming upload (headers: `x-libfw-file-meta`,
461/// optional `x-libfw-offset`, optional `x-libfw-compress`)
462/// - `GET /dir/{*path}` — directory listing (JSON)
463/// - `GET /capabilities` — capability advertisement (JSON, **public**:
464/// no auth — the payload is a non-sensitive contract for adaptive clients)
465///
466/// All routes first pass through the protocol-version middleware, which
467/// enforces the `x-libfw-protocol` handshake shared with the WASM client.
468pub fn router(state: Arc<ServerState>) -> Router {
469 use axum::routing::{get, post};
470
471 Router::new()
472 .route("/file/{*path}", get(handlers::download).head(handlers::head_file))
473 .route("/file/{*path}", post(handlers::upload))
474 .route("/dir", get(handlers::list_dir_root))
475 .route("/dir/{*path}", get(handlers::list_dir))
476 .route("/capabilities", get(handlers::capabilities))
477 .layer(axum::middleware::from_fn(validate_protocol))
478 .with_state(state)
479}
480
481/// Normalize and validate a virtual path from the URL.
482///
483/// Rejects absolute paths, `..` segments, NUL bytes and empty segments.
484pub fn validate_rel_path(path: &str) -> Result<String, &'static str> {
485 if path.contains('\0') {
486 return Err("path contains NUL byte");
487 }
488 if path.starts_with('/') {
489 return Err("path must be relative");
490 }
491 let mut out = String::with_capacity(path.len());
492 for segment in path.split('/') {
493 match segment {
494 "" | "." => {}
495 ".." => return Err("path escapes the mount root"),
496 seg => {
497 if !out.is_empty() {
498 out.push('/');
499 }
500 out.push_str(seg);
501 }
502 }
503 }
504 Ok(out)
505}
506
507#[cfg(test)]
508mod path_tests {
509 use super::decode_compound;
510 use libfw_core::pathmap::{PathCodec, PathCodecError};
511
512 /// A codec that only ever accepts the literal string `"docs"` — a
513 /// stand-in for a whole-string codec like `EncryptedPathCodec`, which
514 /// cannot decode compound paths as a whole.
515 struct DocsOnly;
516 impl PathCodec for DocsOnly {
517 fn encode(&self, real: &str) -> String {
518 real.to_string()
519 }
520 fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
521 if shadow == "docs" {
522 Ok("real/docs".to_string())
523 } else {
524 Err(PathCodecError::Unmapped(shadow.to_string()))
525 }
526 }
527 }
528
529 /// A codec that never decodes anything.
530 struct Never;
531 impl PathCodec for Never {
532 fn encode(&self, real: &str) -> String {
533 real.to_string()
534 }
535 fn decode(&self, _shadow: &str) -> Result<String, PathCodecError> {
536 Err(PathCodecError::InvalidShadow("nope".to_string()))
537 }
538 }
539
540 #[test]
541 fn compound_decodes_dir_shadow_plus_literal_suffix() {
542 let real = decode_compound(&DocsOnly, "docs/sub/deep/file.txt").unwrap();
543 assert_eq!(real, "real/docs/sub/deep/file.txt");
544 }
545
546 #[test]
547 fn compound_prefers_longest_decodable_prefix() {
548 // Shorter prefix "docs" also decodes, but the longer "docs/sub"
549 // must win when the codec accepts it.
550 struct TwoLevel;
551 impl PathCodec for TwoLevel {
552 fn encode(&self, real: &str) -> String {
553 real.to_string()
554 }
555 fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
556 match shadow {
557 "docs" => Ok("real/docs".to_string()),
558 "docs/sub" => Ok("real/docs/sub".to_string()),
559 _ => Err(PathCodecError::Unmapped(shadow.to_string())),
560 }
561 }
562 }
563 let real = decode_compound(&TwoLevel, "docs/sub/deep/file.txt").unwrap();
564 assert_eq!(real, "real/docs/sub/deep/file.txt");
565 }
566
567 #[test]
568 fn compound_is_none_when_no_prefix_decodes() {
569 assert!(decode_compound(&Never, "abc").is_none());
570 assert!(decode_compound(&Never, "a/b/c").is_none());
571 // No '/' at all → nothing to split, even for a decodable prefix.
572 assert!(decode_compound(&DocsOnly, "docs").is_none());
573 }
574
575 #[test]
576 fn compound_decodes_identity_prefix_to_literal_rest() {
577 // A codec mapping a prefix to the root (""): the literal suffix
578 // becomes the whole result.
579 struct RootCodec;
580 impl PathCodec for RootCodec {
581 fn encode(&self, real: &str) -> String {
582 real.to_string()
583 }
584 fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
585 if shadow == "mnt" {
586 Ok(String::new())
587 } else {
588 Err(PathCodecError::Unmapped(shadow.to_string()))
589 }
590 }
591 }
592 let real = decode_compound(&RootCodec, "mnt/file.txt").unwrap();
593 assert_eq!(real, "file.txt");
594 }
595}