1#[cfg(feature = "path-encrypt")]
28use std::sync::Arc;
29
30#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
32pub enum PathCodecError {
33 #[error("unknown shadow path version `{0}`")]
35 UnknownVersion(String),
36 #[error("invalid shadow path: {0}")]
38 InvalidShadow(String),
39 #[error("shadow path does not match any configured mapping: `{0}`")]
41 Unmapped(String),
42}
43
44pub trait PathCodec: Send + Sync + 'static {
52 fn encode(&self, real: &str) -> String;
54 fn decode(&self, shadow: &str) -> Result<String, PathCodecError>;
56}
57
58#[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#[derive(Debug, Clone, Default)]
103pub struct MountPathCodec {
104 entries: Vec<(String, String)>,
106}
107
108impl MountPathCodec {
109 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 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 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, seg => {
175 if !out.is_empty() {
176 out.push('/');
177 }
178 out.push_str(seg);
179 }
180 }
181 }
182 out
183}
184
185fn 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#[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 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 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", ] {
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 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 assert!(codec.decode("home/alice2/x.txt").is_err());
453 assert_eq!(
455 codec.decode("other/thing.txt"),
456 Err(PathCodecError::Unmapped("other/thing.txt".into()))
457 );
458 assert_eq!(codec.encode("public/readme.md"), "pub/readme.md");
460 assert_eq!(codec.encode("unmapped/x.txt"), "unmapped/x.txt");
461 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}