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