arti_rpc_client_core/conn/
builder.rs

1//! Functionality to connect to an RPC server.
2
3use std::{
4    collections::HashMap,
5    io::{self},
6    path::PathBuf,
7    str::FromStr as _,
8};
9
10use fs_mistrust::Mistrust;
11use tor_config_path::{CfgPath, CfgPathResolver};
12use tor_rpc_connect::{
13    auth::RpcAuth,
14    load::{LoadError, LoadOptions},
15    ClientErrorAction, HasClientErrorAction, ParsedConnectPoint,
16};
17
18use crate::{conn::ConnectError, llconn, msgs::response::UnparsedResponse, RpcConn};
19
20use super::ConnectFailure;
21
22/// An error occurred while trying to construct or manipulate an [`RpcConnBuilder`].
23#[derive(Clone, Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum BuilderError {
26    /// We couldn't decode a provided connect string.
27    #[error("Invalid connect string.")]
28    InvalidConnectString,
29}
30
31/// Information about how to construct a connection to an Arti instance.
32//
33// TODO RPC: Once we have our formats more settled, add a link to a piece of documentation
34// explaining what a connect point is and how to make one.
35#[derive(Default, Clone, Debug)]
36pub struct RpcConnBuilder {
37    /// Path entries provided programmatically.
38    ///
39    /// These are considered after entries in
40    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable,
41    /// but before any other entries.
42    /// (See `RPCConnBuilder::new` for details.)
43    ///
44    /// These entries are stored in reverse order.
45    prepend_path_reversed: Vec<SearchEntry>,
46}
47
48/// A single entry in the search path used to find connect points.
49///
50/// Includes information on where we got this entry
51/// (environment variable, application, or default).
52#[derive(Clone, Debug)]
53struct SearchEntry {
54    /// The source telling us this entry.
55    source: ConnPtOrigin,
56    /// The location to search.
57    location: SearchLocation,
58}
59
60/// A single location in the search path used to find connect points.
61#[derive(Clone, Debug)]
62enum SearchLocation {
63    /// A literal connect point entry to parse.
64    Literal(String),
65    /// A path to a connect file, or a directory full of connect files.
66    Path {
67        /// The path to load.
68        path: CfgPath,
69
70        /// If true, then this entry comes from a builtin default,
71        /// and relative paths should cause the connect attempt to be declined.
72        ///
73        /// Otherwise, this entry comes from the user or application,
74        /// and relative paths should cause the connect attempt to abort.
75        is_default_entry: bool,
76    },
77}
78
79/// Diagnostic: An explanation of where we found a connect point,
80/// and why we looked there.
81#[derive(Debug, Clone)]
82pub struct ConnPtDescription {
83    /// What told us to look in this location
84    source: ConnPtOrigin,
85    /// Where we found the connect point.
86    location: ConnPtLocation,
87}
88
89impl std::fmt::Display for ConnPtDescription {
90    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
91        write!(
92            f,
93            "connect point in {}, from {}",
94            &self.location, &self.source
95        )
96    }
97}
98
99/// Diagnostic: a source telling us where to look for a connect point.
100#[derive(Clone, Copy, Debug)]
101enum ConnPtOrigin {
102    /// Found the search entry from an environment variable.
103    EnvVar(&'static str),
104    /// Application manually inserted the search entry.
105    Application,
106    /// The search entry was a built-in default
107    Default,
108}
109
110impl std::fmt::Display for ConnPtOrigin {
111    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112        match self {
113            ConnPtOrigin::EnvVar(varname) => write!(f, "${}", varname),
114            ConnPtOrigin::Application => write!(f, "application"),
115            ConnPtOrigin::Default => write!(f, "default list"),
116        }
117    }
118}
119
120/// Diagnostic: Where we found a connect point.
121#[derive(Clone, Debug)]
122enum ConnPtLocation {
123    /// The connect point was given as a literal string.
124    Literal(String),
125    /// We expanded a CfgPath to find the location of a connect file on disk.
126    File {
127        /// The path as configured
128        path: CfgPath,
129        /// The expanded path.
130        expanded: Option<PathBuf>,
131    },
132    /// We expanded a CfgPath to find a directory, and found the connect file
133    /// within that directory
134    WithinDir {
135        /// The path of the directory as configured.
136        path: CfgPath,
137        /// The location of the file.
138        file: PathBuf,
139    },
140}
141
142impl std::fmt::Display for ConnPtLocation {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        // Note: here we use Path::display(), which in other crates we forbid
145        // and use tor_basic_utils::PathExt::display_lossy().
146        //
147        // Here we make an exception, since arti-rpc-client-core is meant to have
148        // minimal dependencies on our other crates.
149        #[allow(clippy::disallowed_methods)]
150        match self {
151            ConnPtLocation::Literal(s) => write!(f, "literal string {:?}", s),
152            ConnPtLocation::File {
153                path,
154                expanded: Some(ex),
155            } => {
156                write!(f, "file {} [{}]", path, ex.display())
157            }
158            ConnPtLocation::File {
159                path,
160                expanded: None,
161            } => {
162                write!(f, "file {} [cannot expand]", path)
163            }
164
165            ConnPtLocation::WithinDir {
166                path,
167                file: expanded,
168            } => {
169                write!(f, "file {} in directory {}", expanded.display(), path)
170            }
171        }
172    }
173}
174
175impl RpcConnBuilder {
176    /// Create a new `RpcConnBuilder` to try connecting to an Arti instance.
177    ///
178    /// By default, we search:
179    ///   - Any connect points listed in the environment variable `$ARTI_RPC_CONNECT_PATH_OVERRIDE`
180    ///   - Any connect points passed to `RpcConnBuilder::prepend_*`
181    ///     (Since these variables are _prepended_,
182    ///     the ones that are prepended _last_ will be considered _first_.)
183    ///   - Any connect points listed in the environment variable `$ARTI_RPC_CONNECT_PATH`
184    ///   - Any connect files in `${ARTI_LOCAL_DATA}/rpc/connect.d`
185    ///   - Any connect files in `/etc/arti-rpc/connect.d` (unix only)
186    ///   - [`tor_rpc_connect::USER_DEFAULT_CONNECT_POINT`]
187    ///   - [`tor_rpc_connect::SYSTEM_DEFAULT_CONNECT_POINT`] if present
188    //
189    // TODO RPC: Once we have our formats more settled, add a link to a piece of documentation
190    // explaining what a connect point is and how to make one.
191    pub fn new() -> Self {
192        Self::default()
193    }
194
195    /// Prepend a single literal connect point to the search path in this RpcConnBuilder.
196    ///
197    /// This entry will be considered before any entries in
198    /// the `$ARTI_RPC_CONNECT_PATH` environment variable
199    /// but after any entry in
200    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable.
201    ///
202    /// This entry must be a literal connect point, expressed as a TOML table.
203    pub fn prepend_literal_entry(&mut self, s: String) {
204        self.prepend_internal(SearchLocation::Literal(s));
205    }
206
207    /// Prepend a single path entry to the search path in this RpcConnBuilder.
208    ///
209    /// This entry will be considered before any entries in
210    /// the `$ARTI_RPC_CONNECT_PATH` environment variable,
211    /// but after any entry in
212    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable.
213    ///
214    /// This entry must be a path to a file or directory.
215    /// It may contain variables to expand;
216    /// they will be expanded according to the rules of [`CfgPath`],
217    /// using the variables of [`tor_config_path::arti_client_base_resolver`].
218    pub fn prepend_path(&mut self, p: String) {
219        self.prepend_internal(SearchLocation::Path {
220            path: CfgPath::new(p),
221            is_default_entry: false,
222        });
223    }
224
225    /// Prepend a single literal path entry to the search path in this RpcConnBuilder.
226    ///
227    /// This entry will be considered before any entries in
228    /// the `$ARTI_RPC_CONNECT_PATH` environment variable,
229    /// but after any entry in
230    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable.
231    ///
232    /// Variables in this entry will not be expanded.
233    pub fn prepend_literal_path(&mut self, p: PathBuf) {
234        self.prepend_internal(SearchLocation::Path {
235            path: CfgPath::new_literal(p),
236            is_default_entry: false,
237        });
238    }
239
240    /// Prepend the application-provided [`SearchLocation`] to the path.
241    fn prepend_internal(&mut self, location: SearchLocation) {
242        self.prepend_path_reversed.push(SearchEntry {
243            source: ConnPtOrigin::Application,
244            location,
245        });
246    }
247
248    /// Return the list of default path entries that we search _after_
249    /// all user-provided entries.
250    fn default_path_entries() -> Vec<SearchEntry> {
251        use SearchLocation::*;
252        let dflt = |location| SearchEntry {
253            source: ConnPtOrigin::Default,
254            location,
255        };
256        let mut result = vec![
257            dflt(Path {
258                path: CfgPath::new("${ARTI_LOCAL_DATA}/rpc/connect.d/".to_owned()),
259                is_default_entry: true,
260            }),
261            #[cfg(unix)]
262            dflt(Path {
263                path: CfgPath::new_literal("/etc/arti-rpc/connect.d/"),
264                is_default_entry: true,
265            }),
266            dflt(Literal(
267                tor_rpc_connect::USER_DEFAULT_CONNECT_POINT.to_owned(),
268            )),
269        ];
270        if let Some(p) = tor_rpc_connect::SYSTEM_DEFAULT_CONNECT_POINT {
271            result.push(dflt(Literal(p.to_owned())));
272        }
273        result
274    }
275
276    /// Return a vector of every PathEntry that we should try to connect to.
277    fn all_entries(&self) -> Result<Vec<SearchEntry>, ConnectError> {
278        let mut entries = SearchEntry::from_env_var("ARTI_RPC_CONNECT_PATH_OVERRIDE")?;
279        entries.extend(self.prepend_path_reversed.iter().rev().cloned());
280        entries.extend(SearchEntry::from_env_var("ARTI_RPC_CONNECT_PATH")?);
281        entries.extend(Self::default_path_entries());
282        Ok(entries)
283    }
284
285    /// Try to connect to an Arti process as specified by this Builder.
286    pub fn connect(&self) -> Result<RpcConn, ConnectFailure> {
287        let resolver = tor_config_path::arti_client_base_resolver();
288        // TODO RPC: Make this configurable.  (Currently, you can override it with
289        // the environment variable FS_MISTRUST_DISABLE_PERMISSIONS_CHECKS.)
290        let mistrust = Mistrust::default();
291        let options = HashMap::new();
292        let all_entries = self.all_entries().map_err(|e| ConnectFailure {
293            declined: vec![],
294            final_desc: None,
295            final_error: e,
296        })?;
297        let mut declined = Vec::new();
298        for (description, load_result) in all_entries
299            .into_iter()
300            .flat_map(|ent| ent.load(&resolver, &mistrust, &options))
301        {
302            match load_result.and_then(|e| try_connect(&e, &resolver, &mistrust)) {
303                Ok(conn) => return Ok(conn),
304                Err(e) => match e.client_action() {
305                    ClientErrorAction::Abort => {
306                        return Err(ConnectFailure {
307                            declined,
308                            final_desc: Some(description),
309                            final_error: e,
310                        });
311                    }
312                    ClientErrorAction::Decline => {
313                        declined.push((description, e));
314                    }
315                },
316            }
317        }
318        Err(ConnectFailure {
319            declined,
320            final_desc: None,
321            final_error: ConnectError::AllAttemptsDeclined,
322        })
323    }
324}
325
326/// Helper: Try to resolve any variables in parsed,
327/// and open and authenticate an RPC connection to it.
328///
329/// This is a separate function from `RpcConnBuilder::connect` to make error handling easier to read.
330fn try_connect(
331    parsed: &ParsedConnectPoint,
332    resolver: &CfgPathResolver,
333    mistrust: &Mistrust,
334) -> Result<RpcConn, ConnectError> {
335    let tor_rpc_connect::client::Connection {
336        reader,
337        writer,
338        auth,
339        ..
340    } = parsed.resolve(resolver)?.connect(mistrust)?;
341    let mut reader = llconn::Reader::new(io::BufReader::new(reader));
342    let banner = reader
343        .read_msg()
344        .map_err(|e| ConnectError::CannotConnect(e.into()))?
345        .ok_or(ConnectError::InvalidBanner)?;
346    check_banner(&banner)?;
347
348    let mut conn = RpcConn::new(reader, llconn::Writer::new(writer));
349
350    // TODO RPC: remove this "scheme name" from the protocol?
351    let session_id = match auth {
352        RpcAuth::Inherent => conn.authenticate_inherent("auth:inherent")?,
353        RpcAuth::Cookie {
354            secret,
355            server_address,
356        } => conn.authenticate_cookie(secret.load()?.as_ref(), &server_address)?,
357        _ => return Err(ConnectError::AuthenticationNotSupported),
358    };
359    conn.session = Some(session_id);
360
361    Ok(conn)
362}
363
364/// Return Ok if `msg` is a banner indicating the correct protocol.
365fn check_banner(msg: &UnparsedResponse) -> Result<(), ConnectError> {
366    /// Structure to indicate that this is indeed an Arti RPC connection.
367    #[derive(serde::Deserialize)]
368    struct BannerMsg {
369        /// Ignored value
370        #[allow(dead_code)]
371        arti_rpc: serde_json::Value,
372    }
373    let _: BannerMsg =
374        serde_json::from_str(msg.as_str()).map_err(|_| ConnectError::InvalidBanner)?;
375    Ok(())
376}
377
378impl SearchEntry {
379    /// Return an iterator over ParsedConnPoints from this `SearchEntry`.
380    fn load<'a>(
381        &self,
382        resolver: &CfgPathResolver,
383        mistrust: &Mistrust,
384        options: &'a HashMap<PathBuf, LoadOptions>,
385    ) -> ConnPtIterator<'a> {
386        // Create a ConnPtDescription given a connect point's location, so we can describe
387        // an error origin.
388        let descr = |location| ConnPtDescription {
389            source: self.source,
390            location,
391        };
392
393        match &self.location {
394            SearchLocation::Literal(s) => ConnPtIterator::Singleton(
395                descr(ConnPtLocation::Literal(s.clone())),
396                // It's a literal entry, so we just try to parse it.
397                ParsedConnectPoint::from_str(s).map_err(|e| ConnectError::from(LoadError::from(e))),
398            ),
399            SearchLocation::Path {
400                path: cfgpath,
401                is_default_entry,
402            } => {
403                // Create a ConnPtDescription given an optional expanded path.
404                let descr_file = |expanded| {
405                    descr(ConnPtLocation::File {
406                        path: cfgpath.clone(),
407                        expanded,
408                    })
409                };
410
411                // It's a path, so we need to expand it...
412                let path = match cfgpath.path(resolver) {
413                    Ok(p) => p,
414                    Err(e) => {
415                        return ConnPtIterator::Singleton(
416                            descr_file(None),
417                            Err(ConnectError::CannotResolvePath(e)),
418                        )
419                    }
420                };
421                if !path.is_absolute() {
422                    if *is_default_entry {
423                        return ConnPtIterator::Done;
424                    } else {
425                        return ConnPtIterator::Singleton(
426                            descr_file(Some(path)),
427                            Err(ConnectError::RelativeConnectFile),
428                        );
429                    }
430                }
431                // ..then try to load it as a directory...
432                match ParsedConnectPoint::load_dir(&path, mistrust, options) {
433                    Ok(iter) => ConnPtIterator::Dir(self.source, cfgpath.clone(), iter),
434                    Err(LoadError::NotADirectory) => {
435                        // ... and if that fails, try to load it as a file.
436                        let loaded =
437                            ParsedConnectPoint::load_file(&path, mistrust).map_err(|e| e.into());
438                        ConnPtIterator::Singleton(descr_file(Some(path)), loaded)
439                    }
440                    Err(other) => {
441                        ConnPtIterator::Singleton(descr_file(Some(path)), Err(other.into()))
442                    }
443                }
444            }
445        }
446    }
447
448    /// Return a list of `SearchEntry` as specified in an environment variable with a given name.
449    fn from_env_var(varname: &'static str) -> Result<Vec<Self>, ConnectError> {
450        match std::env::var(varname) {
451            Ok(s) if s.is_empty() => Ok(vec![]),
452            Ok(s) => Self::from_env_string(varname, &s),
453            Err(std::env::VarError::NotPresent) => Ok(vec![]),
454            Err(_) => Err(ConnectError::BadEnvironment), // TODO RPC: Preserve more information?
455        }
456    }
457
458    /// Return a list of `SearchEntry` as specified in the value `s` from an envvar called `varname`.
459    fn from_env_string(varname: &'static str, s: &str) -> Result<Vec<Self>, ConnectError> {
460        // TODO RPC: Possibly we should be using std::env::split_paths, if it behaves correctly
461        // with our url-escaped entries.
462        s.split(PATH_SEP_CHAR)
463            .map(|s| {
464                Ok(SearchEntry {
465                    source: ConnPtOrigin::EnvVar(varname),
466                    location: SearchLocation::from_env_string_elt(s)?,
467                })
468            })
469            .collect()
470    }
471}
472
473impl SearchLocation {
474    /// Return a `SearchLocation` from a single entry within an environment variable.
475    fn from_env_string_elt(s: &str) -> Result<SearchLocation, ConnectError> {
476        match s.bytes().next() {
477            Some(b'%') | Some(b'[') => Ok(Self::Literal(
478                percent_encoding::percent_decode_str(s)
479                    .decode_utf8()
480                    .map_err(|_| ConnectError::BadEnvironment)?
481                    .into_owned(),
482            )),
483            _ => Ok(Self::Path {
484                path: CfgPath::new(s.to_owned()),
485                is_default_entry: false,
486            }),
487        }
488    }
489}
490
491/// Character used to separate path environment variables.
492const PATH_SEP_CHAR: char = {
493    cfg_if::cfg_if! {
494         if #[cfg(windows)] { ';' } else { ':' }
495    }
496};
497
498/// Iterator over connect points returned by PathEntry::load().
499enum ConnPtIterator<'a> {
500    /// Iterator over a directory
501    Dir(
502        /// Origin of the directory
503        ConnPtOrigin,
504        /// The directory as configured
505        CfgPath,
506        /// Iterator over the elements loaded from the directory
507        tor_rpc_connect::load::ConnPointIterator<'a>,
508    ),
509    /// A single connect point or error
510    Singleton(ConnPtDescription, Result<ParsedConnectPoint, ConnectError>),
511    /// An exhausted iterator
512    Done,
513}
514
515impl<'a> Iterator for ConnPtIterator<'a> {
516    // TODO RPC yield the pathbuf too, for better errors.
517    type Item = (ConnPtDescription, Result<ParsedConnectPoint, ConnectError>);
518
519    fn next(&mut self) -> Option<Self::Item> {
520        let mut t = ConnPtIterator::Done;
521        std::mem::swap(self, &mut t);
522        match t {
523            ConnPtIterator::Dir(source, cfgpath, mut iter) => {
524                let next = iter
525                    .next()
526                    .map(|(path, res)| (path, res.map_err(|e| e.into())));
527                let Some((expanded, result)) = next else {
528                    *self = ConnPtIterator::Done;
529                    return None;
530                };
531                let description = ConnPtDescription {
532                    source,
533                    location: ConnPtLocation::WithinDir {
534                        path: cfgpath.clone(),
535                        file: expanded,
536                    },
537                };
538                *self = ConnPtIterator::Dir(source, cfgpath, iter);
539                Some((description, result))
540            }
541            ConnPtIterator::Singleton(desc, res) => Some((desc, res)),
542            ConnPtIterator::Done => None,
543        }
544    }
545}