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` 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}