1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3#![allow(renamed_and_removed_lints)] #![allow(unknown_lints)] #![warn(missing_docs)]
7#![warn(noop_method_call)]
8#![warn(unreachable_pub)]
9#![warn(clippy::all)]
10#![deny(clippy::await_holding_lock)]
11#![deny(clippy::cargo_common_metadata)]
12#![deny(clippy::cast_lossless)]
13#![deny(clippy::checked_conversions)]
14#![warn(clippy::cognitive_complexity)]
15#![deny(clippy::debug_assert_with_mut_call)]
16#![deny(clippy::exhaustive_enums)]
17#![deny(clippy::exhaustive_structs)]
18#![deny(clippy::expl_impl_clone_on_copy)]
19#![deny(clippy::fallible_impl_from)]
20#![deny(clippy::implicit_clone)]
21#![deny(clippy::large_stack_arrays)]
22#![warn(clippy::manual_ok_or)]
23#![deny(clippy::missing_docs_in_private_items)]
24#![warn(clippy::needless_borrow)]
25#![warn(clippy::needless_pass_by_value)]
26#![warn(clippy::option_option)]
27#![deny(clippy::print_stderr)]
28#![deny(clippy::print_stdout)]
29#![warn(clippy::rc_buffer)]
30#![deny(clippy::ref_option_ref)]
31#![warn(clippy::semicolon_if_nothing_returned)]
32#![warn(clippy::trait_duplication_in_bounds)]
33#![deny(clippy::unchecked_time_subtraction)]
34#![deny(clippy::unnecessary_wraps)]
35#![warn(clippy::unseparated_literal_suffix)]
36#![deny(clippy::unwrap_used)]
37#![deny(clippy::mod_module_files)]
38#![allow(clippy::let_unit_value)] #![allow(clippy::uninlined_format_args)]
40#![allow(clippy::significant_drop_in_scrutinee)] #![allow(clippy::result_large_err)] #![allow(clippy::needless_raw_string_hashes)] #![allow(clippy::needless_lifetimes)] #![allow(mismatched_lifetime_syntaxes)] #![allow(clippy::collapsible_if)] #![deny(clippy::unused_async)]
47#![deny(clippy::string_slice)] use std::collections::HashMap;
51use std::path::{Path, PathBuf};
52
53use serde::{Deserialize, Serialize};
54use std::borrow::Cow;
55#[cfg(feature = "expand-paths")]
56use {directories::BaseDirs, std::sync::LazyLock};
57
58use tor_error::{ErrorKind, HasKind};
59
60#[cfg(all(test, feature = "expand-paths"))]
61use std::ffi::OsStr;
62
63#[cfg(feature = "address")]
64pub mod addr;
65
66#[cfg(feature = "arti-client")]
67mod arti_client_paths;
68
69#[cfg(feature = "arti-client")]
70pub use arti_client_paths::arti_client_base_resolver;
71
72#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
81#[serde(transparent)]
82pub struct CfgPath(PathInner);
83
84#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
88#[serde(untagged)]
89enum PathInner {
90 Literal(LiteralPath),
92 Shell(String),
94}
95
96#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)]
97struct LiteralPath {
102 literal: PathBuf,
104}
105
106#[derive(thiserror::Error, Debug, Clone)]
108#[non_exhaustive]
109#[cfg_attr(test, derive(PartialEq))]
110pub enum CfgPathError {
111 #[error("Unrecognized variable {0} in path")]
113 UnknownVar(String),
114 #[error(
116 "Couldn't determine XDG Project Directories, needed to resolve a path; probably, unable to determine HOME directory"
117 )]
118 NoProjectDirs,
119 #[error("Can't construct base directories to resolve a path element")]
121 NoBaseDirs,
122 #[error("Can't find the path to the current binary")]
124 NoProgramPath,
125 #[error("Can't find the directory of the current binary")]
127 NoProgramDir,
128 #[error("Invalid path string: {0:?}")]
132 InvalidString(String),
133 #[error(
135 "Variable interpolation $ is not supported (tor-config/expand-paths feature disabled)); $ must still be doubled"
136 )]
137 VariableInterpolationNotSupported(String),
138 #[error("Home dir ~/ is not supported (tor-config/expand-paths feature disabled)")]
140 HomeDirInterpolationNotSupported(String),
141}
142
143impl HasKind for CfgPathError {
144 fn kind(&self) -> ErrorKind {
145 use CfgPathError as E;
146 use ErrorKind as EK;
147 match self {
148 E::UnknownVar(_) | E::InvalidString(_) => EK::InvalidConfig,
149 E::NoProjectDirs | E::NoBaseDirs => EK::NoHomeDirectory,
150 E::NoProgramPath | E::NoProgramDir => EK::InvalidConfig,
151 E::VariableInterpolationNotSupported(_) | E::HomeDirInterpolationNotSupported(_) => {
152 EK::FeatureDisabled
153 }
154 }
155 }
156}
157
158#[derive(Clone, Debug, Default)]
169pub struct CfgPathResolver {
170 vars: HashMap<String, Result<Cow<'static, Path>, CfgPathError>>,
173}
174
175impl CfgPathResolver {
176 #[cfg(feature = "expand-paths")]
178 fn get_var(&self, var: &str) -> Result<Cow<'static, Path>, CfgPathError> {
179 match self.vars.get(var) {
180 Some(val) => val.clone(),
181 None => Err(CfgPathError::UnknownVar(var.to_owned())),
182 }
183 }
184
185 pub fn set_var(
206 &mut self,
207 var: impl Into<String>,
208 val: Result<Cow<'static, Path>, CfgPathError>,
209 ) {
210 self.vars.insert(var.into(), val);
211 }
212
213 #[cfg(all(test, feature = "expand-paths"))]
215 fn from_pairs<K, V>(vars: impl IntoIterator<Item = (K, V)>) -> CfgPathResolver
216 where
217 K: Into<String>,
218 V: AsRef<OsStr>,
219 {
220 let mut path_resolver = CfgPathResolver::default();
221 for (name, val) in vars.into_iter() {
222 let val = Path::new(val.as_ref()).to_owned();
223 path_resolver.set_var(name, Ok(val.into()));
224 }
225 path_resolver
226 }
227}
228
229impl CfgPath {
230 pub fn new(s: String) -> Self {
232 CfgPath(PathInner::Shell(s))
233 }
234
235 pub fn new_literal<P: Into<PathBuf>>(path: P) -> Self {
237 CfgPath(PathInner::Literal(LiteralPath {
238 literal: path.into(),
239 }))
240 }
241
242 pub fn path(&self, path_resolver: &CfgPathResolver) -> Result<PathBuf, CfgPathError> {
247 match &self.0 {
248 PathInner::Shell(s) => expand(s, path_resolver),
249 PathInner::Literal(LiteralPath { literal }) => Ok(literal.clone()),
250 }
251 }
252
253 pub fn as_unexpanded_str(&self) -> Option<&str> {
260 match &self.0 {
261 PathInner::Shell(s) => Some(s),
262 PathInner::Literal(_) => None,
263 }
264 }
265
266 pub fn as_literal_path(&self) -> Option<&Path> {
271 match &self.0 {
272 PathInner::Shell(_) => None,
273 PathInner::Literal(LiteralPath { literal }) => Some(literal),
274 }
275 }
276}
277
278impl std::fmt::Display for CfgPath {
279 fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match &self.0 {
281 PathInner::Literal(LiteralPath { literal }) => write!(fmt, "{:?} [exactly]", literal),
282 PathInner::Shell(s) => s.fmt(fmt),
283 }
284 }
285}
286
287#[cfg(feature = "expand-paths")]
291pub fn home() -> Result<&'static Path, CfgPathError> {
292 static HOME_DIR: LazyLock<Option<PathBuf>> =
294 LazyLock::new(|| Some(BaseDirs::new()?.home_dir().to_owned()));
295 HOME_DIR
296 .as_ref()
297 .map(PathBuf::as_path)
298 .ok_or(CfgPathError::NoBaseDirs)
299}
300
301#[cfg(feature = "expand-paths")]
303fn expand(s: &str, path_resolver: &CfgPathResolver) -> Result<PathBuf, CfgPathError> {
304 let path = shellexpand::path::full_with_context(
305 s,
306 || home().ok(),
307 |x| path_resolver.get_var(x).map(Some),
308 );
309 Ok(path.map_err(|e| e.cause)?.into_owned())
310}
311
312#[cfg(not(feature = "expand-paths"))]
314fn expand(input: &str, _: &CfgPathResolver) -> Result<PathBuf, CfgPathError> {
315 if input.starts_with('~') {
317 return Err(CfgPathError::HomeDirInterpolationNotSupported(input.into()));
318 }
319
320 let mut out = String::with_capacity(input.len());
321 let mut s = input;
322 while let Some((lhs, rhs)) = s.split_once('$') {
323 if let Some(rhs) = rhs.strip_prefix('$') {
324 out += lhs;
326 out += "$";
327 s = rhs;
328 } else {
329 return Err(CfgPathError::VariableInterpolationNotSupported(
330 input.into(),
331 ));
332 }
333 }
334 out += s;
335 Ok(out.into())
336}
337
338#[cfg(all(test, feature = "expand-paths"))]
339mod test {
340 #![allow(clippy::unwrap_used)]
341 use super::*;
342
343 #[test]
344 fn expand_no_op() {
345 let r = CfgPathResolver::from_pairs([("FOO", "foo")]);
346
347 let p = CfgPath::new("Hello/world".to_string());
348 assert_eq!(p.to_string(), "Hello/world".to_string());
349 assert_eq!(p.path(&r).unwrap().to_str(), Some("Hello/world"));
350
351 let p = CfgPath::new("/usr/local/foo".to_string());
352 assert_eq!(p.to_string(), "/usr/local/foo".to_string());
353 assert_eq!(p.path(&r).unwrap().to_str(), Some("/usr/local/foo"));
354 }
355
356 #[cfg(not(target_family = "windows"))]
357 #[test]
358 fn expand_home() {
359 let r = CfgPathResolver::from_pairs([("USER_HOME", home().unwrap())]);
360
361 let p = CfgPath::new("~/.arti/config".to_string());
362 assert_eq!(p.to_string(), "~/.arti/config".to_string());
363
364 let expected = dirs::home_dir().unwrap().join(".arti/config");
365 assert_eq!(p.path(&r).unwrap().to_str(), expected.to_str());
366
367 let p = CfgPath::new("${USER_HOME}/.arti/config".to_string());
368 assert_eq!(p.to_string(), "${USER_HOME}/.arti/config".to_string());
369 assert_eq!(p.path(&r).unwrap().to_str(), expected.to_str());
370 }
371
372 #[cfg(target_family = "windows")]
373 #[test]
374 fn expand_home() {
375 let r = CfgPathResolver::from_pairs([("USER_HOME", home().unwrap())]);
376
377 let p = CfgPath::new("~\\.arti\\config".to_string());
378 assert_eq!(p.to_string(), "~\\.arti\\config".to_string());
379
380 let expected = dirs::home_dir().unwrap().join(".arti\\config");
381 assert_eq!(p.path(&r).unwrap().to_str(), expected.to_str());
382
383 let p = CfgPath::new("${USER_HOME}\\.arti\\config".to_string());
384 assert_eq!(p.to_string(), "${USER_HOME}\\.arti\\config".to_string());
385 assert_eq!(p.path(&r).unwrap().to_str(), expected.to_str());
386 }
387
388 #[test]
389 fn expand_bogus() {
390 let r = CfgPathResolver::from_pairs([("FOO", "foo")]);
391
392 let p = CfgPath::new("${ARTI_WOMBAT}/example".to_string());
393 assert_eq!(p.to_string(), "${ARTI_WOMBAT}/example".to_string());
394
395 assert!(matches!(p.path(&r), Err(CfgPathError::UnknownVar(_))));
396 assert_eq!(
397 &p.path(&r).unwrap_err().to_string(),
398 "Unrecognized variable ARTI_WOMBAT in path"
399 );
400 }
401
402 #[test]
403 fn literal() {
404 let r = CfgPathResolver::from_pairs([("ARTI_CACHE", "foo")]);
405
406 let p = CfgPath::new_literal(PathBuf::from("${ARTI_CACHE}/literally"));
407 assert_eq!(
409 p.path(&r).unwrap().to_str().unwrap(),
410 "${ARTI_CACHE}/literally"
411 );
412 assert_eq!(p.to_string(), "\"${ARTI_CACHE}/literally\" [exactly]");
413 }
414
415 #[test]
416 #[cfg(feature = "expand-paths")]
417 fn program_dir() {
418 let current_exe = std::env::current_exe().unwrap();
419 let r = CfgPathResolver::from_pairs([("PROGRAM_DIR", current_exe.parent().unwrap())]);
420
421 let p = CfgPath::new("${PROGRAM_DIR}/foo".to_string());
422
423 let mut this_binary = current_exe;
424 this_binary.pop();
425 this_binary.push("foo");
426 let expanded = p.path(&r).unwrap();
427 assert_eq!(expanded, this_binary);
428 }
429
430 #[test]
431 #[cfg(not(feature = "expand-paths"))]
432 fn rejections() {
433 let r = CfgPathResolver::from_pairs([("PROGRAM_DIR", std::env::current_exe().unwrap())]);
434
435 let chk_err = |s: &str, mke: &dyn Fn(String) -> CfgPathError| {
436 let p = CfgPath::new(s.to_string());
437 assert_eq!(p.path(&r).unwrap_err(), mke(s.to_string()));
438 };
439
440 let chk_ok = |s: &str, exp| {
441 let p = CfgPath::new(s.to_string());
442 assert_eq!(p.path(&r), Ok(PathBuf::from(exp)));
443 };
444
445 chk_err(
446 "some/${PROGRAM_DIR}/foo",
447 &CfgPathError::VariableInterpolationNotSupported,
448 );
449 chk_err("~some", &CfgPathError::HomeDirInterpolationNotSupported);
450
451 chk_ok("some$$foo$$bar", "some$foo$bar");
452 chk_ok("no dollars", "no dollars");
453 }
454}
455
456#[cfg(test)]
457mod test_serde {
458 #![allow(clippy::bool_assert_comparison)]
460 #![allow(clippy::clone_on_copy)]
461 #![allow(clippy::dbg_macro)]
462 #![allow(clippy::mixed_attributes_style)]
463 #![allow(clippy::print_stderr)]
464 #![allow(clippy::print_stdout)]
465 #![allow(clippy::single_char_pattern)]
466 #![allow(clippy::unwrap_used)]
467 #![allow(clippy::unchecked_time_subtraction)]
468 #![allow(clippy::useless_vec)]
469 #![allow(clippy::needless_pass_by_value)]
470 #![allow(clippy::string_slice)] use super::*;
474
475 use std::ffi::OsString;
476 use std::fmt::Debug;
477
478 use derive_deftly::Deftly;
479 use tor_config::load::TopLevel;
480
481 #[derive(Serialize, Deserialize, Deftly, Eq, PartialEq, Debug)]
482 #[derive_deftly(tor_config::derive::TorConfig)]
483 #[deftly(tor_config(no_default_trait))]
484 struct TestConfigFile {
485 #[deftly(tor_config(no_default))]
486 p: CfgPath,
487 }
488
489 impl TopLevel for TestConfigFile {
490 type Builder = TestConfigFileBuilder;
491 }
492
493 fn deser_json(json: &str) -> CfgPath {
494 dbg!(json);
495 let TestConfigFile { p } = serde_json::from_str(json).expect("deser json failed");
496 p
497 }
498 fn deser_toml(toml: &str) -> CfgPath {
499 dbg!(toml);
500 let TestConfigFile { p } = toml::from_str(toml).expect("deser toml failed");
501 p
502 }
503 fn deser_toml_cfg(toml: &str) -> CfgPath {
504 dbg!(toml);
505 let mut sources = tor_config::ConfigurationSources::new_empty();
506 sources.push_source(
507 tor_config::ConfigurationSource::from_verbatim(toml.to_string()),
508 tor_config::sources::MustRead::MustRead,
509 );
510 let cfg = sources.load().unwrap();
511
512 dbg!(&cfg);
513 let TestConfigFile { p } = tor_config::load::resolve(cfg).expect("cfg resolution failed");
514 p
515 }
516
517 #[test]
518 fn test_parse() {
519 fn desers(toml: &str, json: &str) -> Vec<CfgPath> {
520 vec![deser_toml(toml), deser_toml_cfg(toml), deser_json(json)]
521 }
522
523 for cp in desers(r#"p = "string""#, r#"{ "p": "string" }"#) {
524 assert_eq!(cp.as_unexpanded_str(), Some("string"));
525 assert_eq!(cp.as_literal_path(), None);
526 }
527
528 for cp in desers(
529 r#"p = { literal = "lit" }"#,
530 r#"{ "p": {"literal": "lit"} }"#,
531 ) {
532 assert_eq!(cp.as_unexpanded_str(), None);
533 assert_eq!(cp.as_literal_path(), Some(&*PathBuf::from("lit")));
534 }
535 }
536
537 fn non_string_path() -> PathBuf {
538 #[cfg(target_family = "unix")]
539 {
540 use std::os::unix::ffi::OsStringExt;
541 return PathBuf::from(OsString::from_vec(vec![0x80_u8]));
542 }
543
544 #[cfg(target_family = "windows")]
545 {
546 use std::os::windows::ffi::OsStringExt;
547 return PathBuf::from(OsString::from_wide(&[0xD800_u16]));
548 }
549
550 #[allow(unreachable_code)]
551 PathBuf::default()
553 }
554
555 fn test_roundtrip_cases<SER, S, DESER, E, F>(ser: SER, deser: DESER)
556 where
557 SER: Fn(&TestConfigFile) -> Result<S, E>,
558 DESER: Fn(&S) -> Result<TestConfigFile, F>,
559 S: Debug,
560 E: Debug,
561 F: Debug,
562 {
563 let case = |easy, p| {
564 let input = TestConfigFile { p };
565 let s = match ser(&input) {
566 Ok(s) => s,
567 Err(e) if easy => panic!("ser failed {:?} e={:?}", &input, &e),
568 Err(_) => return,
569 };
570 dbg!(&input, &s);
571 let output = deser(&s).expect("deser failed");
572 assert_eq!(&input, &output, "s={:?}", &s);
573 };
574
575 case(true, CfgPath::new("string".into()));
576 case(true, CfgPath::new_literal(PathBuf::from("nice path")));
577 case(true, CfgPath::new_literal(PathBuf::from("path with ✓")));
578
579 case(false, CfgPath::new_literal(non_string_path()));
584 }
585
586 #[test]
587 fn roundtrip_json() {
588 test_roundtrip_cases(
589 |input| serde_json::to_string(&input),
590 |json| serde_json::from_str(json),
591 );
592 }
593
594 #[test]
595 fn roundtrip_toml() {
596 test_roundtrip_cases(|input| toml::to_string(&input), |toml| toml::from_str(toml));
597 }
598
599 #[test]
600 fn roundtrip_mpack() {
601 test_roundtrip_cases(
602 |input| rmp_serde::to_vec(&input),
603 |mpack| rmp_serde::from_slice(mpack),
604 );
605 }
606}