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