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