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 #[cfg(feature = "path-encrypt")]
54 #[error("path encryption unavailable: {0}")]
55 RngUnavailable(String),
56}
57
58pub trait PathCodec: Send + Sync + 'static {
66 fn encode(&self, real: &str) -> String;
68 fn decode(&self, shadow: &str) -> Result<String, PathCodecError>;
70}
71
72#[derive(Debug, Clone, Copy, Default)]
74pub struct IdentityPathCodec;
75
76impl PathCodec for IdentityPathCodec {
77 fn encode(&self, real: &str) -> String {
78 real.to_string()
79 }
80 fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
81 Ok(shadow.to_string())
82 }
83}
84
85#[derive(Debug, Clone, Default)]
117pub struct MountPathCodec {
118 entries: Vec<(String, String)>,
120}
121
122impl MountPathCodec {
123 pub fn new(entries: Vec<(String, String)>) -> Self {
129 let norm: Vec<(String, String)> = entries
130 .into_iter()
131 .filter_map(|(shadow, real)| {
132 let s = normalize_prefix(&shadow);
133 let r = normalize_prefix(&real);
134 if s.is_empty() && r.is_empty() {
135 return None;
136 }
137 Some((s, r))
138 })
139 .collect();
140 MountPathCodec { entries: norm }
141 }
142}
143
144impl PathCodec for MountPathCodec {
145 fn encode(&self, real: &str) -> String {
146 match self.encode_match(real) {
147 Some((shadow_root, rest)) => join_prefix(shadow_root, rest),
148 None => real.to_string(),
149 }
150 }
151
152 fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
153 match self.decode_match(shadow) {
154 Some((real_root, rest)) => Ok(join_prefix(real_root, rest)),
155 None => Err(PathCodecError::Unmapped(shadow.to_string())),
156 }
157 }
158}
159
160impl MountPathCodec {
161 fn encode_match<'a>(&'a self, real: &'a str) -> Option<(&'a str, &'a str)> {
164 self.entries
165 .iter()
166 .filter(|(_, r)| segment_prefix(real, r.as_str()))
167 .max_by_key(|(_, r)| r.len())
168 .map(|(s, r)| (s.as_str(), real[r.len()..].trim_start_matches('/')))
169 }
170
171 fn decode_match<'a>(&'a self, shadow: &'a str) -> Option<(&'a str, &'a str)> {
174 self.entries
175 .iter()
176 .filter(|(s, _)| segment_prefix(shadow, s.as_str()))
177 .max_by_key(|(s, _)| s.len())
178 .map(|(s, r)| (r.as_str(), shadow[s.len()..].trim_start_matches('/')))
179 }
180}
181
182fn normalize_prefix(p: &str) -> String {
183 let mut out = String::new();
184 for seg in p.split('/') {
185 match seg {
186 "" | "." => {}
187 ".." => continue, seg => {
189 if !out.is_empty() {
190 out.push('/');
191 }
192 out.push_str(seg);
193 }
194 }
195 }
196 out
197}
198
199fn segment_prefix(path: &str, prefix: &str) -> bool {
202 if prefix.is_empty() {
203 return true;
204 }
205 if path == prefix {
206 return true;
207 }
208 path.starts_with(prefix) && path.as_bytes().get(prefix.len()) == Some(&b'/')
209}
210
211fn join_prefix(root: &str, rest: &str) -> String {
212 if root.is_empty() {
213 return rest.to_string();
214 }
215 if rest.is_empty() {
216 return root.to_string();
217 }
218 format!("{root}/{rest}")
219}
220
221#[cfg(feature = "path-encrypt")]
250#[derive(Clone)]
251pub struct EncryptedPathCodec {
252 cipher: Arc<aes_gcm::Aes256Gcm>,
253}
254
255#[cfg(feature = "path-encrypt")]
256impl std::fmt::Debug for EncryptedPathCodec {
257 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
258 f.debug_struct("EncryptedPathCodec")
259 .field("cipher", &"<aes-256-gcm key hidden>")
260 .finish()
261 }
262}
263
264#[cfg(feature = "path-encrypt")]
265const SHADOW_VERSION: &str = "v1";
266#[cfg(feature = "path-encrypt")]
267const NONCE_LEN: usize = 12;
268
269#[cfg(feature = "path-encrypt")]
270fn random_bytes() -> [u8; NONCE_LEN] {
271 let mut nonce = [0u8; NONCE_LEN];
272 getrandom::getrandom(&mut nonce)
281 .expect("OS CSPRNG unavailable: refusing to generate a predictable AES-GCM nonce");
282 nonce
283}
284
285#[cfg(feature = "path-encrypt")]
286impl EncryptedPathCodec {
287 pub fn new(key: [u8; 32]) -> Self {
296 use aes_gcm::KeyInit;
297 let _probe = random_bytes();
299 EncryptedPathCodec {
300 cipher: Arc::new(aes_gcm::Aes256Gcm::new((&key).into())),
301 }
302 }
303
304 pub fn from_hex(hex: &str) -> Result<Self, PathCodecError> {
306 let key = decode_hex(hex).ok_or_else(|| {
307 PathCodecError::InvalidShadow("LIBFW_PATH_KEY must be 64 hex chars (32 bytes)".into())
308 })?;
309 if getrandom::getrandom(&mut [0u8; NONCE_LEN]).is_err() {
313 return Err(PathCodecError::RngUnavailable(
314 "OS CSPRNG unavailable; refusing to enable AES-GCM path encryption".into(),
315 ));
316 }
317 Ok(Self::new(key))
318 }
319}
320
321#[cfg(feature = "path-encrypt")]
322impl PathCodec for EncryptedPathCodec {
323 fn encode(&self, real: &str) -> String {
324 use aes_gcm::aead::{Aead, Payload};
325 use base64::Engine;
326 let nonce = random_bytes();
327 let ct = self
328 .cipher
329 .encrypt(
330 (&nonce).into(),
331 Payload {
332 msg: real.as_bytes(),
333 aad: b"libfw-path-v1",
334 },
335 )
336 .expect("AES-256-GCM encryption cannot fail");
337 let mut blob = Vec::with_capacity(NONCE_LEN + ct.len());
338 blob.extend_from_slice(&nonce);
339 blob.extend_from_slice(&ct);
340 format!(
341 "{SHADOW_VERSION}.{}",
342 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(blob)
343 )
344 }
345
346 fn decode(&self, shadow: &str) -> Result<String, PathCodecError> {
347 use aes_gcm::aead::{Aead, Payload};
348 use base64::Engine;
349 let (version, payload) = shadow.split_once('.').ok_or_else(|| {
350 PathCodecError::InvalidShadow("missing version prefix".into())
351 })?;
352 if version != SHADOW_VERSION {
353 return Err(PathCodecError::UnknownVersion(version.to_string()));
354 }
355 let blob = base64::engine::general_purpose::URL_SAFE_NO_PAD
356 .decode(payload)
357 .map_err(|_| PathCodecError::InvalidShadow("bad base64".into()))?;
358 if blob.len() < NONCE_LEN + 16 {
359 return Err(PathCodecError::InvalidShadow("too short".into()));
360 }
361 let (nonce, ct) = blob.split_at(NONCE_LEN);
362 let plain = self.cipher.decrypt(
363 nonce.into(),
364 Payload {
365 msg: ct,
366 aad: b"libfw-path-v1",
367 },
368 ).map_err(|_| PathCodecError::InvalidShadow("authentication failed (tampered?)".into()))?;
369 String::from_utf8(plain)
370 .map_err(|_| PathCodecError::InvalidShadow("not valid UTF-8".into()))
371 }
372}
373
374#[cfg(feature = "path-encrypt")]
375fn decode_hex(hex: &str) -> Option<[u8; 32]> {
376 let hex = hex.trim();
377 if hex.len() != 64 {
378 return None;
379 }
380 let mut out = [0u8; 32];
381 for (i, byte) in out.iter_mut().enumerate() {
382 *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
383 }
384 Some(out)
385}
386
387#[cfg(all(test, feature = "path-encrypt"))]
388mod encrypt_tests {
389 use super::*;
390
391 const KEY_HEX: &str =
392 "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f";
393
394 #[test]
395 fn round_trip_various_paths() {
396 let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
397 for real in [
398 "",
399 "a.txt",
400 "docs/plan.pdf",
401 "docs/机密/plan.pdf",
402 "deeply/nested/dir/file with spaces.txt",
403 "a/../b", ] {
405 let shadow = codec.encode(real);
406 assert!(shadow.starts_with("v1."), "{shadow}");
407 assert_eq!(codec.decode(&shadow).unwrap(), real);
408 }
409 }
410
411 #[test]
412 fn shadows_are_opaque_and_unique() {
413 let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
414 let s1 = codec.encode("docs/a.txt");
415 let s2 = codec.encode("docs/a.txt");
416 assert_ne!(s1, s2, "random nonce ⇒ distinct shadows");
417 assert!(!s1.contains("docs"), "shadow must not leak the path: {s1}");
418 }
419
420 #[test]
421 fn tampering_is_rejected() {
422 let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
423 let shadow = codec.encode("secret/plan.txt");
424 let mut bytes = shadow.into_bytes();
425 let n = bytes.len();
426 let mid = n / 2;
432 bytes[mid] = if bytes[mid] == b'A' { b'B' } else { b'A' };
433 let bad = String::from_utf8(bytes).unwrap();
434 assert_eq!(
435 codec.decode(&bad),
436 Err(PathCodecError::InvalidShadow(
437 "authentication failed (tampered?)".into()
438 ))
439 );
440 }
441
442 #[test]
443 fn wrong_key_rejected() {
444 let good = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
445 let bad_key = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
446 let evil = EncryptedPathCodec::from_hex(bad_key).unwrap();
447 let shadow = good.encode("docs/a.txt");
448 assert!(evil.decode(&shadow).is_err());
449 }
450
451 #[test]
452 fn bad_version_and_base64() {
453 let codec = EncryptedPathCodec::from_hex(KEY_HEX).unwrap();
454 assert!(matches!(
455 codec.decode("v0.abc"),
456 Err(PathCodecError::UnknownVersion(_))
457 ));
458 assert!(matches!(
459 codec.decode("v1.!!!not-base64!!!"),
460 Err(PathCodecError::InvalidShadow(_))
461 ));
462 }
463
464 #[test]
465 fn hex_key_validation() {
466 assert!(EncryptedPathCodec::from_hex(KEY_HEX).is_ok());
467 assert!(EncryptedPathCodec::from_hex("zz").is_err());
468 assert!(EncryptedPathCodec::from_hex(&KEY_HEX[..62]).is_err());
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475
476 #[test]
477 fn identity_passthrough() {
478 let codec = IdentityPathCodec;
479 assert_eq!(codec.encode("docs/a.txt"), "docs/a.txt");
480 assert_eq!(codec.decode("docs/a.txt").unwrap(), "docs/a.txt");
481 }
482
483 #[test]
484 fn mount_round_trip_and_boundaries() {
485 let codec = MountPathCodec::new(vec![
486 ("home/alice".into(), "data/vol-3/alice".into()),
487 ("pub".into(), "public".into()),
488 ]);
489 assert_eq!(
490 codec.encode("data/vol-3/alice/report.pdf"),
491 "home/alice/report.pdf"
492 );
493 assert_eq!(
494 codec.decode("home/alice/report.pdf").unwrap(),
495 "data/vol-3/alice/report.pdf"
496 );
497 assert!(codec.decode("home/alice2/x.txt").is_err());
499 assert_eq!(
501 codec.decode("other/thing.txt"),
502 Err(PathCodecError::Unmapped("other/thing.txt".into()))
503 );
504 assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
506 assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
507 assert_eq!(codec.encode("public"), "pub");
509 assert_eq!(codec.decode("pub").unwrap(), "public");
510 assert_eq!(codec.encode(""), "");
511 }
512
513 #[test]
514 fn mount_most_specific_wins() {
515 let codec = MountPathCodec::new(vec![
516 ("t".into(), "top".into()),
517 ("t/sub".into(), "top/deep".into()),
518 ]);
519 assert_eq!(codec.encode("top/deep/x"), "t/sub/x");
520 assert_eq!(codec.decode("t/sub/x").unwrap(), "top/deep/x");
521 assert_eq!(codec.decode("t/other").unwrap(), "top/other");
522 }
523
524 #[test]
525 fn mount_normalizes_prefixes() {
526 let codec = MountPathCodec::new(vec![
527 ("/home/alice/".into(), "/data/vol/".into()),
528 ]);
529 assert_eq!(codec.encode("data/vol/x.txt"), "home/alice/x.txt");
530 assert_eq!(codec.decode("home/alice/x.txt").unwrap(), "data/vol/x.txt");
531 }
532}