libfw-server 0.4.5

Embeddable libfw server handlers/middleware (axum integration)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
//! Embeddable `libfw` server: axum routing, bearer-token authorization,
//! HTTP range handling and streaming upload/download.
//!
//! # Example
//!
//! ```no_run
//! use std::sync::Arc;
//! use axum::Router;
//! use libfw_core::auth::{Action, PathValidator, TokenVerifier, Validator};
//! use libfw_core::claims::{Permission, TokenClaims};
//! use libfw_server::{router, ServerState};
//!
//! // 1. Token verifier: parse & verify bearer tokens into claims.
//! #[derive(Clone)]
//! struct MyVerifier;
//! impl TokenVerifier for MyVerifier {
//!     fn verify(&self, token: &str) -> Result<TokenClaims, libfw_core::auth::AuthError> {
//!         Ok(TokenClaims {
//!             sub: token.to_string(),
//!             exp: None,
//!             permissions: vec![Permission::Read, Permission::Write],
//!             allowed_paths: vec!["/".to_string()],
//!         })
//!     }
//! }
//!
//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
//! let state = Arc::new(ServerState::builder()
//!     .storage(libfw_server::FsStorage::new("/srv/files"))
//!     .verifier(MyVerifier)
//!     .validator(PathValidator::new())
//!     .build());
//!
//! let app: Router = router(state);
//! // ... serve `app` with your preferred hyper/tokio setup
//! # Ok(())
//! # }
//! ```

mod auth;
mod handlers;
mod http;
mod storage;

pub use auth::{AuthRejection, BearerClaims};
pub use http::{
    content_range_none_value, content_range_value, etag_matches_if_none_match, if_range_matches,
    parse_range_header, ParsedRange, RangeParseError,
};
pub use storage::{FsStorage, FsSink};

use std::sync::Arc;

use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum::Router;
use libfw_core::auth::{AuthError, TokenVerifier, Validator};
use libfw_core::capabilities::{Capabilities, Limits, ZripLevels};
use libfw_core::claims::TokenClaims;
use libfw_core::compress::CompressionFormat;
use libfw_core::pathmap::{IdentityPathCodec, PathCodec, PathCodecError};
use libfw_core::storage::StorageBackend;
pub use libfw_core::pathmap::MountPathCodec;
#[cfg(feature = "path-encrypt")]
pub use libfw_core::pathmap::EncryptedPathCodec;
use libfw_core::{protocol_compatible, protocol_header_value, DEFAULT_MAX_UPLOAD_SIZE, HEADER_PROTOCOL};
pub use libfw_core::{
    HEADER_COMPRESS, HEADER_COMPRESS_LEVEL, HEADER_FILE_META, HEADER_FINAL, HEADER_OFFSET,
    HEADER_SESSION, HEADER_SESSION_STATUS,
};

/// Error resolving a client-supplied (shadow) path to a real storage path.
///
/// Returned by [`ServerState::resolve_client_path`]; map it to HTTP as:
/// - [`Invalid`](PathResolveError::Invalid) / [`Codec`](PathResolveError::Codec)
///   → `400 Bad Request` (malformed or tampered shadow path),
/// - [`Auth`](PathResolveError::Auth) → `401` / `403` as usual.
#[derive(Debug, thiserror::Error)]
pub enum PathResolveError {
    /// The shadow path failed basic shape validation (absolute, `..`,
    /// NUL byte, empty segments).
    #[error("invalid path: {0}")]
    Invalid(&'static str),
    /// The codec could not decode the shadow (unknown version, bad
    /// base64, tampered ciphertext, unmapped namespace).
    #[error("cannot decode path: {0}")]
    Codec(#[from] PathCodecError),
    /// The decoded real path is not authorized for this token.
    #[error(transparent)]
    Auth(#[from] AuthError),
}

/// Immutable server configuration shared by all handlers.
pub struct ServerState {
    /// The storage backend serving file content.
    pub storage: Arc<dyn StorageBackend>,
    /// Turns bearer tokens into claims.
    pub verifier: Arc<dyn TokenVerifier>,
    /// Decides whether claims may access a path.
    pub validator: Arc<dyn Validator>,
    /// Real storage paths ↔ client-visible shadow paths.
    ///
    /// Defaults to [`IdentityPathCodec`] (no translation).
    pub path_codec: Arc<dyn PathCodec>,
    /// Compression applied to downloads when the client asks for it.
    pub compression: CompressionFormat,
    /// Upper bound for a single upload body.
    pub max_upload_size: u64,
    /// Advertised tuning-parameter ranges; `None` exports the libfw
    /// built-in defaults (and enforces nothing beyond existing hard caps).
    pub limits: Option<Limits>,
    /// Advertised zrip level range; `None` keeps the legacy behavior:
    /// the level header is ignored and downloads always use
    /// [`ZRIP_DEFAULT_LEVEL`](libfw_core::ZRIP_DEFAULT_LEVEL).
    pub zrip_levels: Option<ZripLevels>,
    /// Sweep period of the stale session-temp cleaner (tus `Expiration`),
    /// see [`ServerState::spawn_stale_session_cleanup`].
    ///
    /// Defaults to 1 hour; `Duration::ZERO` disables the sweeper.
    pub cleanup_interval: std::time::Duration,
    /// Max age of an abandoned session upload temp before it is removed.
    ///
    /// Defaults to [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)
    /// (24 hours).
    pub session_ttl: std::time::Duration,
}

impl ServerState {
    /// Start building a server state.
    pub fn builder() -> ServerStateBuilder {
        ServerStateBuilder::default()
    }

    /// Check whether `claims` may perform `action` on `path`.
    pub fn authorize(
        &self,
        claims: &libfw_core::claims::TokenClaims,
        path: &str,
        action: libfw_core::auth::Action,
    ) -> Result<(), AuthError> {
        self.validator.validate(claims, path, action)
    }
}

impl ServerState {
    /// Resolve a client-supplied (shadow) path to the canonical real path.
    ///
    /// Pipeline: shape-validate the shadow → [`PathCodec::decode`] →
    /// shape-validate the decoded real path (defense in depth) →
    /// authorize `action` **against the real path**, so
    /// `allowed_paths` keeps its exact pre-translation semantics.
    ///
    /// **Hierarchical composition:** when the whole string is not a valid
    /// shadow (e.g. a directory shadow with literal child segments
    /// appended, `{dirShadow}/sub/file.txt`), the *longest* decodable
    /// segment prefix is decoded and the remaining segments are appended
    /// verbatim to the decoded real path before authorization. A directory
    /// shadow therefore covers its whole subtree — uploads into
    /// not-yet-listed children, downloads of freshly created files — with
    /// no per-path shadow minting. The fallback only ever succeeds on a
    /// prefix the codec itself accepts, so tampered or unknown shadows
    /// still surface the original codec error (→ `400`); authorization
    /// still runs on the combined **real** path, so `allowed_paths`
    /// prefix semantics are unchanged.
    ///
    /// The returned real path is safe to hand to the storage backend;
    /// it must never be echoed back to the client — responses go through
    /// [`ServerState::expose_path`].
    pub fn resolve_client_path(
        &self,
        claims: &TokenClaims,
        shadow: &str,
        action: libfw_core::auth::Action,
    ) -> Result<String, PathResolveError> {
        let shadow = validate_rel_path(shadow).map_err(PathResolveError::Invalid)?;
        // The canonical root (``) is not a shadow: it maps to itself
        // (the root handler `GET /dir` passes it directly). Everything
        // else must round-trip through the codec.
        let real = if shadow.is_empty() {
            shadow
        } else {
            match self.path_codec.decode(&shadow) {
                Ok(real) => real,
                // Whole-string decode failed → try hierarchical
                // composition (`{dirShadow}/{literal…}`); keep the
                // original error when no prefix decodes.
                Err(err) => match decode_compound(&*self.path_codec, &shadow) {
                    Some(real) => real,
                    None => return Err(PathResolveError::Codec(err)),
                },
            }
        };
        let real = validate_rel_path(&real).map_err(PathResolveError::Invalid)?;
        self.authorize(claims, &real, action)?;
        Ok(real)
    }

    /// Translate a real storage path for client consumption (shadow).
    pub fn expose_path(&self, real: &str) -> String {
        self.path_codec.encode(real)
    }
}

/// Decode the **longest** decodable segment prefix of `shadow` and append
/// the trailing literal segments to the decoded real path.
///
/// This is what gives shadow paths hierarchical composition: a shadow for
/// `docs` used as `{shadow}/a.txt` resolves to `docs/a.txt`, so a single
/// directory shadow covers its whole subtree. The search is
/// longest-prefix-first (`a/b/c` tries `a/b`, then `a`), so a path mixing
/// several shadow-like prefixes still picks the most specific one, and a
/// full shadow — which the caller already tried as a whole — is never
/// re-touched here. Returns `None` when no prefix decodes (tampered or
/// unknown shadows; the caller keeps the original error).
///
/// The prefix must end on a segment boundary: composition splits at `/`
/// only, so a decoded root can never swallow part of a literal segment.
fn decode_compound(codec: &dyn PathCodec, shadow: &str) -> Option<String> {
    let mut end = shadow.len();
    while let Some(slash) = shadow[..end].rfind('/') {
        end = slash;
        let prefix = &shadow[..slash];
        if let Ok(real) = codec.decode(prefix) {
            let rest = &shadow[slash + 1..];
            return Some(if real.is_empty() {
                rest.to_string()
            } else {
                format!("{real}/{rest}")
            });
        }
    }
    None
}

/// Builder for [`ServerState`].
pub struct ServerStateBuilder {
    storage: Option<Arc<dyn StorageBackend>>,
    verifier: Option<Arc<dyn TokenVerifier>>,
    validator: Option<Arc<dyn Validator>>,
    path_codec: Option<Arc<dyn PathCodec>>,
    compression: CompressionFormat,
    max_upload_size: u64,
    limits: Option<Limits>,
    zrip_levels: Option<ZripLevels>,
    cleanup_interval: std::time::Duration,
    session_ttl: std::time::Duration,
}

impl Default for ServerStateBuilder {
    fn default() -> Self {
        ServerStateBuilder {
            storage: None,
            verifier: None,
            validator: None,
            path_codec: None,
            compression: CompressionFormat::Zrip,
            max_upload_size: DEFAULT_MAX_UPLOAD_SIZE,
            limits: None,
            zrip_levels: None,
            cleanup_interval: std::time::Duration::from_secs(3600),
            session_ttl: libfw_core::DEFAULT_SESSION_TTL,
        }
    }
}

impl ServerStateBuilder {
    /// Required: the storage backend.
    pub fn storage(mut self, storage: impl StorageBackend) -> Self {
        self.storage = Some(Arc::new(storage));
        self
    }

    /// Required: the token verifier.
    pub fn verifier(mut self, verifier: impl TokenVerifier) -> Self {
        self.verifier = Some(Arc::new(verifier));
        self
    }

    /// Required: the path/permission validator.
    pub fn validator(mut self, validator: impl Validator) -> Self {
        self.validator = Some(Arc::new(validator));
        self
    }

    /// Path translator between real storage paths and client-visible
    /// shadow paths (default: identity — no translation).
    ///
    /// Handlers resolve inbound shadow paths through
    /// [`ServerState::resolve_client_path`] (which also authorizes the
    /// real path) and encode outbound paths via
    /// [`ServerState::expose_path`]. See [`MountPathCodec`] for readable
    /// aliases and [`EncryptedPathCodec`] for opaque encrypted paths
    /// (`libfw-core/path-encrypt` feature).
    pub fn path_codec(mut self, codec: impl PathCodec) -> Self {
        self.path_codec = Some(Arc::new(codec));
        self
    }

    /// Compression for downloads (default: `Zrip`).
    pub fn compression(mut self, format: CompressionFormat) -> Self {
        self.compression = format;
        self
    }

    /// Maximum upload size in bytes (default: 100 GiB).
    pub fn max_upload_size(mut self, size: u64) -> Self {
        self.max_upload_size = size;
        self
    }

    /// Tuning-parameter ranges advertised at `GET /capabilities`.
    ///
    /// `None` (default) exports the libfw built-in defaults. The server
    /// never hard-enforces these beyond existing caps (frame size, upload
    /// body limit); they are an advisory contract for adaptive clients.
    pub fn limits(mut self, limits: Limits) -> Self {
        self.limits = Some(limits);
        self
    }

    /// Zrip level range for downloads: validates/clamps the
    /// `x-libfw-compress-level` request header and echoes the actual level.
    ///
    /// `None` (default) keeps the legacy behavior — the header is ignored
    /// and every zrip download uses [`ZRIP_DEFAULT_LEVEL`].
    pub fn zrip_levels(mut self, levels: ZripLevels) -> Self {
        self.zrip_levels = Some(levels);
        self
    }

    /// Sweep period of the stale session-temp cleaner (default: 1 hour;
    /// `Duration::ZERO` disables the sweeper).
    pub fn cleanup_interval(mut self, interval: std::time::Duration) -> Self {
        self.cleanup_interval = interval;
        self
    }

    /// Max age of an abandoned session upload temp before removal
    /// (default: 24 hours, [`DEFAULT_SESSION_TTL`](libfw_core::DEFAULT_SESSION_TTL)).
    pub fn session_ttl(mut self, ttl: std::time::Duration) -> Self {
        self.session_ttl = ttl;
        self
    }

    /// Build the state, panicking if required fields are missing.
    pub fn build(self) -> ServerState {
        ServerState {
            storage: self.storage.expect("storage is required"),
            verifier: self.verifier.expect("verifier is required"),
            validator: self.validator.expect("validator is required"),
            path_codec: self.path_codec.unwrap_or_else(|| Arc::new(IdentityPathCodec)),
            compression: self.compression,
            max_upload_size: self.max_upload_size,
            limits: self.limits,
            zrip_levels: self.zrip_levels,
            cleanup_interval: self.cleanup_interval,
            session_ttl: self.session_ttl,
        }
    }
}

impl ServerState {
    /// The capability advertisement served at `GET /capabilities`.
    ///
    /// Filters the compression formats by the configured download
    /// compression and overlays the optional builder overrides; unset
    /// dimensions fall back to the libfw built-in defaults.
    pub fn capabilities(&self) -> Capabilities {
        let mut caps = Capabilities::default();
        if let Some(limits) = &self.limits {
            caps.limits = limits.clone();
        }
        // The advertised upload cap must never exceed what this server
        // actually enforces: a client schedules/chunks uploads by it, so
        // overstating it turns every large transfer into a 413 loop. The
        // enforced limit (which includes `ServerStateBuilder::max_upload_size`)
        // therefore always wins when it is the smaller of the two.
        caps.limits.max_upload_size = caps.limits.max_upload_size.min(self.max_upload_size);
        if let Some(levels) = &self.zrip_levels {
            caps.compression.zrip_levels = *levels;
        }
        caps.compression.formats = match self.compression {
            CompressionFormat::Zrip => {
                vec![CompressionFormat::None, CompressionFormat::Zrip]
            }
            CompressionFormat::None => vec![CompressionFormat::None],
        };
        caps
    }

    /// Spawn the stale session-temp cleaner as a background task.
    ///
    /// Every [`cleanup_interval`](ServerState::cleanup_interval) it sweeps
    /// the storage for abandoned session-upload temps and removes the ones
    /// whose last write is older than [`session_ttl`](ServerState::session_ttl)
    /// (tus `Expiration`; a client that vanished mid-upload never leaves its
    /// `.libfw-sess-*` temp / `.blocks` sidecar behind forever).
    ///
    /// A zero `cleanup_interval` disables the sweeper: the returned handle
    /// completes immediately. Backends that keep no long-lived session temps
    /// (the [`StorageBackend`] trait default) simply report `0` removals.
    pub fn spawn_stale_session_cleanup(
        self: &Arc<Self>,
    ) -> tokio::task::JoinHandle<()> {
        let interval = self.cleanup_interval;
        if interval.is_zero() {
            return tokio::spawn(async {});
        }
        let storage = self.storage.clone();
        let ttl = self.session_ttl;
        let mut tick = tokio::time::interval(interval);
        tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
        tokio::spawn(async move {
            loop {
                tick.tick().await;
                match storage.cleanup_stale_sessions(ttl).await {
                    Ok(n) if n > 0 => {
                        tracing::info!("cleaned {n} stale upload session temp(s)")
                    }
                    _ => {}
                }
            }
        })
    }
}

/// Reject requests that explicitly advertise an incompatible protocol
/// version with `426 Upgrade Required`.
///
/// Requests *without* the handshake header are allowed, so raw HTTP clients
/// (curl, tests, older builds) keep working; the WASM/SDK client always
/// sends the header so it is guaranteed to be matched with this server.
async fn validate_protocol(req: Request, next: Next) -> Response {
    if let Some(value) = req
        .headers()
        .get(HEADER_PROTOCOL)
        .and_then(|v| v.to_str().ok())
    {
        if !protocol_compatible(value) {
            return (
                StatusCode::UPGRADE_REQUIRED,
                format!(
                    "unsupported protocol `{value}`; expected `{}`",
                    protocol_header_value()
                ),
            )
                .into_response();
        }
    }
    next.run(req).await
}

/// Build the axum router with the libfw routes mounted.
///
/// Routes:
/// - `GET  /file/{*path}` — download with Range / ETag / compression
/// - `HEAD /file/{*path}` — metadata only
/// - `POST /file/{*path}` — streaming upload (headers: `x-libfw-file-meta`,
///   optional `x-libfw-offset`, optional `x-libfw-compress`)
/// - `GET  /dir/{*path}`  — directory listing (JSON)
/// - `GET  /capabilities` — capability advertisement (JSON, **public**:
///   no auth — the payload is a non-sensitive contract for adaptive clients)
///
/// All routes first pass through [`validate_protocol`], which enforces the
/// `x-libfw-protocol` handshake shared with the WASM client.
pub fn router(state: Arc<ServerState>) -> Router {
    use axum::routing::{get, post};

    Router::new()
        .route("/file/{*path}", get(handlers::download).head(handlers::head_file))
        .route("/file/{*path}", post(handlers::upload))
        .route("/dir", get(handlers::list_dir_root))
        .route("/dir/{*path}", get(handlers::list_dir))
        .route("/capabilities", get(handlers::capabilities))
        .layer(axum::middleware::from_fn(validate_protocol))
        .with_state(state)
}

/// Normalize and validate a virtual path from the URL.
///
/// Rejects absolute paths, `..` segments, NUL bytes and empty segments.
pub fn validate_rel_path(path: &str) -> Result<String, &'static str> {
    if path.contains('\0') {
        return Err("path contains NUL byte");
    }
    if path.starts_with('/') {
        return Err("path must be relative");
    }
    let mut out = String::with_capacity(path.len());
    for segment in path.split('/') {
        match segment {
            "" | "." => {}
            ".." => return Err("path escapes the mount root"),
            seg => {
                if !out.is_empty() {
                    out.push('/');
                }
                out.push_str(seg);
            }
        }
    }
    Ok(out)
}

#[cfg(test)]
mod path_tests {
    use super::decode_compound;
    use libfw_core::pathmap::{PathCodec, PathCodecError};

    /// A codec that only ever accepts the literal string `"docs"` — a
    /// stand-in for a whole-string codec like `EncryptedPathCodec`, which
    /// cannot decode compound paths as a whole.
    struct DocsOnly;
    impl PathCodec for DocsOnly {
        fn encode(&self, real: &str) -> String {
            real.to_string()
        }
        fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
            if shadow == "docs" {
                Ok("real/docs".to_string())
            } else {
                Err(PathCodecError::Unmapped(shadow.to_string()))
            }
        }
    }

    /// A codec that never decodes anything.
    struct Never;
    impl PathCodec for Never {
        fn encode(&self, real: &str) -> String {
            real.to_string()
        }
        fn decode(&self, _shadow: &str) -> Result<String, PathCodecError> {
            Err(PathCodecError::InvalidShadow("nope".to_string()))
        }
    }

    #[test]
    fn compound_decodes_dir_shadow_plus_literal_suffix() {
        let real = decode_compound(&DocsOnly, "docs/sub/deep/file.txt").unwrap();
        assert_eq!(real, "real/docs/sub/deep/file.txt");
    }

    #[test]
    fn compound_prefers_longest_decodable_prefix() {
        // Shorter prefix "docs" also decodes, but the longer "docs/sub"
        // must win when the codec accepts it.
        struct TwoLevel;
        impl PathCodec for TwoLevel {
            fn encode(&self, real: &str) -> String {
                real.to_string()
            }
            fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
                match shadow {
                    "docs" => Ok("real/docs".to_string()),
                    "docs/sub" => Ok("real/docs/sub".to_string()),
                    _ => Err(PathCodecError::Unmapped(shadow.to_string())),
                }
            }
        }
        let real = decode_compound(&TwoLevel, "docs/sub/deep/file.txt").unwrap();
        assert_eq!(real, "real/docs/sub/deep/file.txt");
    }

    #[test]
    fn compound_is_none_when_no_prefix_decodes() {
        assert!(decode_compound(&Never, "abc").is_none());
        assert!(decode_compound(&Never, "a/b/c").is_none());
        // No '/' at all → nothing to split, even for a decodable prefix.
        assert!(decode_compound(&DocsOnly, "docs").is_none());
    }

    #[test]
    fn compound_decodes_identity_prefix_to_literal_rest() {
        // A codec mapping a prefix to the root (""): the literal suffix
        // becomes the whole result.
        struct RootCodec;
        impl PathCodec for RootCodec {
            fn encode(&self, real: &str) -> String {
                real.to_string()
            }
            fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
                if shadow == "mnt" {
                    Ok(String::new())
                } else {
                    Err(PathCodecError::Unmapped(shadow.to_string()))
                }
            }
        }
        let real = decode_compound(&RootCodec, "mnt/file.txt").unwrap();
        assert_eq!(real, "file.txt");
    }
}