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")]
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 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 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 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", ] {
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 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 assert!(codec.decode("home/alice2/x.txt").is_err());
483 assert_eq!(
485 codec.decode("other/thing.txt"),
486 Err(PathCodecError::Unmapped("other/thing.txt".into()))
487 );
488 assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
490 assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
491 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}