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")]
265impl EncryptedPathCodec {
266    /// Build the codec from a 32-byte key.
267    pub fn new(key: [u8; 32]) -> Self {
268        use aes_gcm::KeyInit;
269        EncryptedPathCodec {
270            cipher: Arc::new(aes_gcm::Aes256Gcm::new((&key).into())),
271        }
272    }
273
274    /// Build the codec from a 64-char hex key (32 bytes).
275    pub fn from_hex(hex: &str) -> Result<Self, PathCodecError> {
276        let key = decode_hex(hex).ok_or_else(|| {
277            PathCodecError::InvalidShadow("LIBFW_PATH_KEY must be 64 hex chars (32 bytes)".into())
278        })?;
279        Ok(Self::new(key))
280    }
281}
282
283#[cfg(feature = "path-encrypt")]
284impl PathCodec for EncryptedPathCodec {
285    fn encode(&self, real: &str) -> String {
286        use aes_gcm::aead::{Aead, Payload};
287        use base64::Engine;
288        let mut nonce = [0u8; NONCE_LEN];
289        getrandom::getrandom(&mut nonce).expect("OS RNG");
290        let ct = self
291            .cipher
292            .encrypt(
293                (&nonce).into(),
294                Payload {
295                    msg: real.as_bytes(),
296                    aad: b"libfw-path-v1",
297                },
298            )
299            .expect("AES-256-GCM encryption cannot fail");
300        let mut blob = Vec::with_capacity(NONCE_LEN + ct.len());
301        blob.extend_from_slice(&nonce);
302        blob.extend_from_slice(&ct);
303        format!(
304            "{SHADOW_VERSION}.{}",
305            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(blob)
306        )
307    }
308
309    fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
310        use aes_gcm::aead::{Aead, Payload};
311        use base64::Engine;
312        let (version, payload) = shadow.split_once('.').ok_or_else(|| {
313            PathCodecError::InvalidShadow("missing version prefix".into())
314        })?;
315        if version != SHADOW_VERSION {
316            return Err(PathCodecError::UnknownVersion(version.to_string()));
317        }
318        let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
319            .decode(payload)
320            .map_err(|_| PathCodecError::InvalidShadow("bad base64".into()))?;
321        if blob.len() < NONCE_LEN + 16 {
322            return Err(PathCodecError::InvalidShadow("too short".into()));
323        }
324        let (nonce, ct) = blob.split_at(NONCE_LEN);
325        let plain = self.cipher.decrypt(
326            nonce.into(),
327            Payload {
328                msg: ct,
329                aad: b"libfw-path-v1",
330            },
331        ).map_err(|_| PathCodecError::InvalidShadow("authentication failed (tampered?)".into()))?;
332        String::from_utf8(plain)
333            .map_err(|_| PathCodecError::InvalidShadow("not valid UTF-8".into()))
334    }
335}
336
337#[cfg(feature = "path-encrypt")]
338fn decode_hex(hex: &str) -> Option<[u8; 32]> {
339    let hex = hex.trim();
340    if hex.len() != 64 {
341        return None;
342    }
343    let mut out = [0u8; 32];
344    for (i, byte) in out.iter_mut().enumerate() {
345        *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
346    }
347    Some(out)
348}
349
350#[cfg(all(test, feature = "path-encrypt"))]
351mod encrypt_tests {
352    use super::*;
353
354    const KEY_HEX: &str =
355        "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
356
357    #[test]
358    fn round_trip_various_paths() {
359        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
360        for real in [
361            "",
362            "a.txt",
363            "docs/plan.pdf",
364            "docs/机密/plan.pdf",
365            "deeply/nested/dir/file with spaces.txt",
366            "a/../b", // handled upstream by validate_rel_path; still round-trips
367        ] {
368            let shadow = codec.encode(real);
369            assert!(shadow.starts_with("v1."), "{shadow}");
370            assert_eq!(codec.decode(&shadow).unwrap(), real);
371        }
372    }
373
374    #[test]
375    fn shadows_are_opaque_and_unique() {
376        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
377        let s1 = codec.encode("docs/a.txt");
378        let s2 = codec.encode("docs/a.txt");
379        assert_ne!(s1, s2, "random nonce ⇒ distinct shadows");
380        assert!(!s1.contains("docs"), "shadow must not leak the path: {s1}");
381    }
382
383    #[test]
384    fn tampering_is_rejected() {
385        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
386        let shadow = codec.encode("secret/plan.txt");
387        let mut bytes = shadow.into_bytes();
388        let n = bytes.len();
389        // Flip a byte well before the end: base64 trailing chars can contain
390        // unused low bits (when byte count ≢ 0 mod 3), so flipping the very
391        // last char may be a no-op in the decoded byte stream. Using a
392        // position near the middle of the base64 payload guarantees we hit
393        // a fully-significant byte.
394        let mid = n / 2;
395        bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' };
396        let bad = String::from_utf8(bytes).unwrap();
397        assert_eq!(
398            codec.decode(&bad),
399            Err(PathCodecError::InvalidShadow(
400                "authentication failed (tampered?)".into()
401            ))
402        );
403    }
404
405    #[test]
406    fn wrong_key_rejected() {
407        let good = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
408        let bad_key = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
409        let evil = EncryptedPathCodec::from_hex(bad_key).unwrap();
410        let shadow = good.encode("docs/a.txt");
411        assert!(evil.decode(&shadow).is_err());
412    }
413
414    #[test]
415    fn bad_version_and_base64() {
416        let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
417        assert!(matches!(
418            codec.decode("v0.abc"),
419            Err(PathCodecError::UnknownVersion(_))
420        ));
421        assert!(matches!(
422            codec.decode("v1.!!!not-base64!!!"),
423            Err(PathCodecError::InvalidShadow(_))
424        ));
425    }
426
427    #[test]
428    fn hex_key_validation() {
429        assert!(EncryptedPathCodec::from_hex(KEY_HEX).is_ok());
430        assert!(EncryptedPathCodec::from_hex("zz").is_err());
431        assert!(EncryptedPathCodec::from_hex(&KEY_HEX[..62]).is_err());
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn identity_passthrough() {
441        let codec = IdentityPathCodec;
442        assert_eq!(codec.encode("docs/a.txt"), "docs/a.txt");
443        assert_eq!(codec.decode("docs/a.txt").unwrap(), "docs/a.txt");
444    }
445
446    #[test]
447    fn mount_round_trip_and_boundaries() {
448        let codec = MountPathCodec::new(vec![
449            ("home/alice".into(), "data/vol-3/alice".into()),
450            ("pub".into(), "public".into()),
451        ]);
452        assert_eq!(
453            codec.encode("data/vol-3/alice/report.pdf"),
454            "home/alice/report.pdf"
455        );
456        assert_eq!(
457            codec.decode("home/alice/report.pdf").unwrap(),
458            "data/vol-3/alice/report.pdf"
459        );
460        // Segment boundary: `home/alice` must not match `home/alice2`.
461        assert!(codec.decode("home/alice2/x.txt").is_err());
462        // Unmapped shadow rejected …
463        assert_eq!(
464            codec.decode("other/thing.txt"),
465            Err(PathCodecError::Unmapped("other/thing.txt".into()))
466        );
467        // … unmapped real passes through (listings never fail).
468        assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
469        assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
470        // Root alias.
471        assert_eq!(codec.encode("public"), "pub");
472        assert_eq!(codec.decode("pub").unwrap(), "public");
473        assert_eq!(codec.encode(""), "");
474    }
475
476    #[test]
477    fn mount_most_specific_wins() {
478        let codec = MountPathCodec::new(vec![
479            ("t".into(), "top".into()),
480            ("t/sub".into(), "top/deep".into()),
481        ]);
482        assert_eq!(codec.encode("top/deep/x"), "t/sub/x");
483        assert_eq!(codec.decode("t/sub/x").unwrap(), "top/deep/x");
484        assert_eq!(codec.decode("t/other").unwrap(), "top/other");
485    }
486
487    #[test]
488    fn mount_normalizes_prefixes() {
489        let codec = MountPathCodec::new(vec![
490            ("/home/alice/".into(), "/data/vol/".into()),
491        ]);
492        assert_eq!(codec.encode("data/vol/x.txt"), "home/alice/x.txt");
493        assert_eq!(codec.decode("home/alice/x.txt").unwrap(), "data/vol/x.txt");
494    }
495}