Skip to main content

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    /// **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) keeps the legacy behavior — the header is ignored
327    /// and every zrip download uses [`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        if let Some(levels) = &self.zrip_levels {
376            caps.compression.zrip_levels = *levels;
377        }
378        caps.compression.formats = match self.compression {
379            CompressionFormat::Zrip => {
380                vec![CompressionFormat::None, CompressionFormat::Zrip]
381            }
382            CompressionFormat::None => vec![CompressionFormat::None],
383        };
384        caps
385    }
386
387    /// Spawn the stale session-temp cleaner as a background task.
388    ///
389    /// Every [`cleanup_interval`](ServerState::cleanup_interval) it sweeps
390    /// the storage for abandoned session-upload temps and removes the ones
391    /// whose last write is older than [`session_ttl`](ServerState::session_ttl)
392    /// (tus `Expiration`; a client that vanished mid-upload never leaves its
393    /// `.libfw-sess-*` temp / `.blocks` sidecar behind forever).
394    ///
395    /// A zero `cleanup_interval` disables the sweeper: the returned handle
396    /// completes immediately. Backends that keep no long-lived session temps
397    /// (the [`StorageBackend`] trait default) simply report `0` removals.
398    pub fn spawn_stale_session_cleanup(
399        self: &Arc<Self>,
400    ) -> tokio::task::JoinHandle<()> {
401        let interval = self.cleanup_interval;
402        if interval.is_zero() {
403            return tokio::spawn(async {});
404        }
405        let storage = self.storage.clone();
406        let ttl = self.session_ttl;
407        let mut tick = tokio::time::interval(interval);
408        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
409        tokio::spawn(async move {
410            loop {
411                tick.tick().await;
412                match storage.cleanup_stale_sessions(ttl).await {
413                    Ok(n) if n > 0 => {
414                        tracing::info!("cleaned {n} stale upload session temp(s)")
415                    }
416                    _ => {}
417                }
418            }
419        })
420    }
421}
422
423/// Reject requests that explicitly advertise an incompatible protocol
424/// version with `426 Upgrade Required`.
425///
426/// Requests *without* the handshake header are allowed, so raw HTTP clients
427/// (curl, tests, older builds) keep working; the WASM/SDK client always
428/// sends the header so it is guaranteed to be matched with this server.
429async fn validate_protocol(req: Request, next: Next) -> Response {
430    if let Some(value) = req
431        .headers()
432        .get(HEADER_PROTOCOL)
433        .and_then(|v| v.to_str().ok())
434    {
435        if !protocol_compatible(value) {
436            return (
437                StatusCode::UPGRADE_REQUIRED,
438                format!(
439                    "unsupported protocol `{value}`; expected `{}`",
440                    protocol_header_value()
441                ),
442            )
443                .into_response();
444        }
445    }
446    next.run(req).await
447}
448
449/// Build the axum router with the libfw routes mounted.
450///
451/// Routes:
452/// - `GET  /file/{*path}` — download with Range / ETag / compression
453/// - `HEAD /file/{*path}` — metadata only
454/// - `POST /file/{*path}` — streaming upload (headers: `x-libfw-file-meta`,
455///   optional `x-libfw-offset`, optional `x-libfw-compress`)
456/// - `GET  /dir/{*path}`  — directory listing (JSON)
457/// - `GET  /capabilities` — capability advertisement (JSON, **public**:
458///   no auth — the payload is a non-sensitive contract for adaptive clients)
459///
460/// All routes first pass through [`validate_protocol`], which enforces the
461/// `x-libfw-protocol` handshake shared with the WASM client.
462pub fn router(state: Arc<ServerState>) -> Router {
463    use axum::routing::{get, post};
464
465    Router::new()
466        .route("/file/{*path}", get(handlers::download).head(handlers::head_file))
467        .route("/file/{*path}", post(handlers::upload))
468        .route("/dir", get(handlers::list_dir_root))
469        .route("/dir/{*path}", get(handlers::list_dir))
470        .route("/capabilities", get(handlers::capabilities))
471        .layer(axum::middleware::from_fn(validate_protocol))
472        .with_state(state)
473}
474
475/// Normalize and validate a virtual path from the URL.
476///
477/// Rejects absolute paths, `..` segments, NUL bytes and empty segments.
478pub fn validate_rel_path(path: &str) -> Result<String, &'static str> {
479    if path.contains('\0') {
480        return Err("path contains NUL byte");
481    }
482    if path.starts_with('/') {
483        return Err("path must be relative");
484    }
485    let mut out = String::with_capacity(path.len());
486    for segment in path.split('/') {
487        match segment {
488            "" | "." => {}
489            ".." => return Err("path escapes the mount root"),
490            seg => {
491                if !out.is_empty() {
492                    out.push('/');
493                }
494                out.push_str(seg);
495            }
496        }
497    }
498    Ok(out)
499}
500
501#[cfg(test)]
502mod path_tests {
503    use super::decode_compound;
504    use libfw_core::pathmap::{PathCodec, PathCodecError};
505
506    /// A codec that only ever accepts the literal string `"docs"` — a
507    /// stand-in for a whole-string codec like `EncryptedPathCodec`, which
508    /// cannot decode compound paths as a whole.
509    struct DocsOnly;
510    impl PathCodec for DocsOnly {
511        fn encode(&self, real: &str) -> String {
512            real.to_string()
513        }
514        fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
515            if shadow == "docs" {
516                Ok("real/docs".to_string())
517            } else {
518                Err(PathCodecError::Unmapped(shadow.to_string()))
519            }
520        }
521    }
522
523    /// A codec that never decodes anything.
524    struct Never;
525    impl PathCodec for Never {
526        fn encode(&self, real: &str) -> String {
527            real.to_string()
528        }
529        fn decode(&self, _shadow: &str) -> Result<String, PathCodecError> {
530            Err(PathCodecError::InvalidShadow("nope".to_string()))
531        }
532    }
533
534    #[test]
535    fn compound_decodes_dir_shadow_plus_literal_suffix() {
536        let real = decode_compound(&DocsOnly, "docs/sub/deep/file.txt").unwrap();
537        assert_eq!(real, "real/docs/sub/deep/file.txt");
538    }
539
540    #[test]
541    fn compound_prefers_longest_decodable_prefix() {
542        // Shorter prefix "docs" also decodes, but the longer "docs/sub"
543        // must win when the codec accepts it.
544        struct TwoLevel;
545        impl PathCodec for TwoLevel {
546            fn encode(&self, real: &str) -> String {
547                real.to_string()
548            }
549            fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
550                match shadow {
551                    "docs" => Ok("real/docs".to_string()),
552                    "docs/sub" => Ok("real/docs/sub".to_string()),
553                    _ => Err(PathCodecError::Unmapped(shadow.to_string())),
554                }
555            }
556        }
557        let real = decode_compound(&TwoLevel, "docs/sub/deep/file.txt").unwrap();
558        assert_eq!(real, "real/docs/sub/deep/file.txt");
559    }
560
561    #[test]
562    fn compound_is_none_when_no_prefix_decodes() {
563        assert!(decode_compound(&Never, "abc").is_none());
564        assert!(decode_compound(&Never, "a/b/c").is_none());
565        // No '/' at all → nothing to split, even for a decodable prefix.
566        assert!(decode_compound(&DocsOnly, "docs").is_none());
567    }
568
569    #[test]
570    fn compound_decodes_identity_prefix_to_literal_rest() {
571        // A codec mapping a prefix to the root (""): the literal suffix
572        // becomes the whole result.
573        struct RootCodec;
574        impl PathCodec for RootCodec {
575            fn encode(&self, real: &str) -> String {
576                real.to_string()
577            }
578            fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
579                if shadow == "mnt" {
580                    Ok(String::new())
581                } else {
582                    Err(PathCodecError::Unmapped(shadow.to_string()))
583                }
584            }
585        }
586        let real = decode_compound(&RootCodec, "mnt/file.txt").unwrap();
587        assert_eq!(real, "file.txt");
588    }
589}