1#[cfg(feature = "path-encrypt")]
37use std::sync::Arc;
38
39#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
41pub enum PathCodecError {
42 #[error("unknown shadow path version `{0}`")]
44 UnknownVersion(String),
45 #[error("invalid shadow path: {0}")]
47 InvalidShadow(String),
48 #[error("shadow path does not match any configured mapping: `{0}`")]
50 Unmapped(String),
51}
52
53pub trait PathCodec: Send + Sync + 'static {
61 fn encode(&self, real: &str) -> String;
63 fn decode(&self, shadow: &str) -> Result<String, PathCodecError>;
65}
66
67#[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#[derive(Debug, Clone, Default)]
112pub struct MountPathCodec {
113 entries: Vec<(String, String)>,
115}
116
117impl MountPathCodec {
118 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 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 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, seg => {
184 if !out.is_empty() {
185 out.push('/');
186 }
187 out.push_str(seg);
188 }
189 }
190 }
191 out
192}
193
194fn 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#[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 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 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", ] {
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 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 assert!(codec.decode("home/alice2/x.txt").is_err());
462 assert_eq!(
464 codec.decode("other/thing.txt"),
465 Err(PathCodecError::Unmapped("other/thing.txt".into()))
466 );
467 assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
469 assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
470 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}