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
12//! [`validate_rel_path`](crate::validate_rel_path) produces): no leading
13//! `/`, no `.`/`..`, no empty 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 token semantics stay exactly as before.
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}
52
53/// Converts between real storage paths and client-visible shadow paths.
54///
55/// [`encode`](PathCodec::encode) is total: every real path *must* be
56/// representable (an encrypting codec can always encrypt; a mapping codec
57/// falls back to identity for unmapped subtrees so listings never fail).
58/// [`decode`](PathCodec::decode) fails when the shadow is invalid,
59/// tampered with, or unmapped — the caller turns that into a `400`.
60pub trait PathCodec: Send + Sync + 'static {
61    /// Real path → shadow path.
62    fn encode(&self, real: &str) -> String;
63    /// Shadow path → real path.
64    fn decode(&self, shadow: &str) -> Result<String, PathCodecError>;
65}
66
67/// The default codec: shadow paths *are* real paths. No translation.
68#[derive(Debug, Clone, Copy, Default)]
69pub struct IdentityPathCodec;
70
71impl PathCodec for IdentityPathCodec {
72    fn encode(&self, real: &str) -> String {
73        real.to_string()
74    }
75    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
76        Ok(shadow.to_string())
77    }
78}
79
80/// Prefix-mapping codec: readable shadow namespaces backed by real subtrees.
81///
82/// Each entry maps a shadow prefix to a real prefix (canonical relative
83/// form, no leading/trailing `/`, root is `""`):
84///
85/// ```
86/// use libfw_core::pathmap::{MountPathCodec, PathCodec};
87///
88/// let codec = MountPathCodec::new(vec![
89///     ("home/alice".to_string(), "data/vol-3/alice".to_string()),
90/// ]);
91///
92/// // real → shadow: the real subtree is aliased under the shadow prefix
93/// assert_eq!(
94///     codec.encode("data/vol-3/alice/report.pdf"),
95///     "home/alice/report.pdf"
96/// );
97/// // shadow → real
98/// assert_eq!(
99///     codec.decode("home/alice/report.pdf").unwrap(),
100///     "data/vol-3/alice/report.pdf"
101/// );
102/// // a shadow outside every mapping is rejected …
103/// assert!(codec.decode("other/thing.txt").is_err());
104/// // … while a real path outside every mapping passes through unchanged
105/// assert_eq!(codec.encode("public/readme.md"), "public/readme.md");
106/// ```
107///
108/// Matching is on segment boundaries: `home/alice` matches `home/alice/x`
109/// but not `home/alice2/x`. When several entries match, the longest shadow
110/// prefix wins (most specific mapping).
111#[derive(Debug, Clone, Default)]
112pub struct MountPathCodec {
113    /// `(shadow_prefix, real_prefix)`, ordered most-specific first.
114    entries: Vec<(String, String)>,
115}
116
117impl MountPathCodec {
118    /// Build a codec from `(shadow_prefix, real_prefix)` pairs.
119    ///
120    /// Prefixes are normalized (leading/trailing `/` and empty segments
121    /// stripped). A fully empty pair (root↔root) is ignored — use
122    /// `IdentityPathCodec` for that.
123    pub fn new(entries: Vec<(String, String)>) -> Self {
124        let norm: Vec<(String, String)> = entries
125            .into_iter()
126            .filter_map(|(shadow, real)| {
127                let s = normalize_prefix(&shadow);
128                let r = normalize_prefix(&real);
129                if s.is_empty() && r.is_empty() {
130                    return None;
131                }
132                Some((s, r))
133            })
134            .collect();
135        MountPathCodec { entries: norm }
136    }
137}
138
139impl PathCodec for MountPathCodec {
140    fn encode(&self, real: &str) -> String {
141        match self.encode_match(real) {
142            Some((shadow_root, rest)) => join_prefix(shadow_root, rest),
143            None => real.to_string(),
144        }
145    }
146
147    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
148        match self.decode_match(shadow) {
149            Some((real_root, rest)) => Ok(join_prefix(real_root, rest)),
150            None => Err(PathCodecError::Unmapped(shadow.to_string())),
151        }
152    }
153}
154
155impl MountPathCodec {
156    /// Longest entry whose **real** prefix matches `real` (encode
157    /// direction): returns the shadow root + the remainder.
158    fn encode_match<'a>(&'a self, real: &'a str) -> Option<(&'a str, &'a str)> {
159        self.entries
160            .iter()
161            .filter(|(_, r)| segment_prefix(real, r.as_str()))
162            .max_by_key(|(_, r)| r.len())
163            .map(|(s, r)| (s.as_str(), real[r.len()..].trim_start_matches('/')))
164    }
165
166    /// Longest entry whose **shadow** prefix matches `shadow` (decode
167    /// direction): returns the real root + the remainder.
168    fn decode_match<'a>(&'a self, shadow: &'a str) -> Option<(&'a str, &'a str)> {
169        self.entries
170            .iter()
171            .filter(|(s, _)| segment_prefix(shadow, s.as_str()))
172            .max_by_key(|(s, _)| s.len())
173            .map(|(s, r)| (r.as_str(), shadow[s.len()..].trim_start_matches('/')))
174    }
175}
176
177fn normalize_prefix(p: &str) -> String {
178    let mut out = String::new();
179    for seg in p.split('/') {
180        match seg {
181            "" | "." => {}
182            ".." => continue, // defensive: drop escapes rather than error
183            seg => {
184                if !out.is_empty() {
185                    out.push('/');
186                }
187                out.push_str(seg);
188            }
189        }
190    }
191    out
192}
193
194/// Does `prefix` match `path` on a segment boundary? The empty prefix
195/// matches everything.
196fn segment_prefix(path: &str, prefix: &str) -> bool {
197    if prefix.is_empty() {
198        return true;
199    }
200    if path == prefix {
201        return true;
202    }
203    path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
204}
205
206fn join_prefix(root: &str, rest: &str) -> String {
207    if root.is_empty() {
208        return rest.to_string();
209    }
210    if rest.is_empty() {
211        return root.to_string();
212    }
213    format!("{root}/{rest}")
214}
215
216/// Encrypting codec: shadow paths are opaque `v1.<base64url>` blobs.
217///
218/// Real paths are AES-256-GCM encrypted with a server-held key; the
219/// ciphertext carries a random 96-bit nonce and a 128-bit auth tag, so the
220/// shadow is unreadable **and** unforgeable (tampering fails the tag check
221/// at decode time). Encrypting the same path twice yields different
222/// shadows: a listing leaks nothing, not even equality of paths.
223///
224/// Shadow format: `v1.` + base64url(no-pad) of `nonce(12) ‖ ciphertext+tag`.
225///
226/// ```
227/// use libfw_core::pathmap::{EncryptedPathCodec, PathCodec};
228///
229/// let codec = EncryptedPathCodec::from_hex(
230///     "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
231/// ).unwrap();
232///
233/// let shadow = codec.encode("docs/机密/plan.pdf");
234/// assert_ne!(shadow, "docs/机密/plan.pdf");
235/// assert_eq!(codec.decode(&shadow).unwrap(), "docs/机密/plan.pdf");
236/// // the same path encrypts differently every time
237/// assert_ne!(codec.encode("docs/机密/plan.pdf"), shadow);
238/// // tampered shadows are rejected
239/// let mut bad = shadow.into_bytes();
240/// let n = bad.len();
241/// bad[n - 1] = if bad[n - 1] == b'A' { b'B' } else { b'A' };
242/// assert!(codec.decode(&String::from_utf8(bad).unwrap()).is_err());
243/// ```
244#[cfg(feature = "path-encrypt")]
245#[derive(Clone)]
246pub struct EncryptedPathCodec {
247    cipher: Arc<aes_gcm::Aes256Gcm>,
248}
249
250#[cfg(feature = "path-encrypt")]
251impl std::fmt::Debug for EncryptedPathCodec {
252    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253        f.debug_struct("EncryptedPathCodec")
254            .field("cipher", &"<aes-256-gcm key hidden>")
255            .finish()
256    }
257}
258
259#[cfg(feature = "path-encrypt")]
260const SHADOW_VERSION: &str = "v1";
261#[cfg(feature = "path-encrypt")]
262const NONCE_LEN: usize = 12;
263
264#[cfg(feature = "path-encrypt")]
265fn random_bytes() -> [u8; NONCE_LEN] {
266    let mut nonce = [0u8; NONCE_LEN];
267    match getrandom::getrandom(&mut nonce) {
268        Ok(()) => nonce,
269        Err(_) => {
270            // Some constrained hosts have no usable OS RNG. Fall back to an
271            // in-process PRNG seeded from time so the codec still works without
272            // panicking, while remaining explicit that this is a degraded path.
273            let mut state = std::time::SystemTime::now()
274                .duration_since(std::time::UNIX_EPOCH)
275                .unwrap_or_default()
276                .as_nanos() as u64;
277            for i in &mut nonce {
278                state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
279                *i = (state >> 32) as u8;
280            }
281            nonce
282        }
283    }
284}
285
286#[cfg(feature = "path-encrypt")]
287impl EncryptedPathCodec {
288    /// Build the codec from a 32-byte key.
289    pub fn new(key: [u8; 32]) -> Self {
290        use aes_gcm::KeyInit;
291        EncryptedPathCodec {
292            cipher: Arc::new(aes_gcm::Aes256Gcm::new((&key).into())),
293        }
294    }
295
296    /// Build the codec from a 64-char hex key (32 bytes).
297    pub fn from_hex(hex: &str) -> Result<Self, PathCodecError> {
298        let key = decode_hex(hex).ok_or_else(|| {
299            PathCodecError::InvalidShadow("LIBFW_PATH_KEY must be 64 hex chars (32 bytes)".into())
300        })?;
301        Ok(Self::new(key))
302    }
303}
304
305#[cfg(feature = "path-encrypt")]
306impl PathCodec for EncryptedPathCodec {
307    fn encode(&self, real: &str) -> String {
308        use aes_gcm::aead::{Aead, Payload};
309        use base64::Engine;
310        let nonce = random_bytes();
311        let ct = self
312            .cipher
313            .encrypt(
314                (&nonce).into(),
315                Payload {
316                    msg: real.as_bytes(),
317                    aad: b"libfw-path-v1",
318                },
319            )
320            .expect("AES-256-GCM encryption cannot fail");
321        let mut blob = Vec::with_capacity(NONCE_LEN + ct.len());
322        blob.extend_from_slice(&nonce);
323        blob.extend_from_slice(&ct);
324        format!(
325            "{SHADOW_VERSION}.{}",
326            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(blob)
327        )
328    }
329
330    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
331        use aes_gcm::aead::{Aead, Payload};
332        use base64::Engine;
333        let (version, payload) = shadow.split_once('.').ok_or_else(|| {
334            PathCodecError::InvalidShadow("missing version prefix".into())
335        })?;
336        if version != SHADOW_VERSION {
337            return Err(PathCodecError::UnknownVersion(version.to_string()));
338        }
339        let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
340            .decode(payload)
341            .map_err(|_| PathCodecError::InvalidShadow("bad base64".into()))?;
342        if blob.len() < NONCE_LEN + 16 {
343            return Err(PathCodecError::InvalidShadow("too short".into()));
344        }
345        let (nonce, ct) = blob.split_at(NONCE_LEN);
346        let plain = self.cipher.decrypt(
347            nonce.into(),
348            Payload {
349                msg: ct,
350                aad: b"libfw-path-v1",
351            },
352        ).map_err(|_| PathCodecError::InvalidShadow("authentication failed (tampered?)".into()))?;
353        String::from_utf8(plain)
354            .map_err(|_| PathCodecError::InvalidShadow("not valid UTF-8".into()))
355    }
356}
357
358#[cfg(feature = "path-encrypt")]
359fn decode_hex(hex: &str) -> Option<[u8; 32]> {
360    let hex = hex.trim();
361    if hex.len() != 64 {
362        return None;
363    }
364    let mut out = [0u8; 32];
365    for (i, byte) in out.iter_mut().enumerate() {
366        *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
367    }
368    Some(out)
369}
370
371#[cfg(all(test, feature = "path-encrypt"))]
372mod encrypt_tests {
373    use super::*;
374
375    const KEY_HEX: &str =
376        "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
377
378    #[test]
379    fn round_trip_various_paths() {
380        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
381        for real in [
382            "",
383            "a.txt",
384            "docs/plan.pdf",
385            "docs/机密/plan.pdf",
386            "deeply/nested/dir/file with spaces.txt",
387            "a/../b", // handled upstream by validate_rel_path; still round-trips
388        ] {
389            let shadow = codec.encode(real);
390            assert!(shadow.starts_with("v1."), "{shadow}");
391            assert_eq!(codec.decode(&shadow).unwrap(), real);
392        }
393    }
394
395    #[test]
396    fn shadows_are_opaque_and_unique() {
397        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
398        let s1 = codec.encode("docs/a.txt");
399        let s2 = codec.encode("docs/a.txt");
400        assert_ne!(s1, s2, "random nonce ⇒ distinct shadows");
401        assert!(!s1.contains("docs"), "shadow must not leak the path: {s1}");
402    }
403
404    #[test]
405    fn tampering_is_rejected() {
406        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
407        let shadow = codec.encode("secret/plan.txt");
408        let mut bytes = shadow.into_bytes();
409        let n = bytes.len();
410        // Flip a byte well before the end: base64 trailing chars can contain
411        // unused low bits (when byte count ≢ 0 mod 3), so flipping the very
412        // last char may be a no-op in the decoded byte stream. Using a
413        // position near the middle of the base64 payload guarantees we hit
414        // a fully-significant byte.
415        let mid = n / 2;
416        bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' };
417        let bad = String::from_utf8(bytes).unwrap();
418        assert_eq!(
419            codec.decode(&bad),
420            Err(PathCodecError::InvalidShadow(
421                "authentication failed (tampered?)".into()
422            ))
423        );
424    }
425
426    #[test]
427    fn wrong_key_rejected() {
428        let good = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
429        let bad_key = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
430        let evil = EncryptedPathCodec::from_hex(bad_key).unwrap();
431        let shadow = good.encode("docs/a.txt");
432        assert!(evil.decode(&shadow).is_err());
433    }
434
435    #[test]
436    fn bad_version_and_base64() {
437        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
438        assert!(matches!(
439            codec.decode("v0.abc"),
440            Err(PathCodecError::UnknownVersion(_))
441        ));
442        assert!(matches!(
443            codec.decode("v1.!!!not-base64!!!"),
444            Err(PathCodecError::InvalidShadow(_))
445        ));
446    }
447
448    #[test]
449    fn hex_key_validation() {
450        assert!(EncryptedPathCodec::from_hex(KEY_HEX).is_ok());
451        assert!(EncryptedPathCodec::from_hex("zz").is_err());
452        assert!(EncryptedPathCodec::from_hex(&KEY_HEX[..62]).is_err());
453    }
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    #[test]
461    fn identity_passthrough() {
462        let codec = IdentityPathCodec;
463        assert_eq!(codec.encode("docs/a.txt"), "docs/a.txt");
464        assert_eq!(codec.decode("docs/a.txt").unwrap(), "docs/a.txt");
465    }
466
467    #[test]
468    fn mount_round_trip_and_boundaries() {
469        let codec = MountPathCodec::new(vec![
470            ("home/alice".into(), "data/vol-3/alice".into()),
471            ("pub".into(), "public".into()),
472        ]);
473        assert_eq!(
474            codec.encode("data/vol-3/alice/report.pdf"),
475            "home/alice/report.pdf"
476        );
477        assert_eq!(
478            codec.decode("home/alice/report.pdf").unwrap(),
479            "data/vol-3/alice/report.pdf"
480        );
481        // Segment boundary: `home/alice` must not match `home/alice2`.
482        assert!(codec.decode("home/alice2/x.txt").is_err());
483        // Unmapped shadow rejected …
484        assert_eq!(
485            codec.decode("other/thing.txt"),
486            Err(PathCodecError::Unmapped("other/thing.txt".into()))
487        );
488        // … unmapped real passes through (listings never fail).
489        assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
490        assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
491        // Root alias.
492        assert_eq!(codec.encode("public"), "pub");
493        assert_eq!(codec.decode("pub").unwrap(), "public");
494        assert_eq!(codec.encode(""), "");
495    }
496
497    #[test]
498    fn mount_most_specific_wins() {
499        let codec = MountPathCodec::new(vec![
500            ("t".into(), "top".into()),
501            ("t/sub".into(), "top/deep".into()),
502        ]);
503        assert_eq!(codec.encode("top/deep/x"), "t/sub/x");
504        assert_eq!(codec.decode("t/sub/x").unwrap(), "top/deep/x");
505        assert_eq!(codec.decode("t/other").unwrap(), "top/other");
506    }
507
508    #[test]
509    fn mount_normalizes_prefixes() {
510        let codec = MountPathCodec::new(vec![
511            ("/home/alice/".into(), "/data/vol/".into()),
512        ]);
513        assert_eq!(codec.encode("data/vol/x.txt"), "home/alice/x.txt");
514        assert_eq!(codec.decode("home/alice/x.txt").unwrap(), "data/vol/x.txt");
515    }
516}