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