1use std::path::{Component, Path};
12
13use thiserror::Error;
14
15use crate::metrics::SecurityMetrics;
16
17pub const ENV_DOTFILE_ALLOWLIST: &str = "DOTFILE_ALLOWLIST";
21
22pub const DEFAULT_ALLOWED: &[&str] = &[".acl", ".meta", ".account"];
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Error)]
28pub enum DotfileError {
29 #[error("dotfile path component is not on the allowlist")]
31 NotAllowed,
32}
33
34#[derive(Debug, Clone)]
41pub struct DotfileAllowlist {
42 allowed: Vec<String>,
43 metrics: Option<SecurityMetrics>,
44}
45
46impl DotfileAllowlist {
47 pub fn from_env() -> Self {
50 match std::env::var(ENV_DOTFILE_ALLOWLIST) {
51 Ok(raw) => {
52 let parsed = parse_csv(&raw);
53 if parsed.is_empty() {
54 Self::with_defaults()
55 } else {
56 Self {
57 allowed: parsed,
58 metrics: None,
59 }
60 }
61 }
62 Err(_) => Self::with_defaults(),
63 }
64 }
65
66 pub fn with_defaults() -> Self {
68 Self {
69 allowed: DEFAULT_ALLOWED.iter().map(|s| (*s).to_string()).collect(),
70 metrics: None,
71 }
72 }
73
74 pub fn new(entries: Vec<String>) -> Self {
77 let allowed = entries
78 .into_iter()
79 .map(|e| normalise_entry(&e))
80 .filter(|e| !e.is_empty() && e != ".")
81 .collect();
82 Self {
83 allowed,
84 metrics: None,
85 }
86 }
87
88 pub fn with_metrics(mut self, metrics: SecurityMetrics) -> Self {
90 self.metrics = Some(metrics);
91 self
92 }
93
94 pub fn entries(&self) -> &[String] {
97 &self.allowed
98 }
99
100 pub fn is_allowed(&self, path: &Path) -> bool {
109 for component in path.components() {
110 match component {
111 Component::Normal(os) => {
112 let s = match os.to_str() {
113 Some(s) => s,
114 None => {
116 self.record_deny();
117 return false;
118 }
119 };
120 if s.starts_with('.') && !self.allowed.iter().any(|a| a == s) {
121 self.record_deny();
122 return false;
123 }
124 if fold_case(s).ends_with(".meta.json") || is_non_canonical_guarded(s) {
133 self.record_deny();
134 return false;
135 }
136 }
137 Component::CurDir | Component::ParentDir => {
138 self.record_deny();
141 return false;
142 }
143 Component::Prefix(_) | Component::RootDir => {
144 }
146 }
147 }
148 true
149 }
150
151 fn record_deny(&self) {
152 if let Some(m) = &self.metrics {
153 m.record_dotfile_deny();
154 }
155 }
156}
157
158impl Default for DotfileAllowlist {
159 fn default() -> Self {
160 Self::with_defaults()
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, Hash, Error)]
177pub enum DotfilePathError {
178 #[error("dotfile segment '{segment}' not allowed in path '{path}'")]
180 NotAllowed { segment: String, path: String },
181
182 #[error("parent-directory traversal segment '..' not allowed in path '{0}'")]
185 ParentTraversal(String),
186
187 #[error("malformed path segment in '{0}'")]
191 Malformed(String),
192}
193
194const STATIC_ALLOWED_DOTFILES: &[&str] = &[
195 ".acl",
196 ".meta",
197 ".well-known",
198 ".quota.json",
199 ".acl.meta",
203 ".account",
208];
209
210pub fn is_path_allowed(path: &str) -> Result<(), DotfilePathError> {
236 for segment in path.split('/') {
237 if segment.is_empty() || segment == "." {
238 continue;
239 }
240 if segment == ".." {
241 return Err(DotfilePathError::ParentTraversal(path.to_string()));
242 }
243 if fold_case(segment).ends_with(".meta.json") || is_non_canonical_guarded(segment) {
248 return Err(DotfilePathError::NotAllowed {
249 segment: segment.to_string(),
250 path: path.to_string(),
251 });
252 }
253 if !segment.starts_with('.') {
254 continue;
255 }
256 if STATIC_ALLOWED_DOTFILES.contains(&segment) {
257 continue;
258 }
259 return Err(DotfilePathError::NotAllowed {
260 segment: segment.to_string(),
261 path: path.to_string(),
262 });
263 }
264 Ok(())
265}
266
267fn is_non_canonical_guarded(segment: &str) -> bool {
280 let folded = fold_case(segment);
281 if folded == segment {
282 return false;
283 }
284 folded.ends_with(".acl")
285 || folded.ends_with(".meta")
286 || folded.ends_with(".meta.json")
287 || STATIC_ALLOWED_DOTFILES.contains(&folded.as_str())
288}
289
290#[must_use]
306pub fn fold_case(s: &str) -> String {
307 let mut out = String::with_capacity(s.len());
308 for c in s.chars() {
309 match c {
310 '\u{017F}' => out.push('s'),
311 '\u{00DF}' | '\u{1E9E}' => out.push_str("ss"),
312 '\u{FB00}' => out.push_str("ff"),
313 '\u{FB01}' => out.push_str("fi"),
314 '\u{FB02}' => out.push_str("fl"),
315 '\u{FB03}' => out.push_str("ffi"),
316 '\u{FB04}' => out.push_str("ffl"),
317 '\u{FB05}' | '\u{FB06}' => out.push_str("st"),
318 c => out.extend(c.to_lowercase()),
319 }
320 }
321 out
322}
323
324fn parse_csv(raw: &str) -> Vec<String> {
325 raw.split(',')
326 .map(|s| s.trim())
327 .filter(|s| !s.is_empty())
328 .map(normalise_entry)
329 .filter(|s| !s.is_empty() && s != ".")
330 .collect()
331}
332
333fn normalise_entry(entry: &str) -> String {
334 let trimmed = entry.trim().trim_start_matches('/');
335 if trimmed.is_empty() {
336 return String::new();
337 }
338 if trimmed.starts_with('.') {
339 trimmed.to_string()
340 } else {
341 format!(".{trimmed}")
342 }
343}
344
345#[cfg(test)]
348mod tests {
349 use super::*;
350 use std::path::PathBuf;
351
352 #[test]
353 fn case_variants_of_guarded_names_are_refused() {
354 let al = DotfileAllowlist::default();
355 for bad in [
356 "/p/doc.ACL",
357 "/p/doc.Acl",
358 "/p/doc.META",
359 "/p/X.META.JSON",
360 "/p/x.Meta.Json",
361 "/p/.ACL",
362 "/p/.Meta",
363 ] {
364 assert!(!al.is_allowed(&PathBuf::from(bad)), "{bad}");
365 assert!(is_path_allowed(bad).is_err(), "{bad}");
366 }
367 assert_eq!("\u{212A}".to_lowercase(), "k");
369 assert!(is_path_allowed("/x.meta.j\u{17F}on").is_err());
371 assert!(
372 is_path_allowed("/doc.\u{17F}cl").is_ok(),
373 "not a guarded name"
374 );
375 assert!(is_path_allowed("/doc.ACL").is_err());
376 for ok in ["/p/doc.acl", "/p/doc.meta", "/p/Readme.TXT", "/p/Photo.JPG"] {
377 assert!(al.is_allowed(&PathBuf::from(ok)), "{ok}");
378 assert!(is_path_allowed(ok).is_ok(), "{ok}");
379 }
380 }
381
382 #[test]
383 fn default_permits_acl_and_meta() {
384 let al = DotfileAllowlist::default();
385 assert!(al.is_allowed(&PathBuf::from("/resource/.acl")));
386 assert!(al.is_allowed(&PathBuf::from("/resource/.meta")));
387 }
388
389 #[test]
390 fn default_blocks_env() {
391 let al = DotfileAllowlist::default();
392 assert!(!al.is_allowed(&PathBuf::from("/.env")));
393 assert!(!al.is_allowed(&PathBuf::from("/x/y/.env")));
394 }
395
396 #[test]
397 fn explicit_allowlist_accepts_listed_entries() {
398 let al = DotfileAllowlist::new(vec![".env".into(), ".config".into()]);
399 assert!(al.is_allowed(&PathBuf::from("/.env")));
400 assert!(al.is_allowed(&PathBuf::from("/.config")));
401 assert!(!al.is_allowed(&PathBuf::from("/.secret")));
402 }
403
404 #[test]
405 fn entry_without_dot_prefix_is_normalised() {
406 let al = DotfileAllowlist::new(vec!["notifications".into()]);
407 assert!(al.is_allowed(&PathBuf::from("/.notifications")));
408 }
409
410 #[test]
411 fn nested_dotfile_rejected() {
412 let al = DotfileAllowlist::default();
413 assert!(!al.is_allowed(&PathBuf::from("foo/.secret/bar")));
414 }
415
416 #[test]
417 fn path_without_dotfiles_accepted() {
418 let al = DotfileAllowlist::default();
419 assert!(al.is_allowed(&PathBuf::from("/a/b/c/file.ttl")));
420 }
421
422 #[test]
423 fn parent_dir_rejected() {
424 let al = DotfileAllowlist::default();
425 assert!(!al.is_allowed(&PathBuf::from("foo/..")));
426 }
427
428 #[test]
431 fn allows_acl_file() {
432 assert!(is_path_allowed("/.acl").is_ok());
433 assert!(is_path_allowed("/pod/.acl").is_ok());
434 assert!(is_path_allowed("/pod/container/.acl").is_ok());
435 }
436
437 #[test]
438 fn allows_meta_file() {
439 assert!(is_path_allowed("/.meta").is_ok());
440 assert!(is_path_allowed("/pod/.meta").is_ok());
441 assert!(is_path_allowed("/pod/container/.meta").is_ok());
442 }
443
444 #[test]
445 fn allows_well_known_subtree() {
446 assert!(is_path_allowed("/.well-known").is_ok());
447 assert!(is_path_allowed("/.well-known/openid-configuration").is_ok());
448 assert!(is_path_allowed("/.well-known/solid").is_ok());
449 assert!(is_path_allowed("/pod/.well-known/nested").is_ok());
450 }
451
452 #[test]
453 fn allows_quota_sidecar() {
454 assert!(is_path_allowed("/.quota.json").is_ok());
455 assert!(is_path_allowed("/pod/.quota.json").is_ok());
456 assert!(is_path_allowed("/pod/container/.quota.json").is_ok());
457 }
458
459 #[test]
460 fn allows_resource_specific_acl() {
461 assert!(is_path_allowed("/foo.acl").is_ok());
465 assert!(is_path_allowed("/foo.meta").is_ok());
466 assert!(is_path_allowed("/pod/data.ttl.acl").is_ok());
467 assert!(is_path_allowed("/pod/image.jpg.meta").is_ok());
468 }
469
470 #[test]
471 fn allows_normal_path() {
472 assert!(is_path_allowed("/foo/bar.ttl").is_ok());
473 assert!(is_path_allowed("/").is_ok());
474 assert!(is_path_allowed("/pod/data/doc.ttl").is_ok());
475 assert!(is_path_allowed("").is_ok());
476 }
477
478 #[test]
479 fn blocks_env_file() {
480 match is_path_allowed("/.env") {
481 Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".env"),
482 other => panic!("expected NotAllowed for /.env, got {other:?}"),
483 }
484 assert!(is_path_allowed("/pod/.env").is_err());
485 assert!(is_path_allowed("/deep/path/.env").is_err());
486 }
487
488 #[test]
489 fn blocks_git_dir() {
490 match is_path_allowed("/pod/.git/config") {
491 Err(DotfilePathError::NotAllowed { segment, .. }) => assert_eq!(segment, ".git"),
492 other => panic!("expected NotAllowed for /pod/.git/config, got {other:?}"),
493 }
494 assert!(is_path_allowed("/.git").is_err());
495 assert!(is_path_allowed("/.git/HEAD").is_err());
496 assert!(is_path_allowed("/.ssh/id_rsa").is_err());
497 }
498
499 #[test]
500 fn blocks_hidden_file_anywhere() {
501 assert!(is_path_allowed("/foo/.hidden/bar.ttl").is_err());
502 assert!(is_path_allowed("/a/b/c/.secret").is_err());
503 assert!(is_path_allowed("/.DS_Store").is_err());
504 assert!(is_path_allowed("/pod/.npmrc").is_err());
505 }
506
507 #[test]
508 fn blocks_double_dot() {
509 match is_path_allowed("/pod/../etc/passwd") {
510 Err(DotfilePathError::ParentTraversal(_)) => {}
511 other => panic!("expected ParentTraversal for /pod/../etc/passwd, got {other:?}"),
512 }
513 assert!(matches!(
514 is_path_allowed(".."),
515 Err(DotfilePathError::ParentTraversal(_))
516 ));
517 assert!(matches!(
518 is_path_allowed("/a/../b"),
519 Err(DotfilePathError::ParentTraversal(_))
520 ));
521 }
522
523 #[test]
526 fn default_permits_account() {
527 let al = DotfileAllowlist::default();
528 assert!(
529 al.is_allowed(&PathBuf::from("/.account")),
530 ".account must be on default allowlist"
531 );
532 assert!(
533 al.is_allowed(&PathBuf::from("/pod/.account")),
534 ".account nested under pod must pass"
535 );
536 }
537
538 #[test]
539 fn allows_account_path_free_function() {
540 assert!(
541 is_path_allowed("/.account").is_ok(),
542 ".account must pass the free-function check"
543 );
544 assert!(
545 is_path_allowed("/.account/login").is_ok(),
546 ".account subtree must pass"
547 );
548 assert!(
549 is_path_allowed("/pod/.account/register").is_ok(),
550 ".account under pod must pass"
551 );
552 }
553
554 #[test]
555 fn account_in_default_allowed_constant() {
556 assert!(
557 DEFAULT_ALLOWED.contains(&".account"),
558 "DEFAULT_ALLOWED must include .account"
559 );
560 }
561}