Skip to main content

libfw_core/
pathmap.rs

1//! Path translation: storage ("real") paths vs. client-visible shadow paths.
2//!
3//! Some deployments do not want real storage layout (directory names,
4//! hierarchy, naming habits) to reach the client. A [`PathCodec`] converts
5//! between the two sides:
6//!
7//! - [`encode`](PathCodec::encode): real → shadow (outbound; response
8//!   bodies, listings, upload echoes).
9//! - [`decode`](PathCodec::decode): shadow → real (inbound; request URLs).
10//!
11//! Codecs operate on **canonical relative paths** (what the server's
12//! `validate_rel_path` produces): no leading `/`, no `.`/`..`, no empty
13//! segments. The root is `""`.
14//!
15//! Authorization is unaffected by translation: the server validates the
16//! *real* path against `allowed_paths` (see the `resolve_client_path`
17//! helper in `libfw-server`), so claims always apply to the real path.
18//!
19//! **Hierarchical composition:** `resolve_client_path` also accepts a
20//! shadow with literal child segments appended (`{dirShadow}/sub/file`):
21//! it decodes the longest decodable segment prefix and appends the
22//! remainder verbatim, then authorizes the combined real path — a
23//! directory shadow covers its whole subtree, with no per-path shadow
24//! minting. Whole-string codecs such as `EncryptedPathCodec` get this
25//! for free; prefix codecs like [`MountPathCodec`] already behave that
26//! way natively.
27//!
28//! # Codecs
29//!
30//! | Codec | Shadow looks like | Use when |
31//! |---|---|---|
32//! | [`IdentityPathCodec`] | the real path | default; no translation |
33//! | `EncryptedPathCodec` | `v1.<base64url>` opaque blob | real paths must be hidden completely |
34//! | [`MountPathCodec`] | a readable alias (`home/alice/…`) | readable, stable shadow namespaces |
35
36#[cfg(feature = "path-encrypt")]
37use std::sync::Arc;
38
39/// Error decoding a client-supplied shadow path back to a real path.
40#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum PathCodecError {
42    /// The shadow did not carry a recognized format version.
43    #[error("unknown shadow path version `{0}`")]
44    UnknownVersion(String),
45    /// The shadow was structurally invalid (bad encoding, bad key data).
46    #[error("invalid shadow path: {0}")]
47    InvalidShadow(String),
48    /// The shadow does not belong to any configured mapping.
49    #[error("shadow path does not match any configured mapping: `{0}`")]
50    Unmapped(String),
51    /// The host has no usable OS CSPRNG, so AES-GCM path encryption cannot
52    /// generate safe nonces (see [`EncryptedPathCodec`]).
53    #[cfg(feature = "path-encrypt")]
54    #[error("path encryption unavailable: {0}")]
55    RngUnavailable(String),
56}
57
58/// Converts between real storage paths and client-visible shadow paths.
59///
60/// [`encode`](PathCodec::encode) is total: every real path *must* be
61/// representable (an encrypting codec can always encrypt; a mapping codec
62/// falls back to identity for unmapped subtrees so listings never fail).
63/// [`decode`](PathCodec::decode) fails when the shadow is invalid,
64/// tampered with, or unmapped — the caller turns that into a `400`.
65pub trait PathCodec: Send + Sync + 'static {
66    /// Real path → shadow path.
67    fn encode(&self, real: &str) -> String;
68    /// Shadow path → real path.
69    fn decode(&self, shadow: &str) -> Result<String, PathCodecError>;
70}
71
72/// The default codec: shadow paths *are* real paths. No translation.
73#[derive(Debug, Clone, Copy, Default)]
74pub struct IdentityPathCodec;
75
76impl PathCodec for IdentityPathCodec {
77    fn encode(&self, real: &str) -> String {
78        real.to_string()
79    }
80    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
81        Ok(shadow.to_string())
82    }
83}
84
85/// Prefix-mapping codec: readable shadow namespaces backed by real subtrees.
86///
87/// Each entry maps a shadow prefix to a real prefix (canonical relative
88/// form, no leading/trailing `/`, root is `""`):
89///
90/// ```
91/// use libfw_core::pathmap::{MountPathCodec, PathCodec};
92///
93/// let codec = MountPathCodec::new(vec![
94///     ("home/alice".to_string(), "data/vol-3/alice".to_string()),
95/// ]);
96///
97/// // real → shadow: the real subtree is aliased under the shadow prefix
98/// assert_eq!(
99///     codec.encode("data/vol-3/alice/report.pdf"),
100///     "home/alice/report.pdf"
101/// );
102/// // shadow → real
103/// assert_eq!(
104///     codec.decode("home/alice/report.pdf").unwrap(),
105///     "data/vol-3/alice/report.pdf"
106/// );
107/// // a shadow outside every mapping is rejected …
108/// assert!(codec.decode("other/thing.txt").is_err());
109/// // … while a real path outside every mapping passes through unchanged
110/// assert_eq!(codec.encode("public/readme.md"), "public/readme.md");
111/// ```
112///
113/// Matching is on segment boundaries: `home/alice` matches `home/alice/x`
114/// but not `home/alice2/x`. When several entries match, the longest shadow
115/// prefix wins (most specific mapping).
116#[derive(Debug, Clone, Default)]
117pub struct MountPathCodec {
118    /// `(shadow_prefix, real_prefix)`, ordered most-specific first.
119    entries: Vec<(String, String)>,
120}
121
122impl MountPathCodec {
123    /// Build a codec from `(shadow_prefix, real_prefix)` pairs.
124    ///
125    /// Prefixes are normalized (leading/trailing `/` and empty segments
126    /// stripped). A fully empty pair (root↔root) is ignored — use
127    /// `IdentityPathCodec` for that.
128    pub fn new(entries: Vec<(String, String)>) -> Self {
129        let norm: Vec<(String, String)> = entries
130            .into_iter()
131            .filter_map(|(shadow, real)| {
132                let s = normalize_prefix(&shadow);
133                let r = normalize_prefix(&real);
134                if s.is_empty() && r.is_empty() {
135                    return None;
136                }
137                Some((s, r))
138            })
139            .collect();
140        MountPathCodec { entries: norm }
141    }
142}
143
144impl PathCodec for MountPathCodec {
145    fn encode(&self, real: &str) -> String {
146        match self.encode_match(real) {
147            Some((shadow_root, rest)) => join_prefix(shadow_root, rest),
148            None => real.to_string(),
149        }
150    }
151
152    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
153        match self.decode_match(shadow) {
154            Some((real_root, rest)) => Ok(join_prefix(real_root, rest)),
155            None => Err(PathCodecError::Unmapped(shadow.to_string())),
156        }
157    }
158}
159
160impl MountPathCodec {
161    /// Longest entry whose **real** prefix matches `real` (encode
162    /// direction): returns the shadow root + the remainder.
163    fn encode_match<'a>(&'a self, real: &'a str) -> Option<(&'a str, &'a str)> {
164        self.entries
165            .iter()
166            .filter(|(_, r)| segment_prefix(real, r.as_str()))
167            .max_by_key(|(_, r)| r.len())
168            .map(|(s, r)| (s.as_str(), real[r.len()..].trim_start_matches('/')))
169    }
170
171    /// Longest entry whose **shadow** prefix matches `shadow` (decode
172    /// direction): returns the real root + the remainder.
173    fn decode_match<'a>(&'a self, shadow: &'a str) -> Option<(&'a str, &'a str)> {
174        self.entries
175            .iter()
176            .filter(|(s, _)| segment_prefix(shadow, s.as_str()))
177            .max_by_key(|(s, _)| s.len())
178            .map(|(s, r)| (r.as_str(), shadow[s.len()..].trim_start_matches('/')))
179    }
180}
181
182fn normalize_prefix(p: &str) -> String {
183    let mut out = String::new();
184    for seg in p.split('/') {
185        match seg {
186            "" | "." => {}
187            ".." => continue, // defensive: drop escapes rather than error
188            seg => {
189                if !out.is_empty() {
190                    out.push('/');
191                }
192                out.push_str(seg);
193            }
194        }
195    }
196    out
197}
198
199/// Does `prefix` match `path` on a segment boundary? The empty prefix
200/// matches everything.
201fn segment_prefix(path: &str, prefix: &str) -> bool {
202    if prefix.is_empty() {
203        return true;
204    }
205    if path == prefix {
206        return true;
207    }
208    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
209}
210
211fn join_prefix(root: &str, rest: &str) -> String {
212    if root.is_empty() {
213        return rest.to_string();
214    }
215    if rest.is_empty() {
216        return root.to_string();
217    }
218    format!("{root}/{rest}")
219}
220
221/// Encrypting codec: shadow paths are opaque `v1.<base64url>` blobs.
222///
223/// Real paths are AES-256-GCM encrypted with a server-held key; the
224/// ciphertext carries a random 96-bit nonce and a 128-bit auth tag, so the
225/// shadow is unreadable **and** unforgeable (tampering fails the tag check
226/// at decode time). Encrypting the same path twice yields different
227/// shadows: a listing leaks nothing, not even equality of paths.
228///
229/// Shadow format: `v1.` + base64url(no-pad) of `nonce(12) ‖ ciphertext+tag`.
230///
231/// ```
232/// use libfw_core::pathmap::{EncryptedPathCodec, PathCodec};
233///
234/// let codec = EncryptedPathCodec::from_hex(
235///     "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
236/// ).unwrap();
237///
238/// let shadow = codec.encode("docs/机密/plan.pdf");
239/// assert_ne!(shadow, "docs/机密/plan.pdf");
240/// assert_eq!(codec.decode(&shadow).unwrap(), "docs/机密/plan.pdf");
241/// // the same path encrypts differently every time
242/// assert_ne!(codec.encode("docs/机密/plan.pdf"), shadow);
243/// // tampered shadows are rejected
244/// let mut bad = shadow.into_bytes();
245/// let n = bad.len();
246/// bad[n - 1] = if bad[n - 1] == b'A' { b'B' } else { b'A' };
247/// assert!(codec.decode(&String::from_utf8(bad).unwrap()).is_err());
248/// ```
249#[cfg(feature = "path-encrypt")]
250#[derive(Clone)]
251pub struct EncryptedPathCodec {
252    cipher: Arc<aes_gcm::Aes256Gcm>,
253}
254
255#[cfg(feature = "path-encrypt")]
256impl std::fmt::Debug for EncryptedPathCodec {
257    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258        f.debug_struct("EncryptedPathCodec")
259            .field("cipher", &"<aes-256-gcm key hidden>")
260            .finish()
261    }
262}
263
264#[cfg(feature = "path-encrypt")]
265const SHADOW_VERSION: &str = "v1";
266#[cfg(feature = "path-encrypt")]
267const NONCE_LEN: usize = 12;
268
269#[cfg(feature = "path-encrypt")]
270fn random_bytes() -> [u8; NONCE_LEN] {
271    let mut nonce = [0u8; NONCE_LEN];
272    // AES-GCM is catastrophically broken by nonce reuse under a fixed key: two
273    // messages sharing a nonce leak their XOR *and* the GHASH subkey (which in
274    // turn allows forging tags). There is therefore no safe "degraded" source —
275    // a time-seeded PRNG hands an attacker predictable nonces — so this never
276    // substitutes a weaker one. The CSPRNG is validated at construction
277    // (`EncryptedPathCodec::from_hex`); reaching this abort means the host lost
278    // its OS RNG after startup, where failing loudly beats emitting a nonce
279    // that could silently defeat the encryption.
280    getrandom::getrandom(&mut nonce)
281        .expect("OS CSPRNG unavailable: refusing to generate a predictable AES-GCM nonce");
282    nonce
283}
284
285#[cfg(feature = "path-encrypt")]
286impl EncryptedPathCodec {
287    /// Build the codec from a 32-byte key.
288    ///
289    /// # Panics
290    ///
291    /// If the host has no usable OS CSPRNG: AES-GCM nonces must be
292    /// unpredictable (see `random_bytes`), so the codec refuses to exist
293    /// rather than silently emit predictable nonces. Use
294    /// [`EncryptedPathCodec::from_hex`] to get this as a recoverable error.
295    pub fn new(key: [u8; 32]) -> Self {
296        use aes_gcm::KeyInit;
297        // Fail closed at construction, not per request.
298        let _probe = random_bytes();
299        EncryptedPathCodec {
300            cipher: Arc::new(aes_gcm::Aes256Gcm::new((&key).into())),
301        }
302    }
303
304    /// Build the codec from a 64-char hex key (32 bytes).
305    pub fn from_hex(hex: &str) -> Result<Self, PathCodecError> {
306        let key = decode_hex(hex).ok_or_else(|| {
307            PathCodecError::InvalidShadow("LIBFW_PATH_KEY must be 64 hex chars (32 bytes)".into())
308        })?;
309        // Fail closed with a recoverable error: without an OS CSPRNG the
310        // nonces would be predictable (nonce reuse breaks AES-GCM), so an
311        // encrypted deployment must not start half-working.
312        if getrandom::getrandom(&mut [0u8; NONCE_LEN]).is_err() {
313            return Err(PathCodecError::RngUnavailable(
314                "OS CSPRNG unavailable; refusing to enable AES-GCM path encryption".into(),
315            ));
316        }
317        Ok(Self::new(key))
318    }
319}
320
321#[cfg(feature = "path-encrypt")]
322impl PathCodec for EncryptedPathCodec {
323    fn encode(&self, real: &str) -> String {
324        use aes_gcm::aead::{Aead, Payload};
325        use base64::Engine;
326        let nonce = random_bytes();
327        let ct = self
328            .cipher
329            .encrypt(
330                (&nonce).into(),
331                Payload {
332                    msg: real.as_bytes(),
333                    aad: b"libfw-path-v1",
334                },
335            )
336            .expect("AES-256-GCM encryption cannot fail");
337        let mut blob = Vec::with_capacity(NONCE_LEN + ct.len());
338        blob.extend_from_slice(&nonce);
339        blob.extend_from_slice(&ct);
340        format!(
341            "{SHADOW_VERSION}.{}",
342            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(blob)
343        )
344    }
345
346    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
347        use aes_gcm::aead::{Aead, Payload};
348        use base64::Engine;
349        let (version, payload) = shadow.split_once('.').ok_or_else(|| {
350            PathCodecError::InvalidShadow("missing version prefix".into())
351        })?;
352        if version != SHADOW_VERSION {
353            return Err(PathCodecError::UnknownVersion(version.to_string()));
354        }
355        let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
356            .decode(payload)
357            .map_err(|_| PathCodecError::InvalidShadow("bad base64".into()))?;
358        if blob.len() < NONCE_LEN + 16 {
359            return Err(PathCodecError::InvalidShadow("too short".into()));
360        }
361        let (nonce, ct) = blob.split_at(NONCE_LEN);
362        let plain = self.cipher.decrypt(
363            nonce.into(),
364            Payload {
365                msg: ct,
366                aad: b"libfw-path-v1",
367            },
368        ).map_err(|_| PathCodecError::InvalidShadow("authentication failed (tampered?)".into()))?;
369        String::from_utf8(plain)
370            .map_err(|_| PathCodecError::InvalidShadow("not valid UTF-8".into()))
371    }
372}
373
374#[cfg(feature = "path-encrypt")]
375fn decode_hex(hex: &str) -> Option<[u8; 32]> {
376    let hex = hex.trim();
377    if hex.len() != 64 {
378        return None;
379    }
380    let mut out = [0u8; 32];
381    for (i, byte) in out.iter_mut().enumerate() {
382        *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
383    }
384    Some(out)
385}
386
387#[cfg(all(test, feature = "path-encrypt"))]
388mod encrypt_tests {
389    use super::*;
390
391    const KEY_HEX: &str =
392        "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
393
394    #[test]
395    fn round_trip_various_paths() {
396        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
397        for real in [
398            "",
399            "a.txt",
400            "docs/plan.pdf",
401            "docs/机密/plan.pdf",
402            "deeply/nested/dir/file with spaces.txt",
403            "a/../b", // handled upstream by validate_rel_path; still round-trips
404        ] {
405            let shadow = codec.encode(real);
406            assert!(shadow.starts_with("v1."), "{shadow}");
407            assert_eq!(codec.decode(&shadow).unwrap(), real);
408        }
409    }
410
411    #[test]
412    fn shadows_are_opaque_and_unique() {
413        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
414        let s1 = codec.encode("docs/a.txt");
415        let s2 = codec.encode("docs/a.txt");
416        assert_ne!(s1, s2, "random nonce ⇒ distinct shadows");
417        assert!(!s1.contains("docs"), "shadow must not leak the path: {s1}");
418    }
419
420    #[test]
421    fn tampering_is_rejected() {
422        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
423        let shadow = codec.encode("secret/plan.txt");
424        let mut bytes = shadow.into_bytes();
425        let n = bytes.len();
426        // Flip a byte well before the end: base64 trailing chars can contain
427        // unused low bits (when byte count ≢ 0 mod 3), so flipping the very
428        // last char may be a no-op in the decoded byte stream. Using a
429        // position near the middle of the base64 payload guarantees we hit
430        // a fully-significant byte.
431        let mid = n / 2;
432        bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' };
433        let bad = String::from_utf8(bytes).unwrap();
434        assert_eq!(
435            codec.decode(&bad),
436            Err(PathCodecError::InvalidShadow(
437                "authentication failed (tampered?)".into()
438            ))
439        );
440    }
441
442    #[test]
443    fn wrong_key_rejected() {
444        let good = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
445        let bad_key = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
446        let evil = EncryptedPathCodec::from_hex(bad_key).unwrap();
447        let shadow = good.encode("docs/a.txt");
448        assert!(evil.decode(&shadow).is_err());
449    }
450
451    #[test]
452    fn bad_version_and_base64() {
453        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
454        assert!(matches!(
455            codec.decode("v0.abc"),
456            Err(PathCodecError::UnknownVersion(_))
457        ));
458        assert!(matches!(
459            codec.decode("v1.!!!not-base64!!!"),
460            Err(PathCodecError::InvalidShadow(_))
461        ));
462    }
463
464    #[test]
465    fn hex_key_validation() {
466        assert!(EncryptedPathCodec::from_hex(KEY_HEX).is_ok());
467        assert!(EncryptedPathCodec::from_hex("zz").is_err());
468        assert!(EncryptedPathCodec::from_hex(&KEY_HEX[..62]).is_err());
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475
476    #[test]
477    fn identity_passthrough() {
478        let codec = IdentityPathCodec;
479        assert_eq!(codec.encode("docs/a.txt"), "docs/a.txt");
480        assert_eq!(codec.decode("docs/a.txt").unwrap(), "docs/a.txt");
481    }
482
483    #[test]
484    fn mount_round_trip_and_boundaries() {
485        let codec = MountPathCodec::new(vec![
486            ("home/alice".into(), "data/vol-3/alice".into()),
487            ("pub".into(), "public".into()),
488        ]);
489        assert_eq!(
490            codec.encode("data/vol-3/alice/report.pdf"),
491            "home/alice/report.pdf"
492        );
493        assert_eq!(
494            codec.decode("home/alice/report.pdf").unwrap(),
495            "data/vol-3/alice/report.pdf"
496        );
497        // Segment boundary: `home/alice` must not match `home/alice2`.
498        assert!(codec.decode("home/alice2/x.txt").is_err());
499        // Unmapped shadow rejected …
500        assert_eq!(
501            codec.decode("other/thing.txt"),
502            Err(PathCodecError::Unmapped("other/thing.txt".into()))
503        );
504        // … unmapped real passes through (listings never fail).
505        assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
506        assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
507        // Root alias.
508        assert_eq!(codec.encode("public"), "pub");
509        assert_eq!(codec.decode("pub").unwrap(), "public");
510        assert_eq!(codec.encode(""), "");
511    }
512
513    #[test]
514    fn mount_most_specific_wins() {
515        let codec = MountPathCodec::new(vec![
516            ("t".into(), "top".into()),
517            ("t/sub".into(), "top/deep".into()),
518        ]);
519        assert_eq!(codec.encode("top/deep/x"), "t/sub/x");
520        assert_eq!(codec.decode("t/sub/x").unwrap(), "top/deep/x");
521        assert_eq!(codec.decode("t/other").unwrap(), "top/other");
522    }
523
524    #[test]
525    fn mount_normalizes_prefixes() {
526        let codec = MountPathCodec::new(vec![
527            ("/home/alice/".into(), "/data/vol/".into()),
528        ]);
529        assert_eq!(codec.encode("data/vol/x.txt"), "home/alice/x.txt");
530        assert_eq!(codec.decode("home/alice/x.txt").unwrap(), "data/vol/x.txt");
531    }
532}