1
//! Functionality to connect to an RPC server.
2

            
3
use std::{collections::HashMap, path::PathBuf, str::FromStr as _, sync::Arc};
4

            
5
use fs_mistrust::Mistrust;
6
use tor_config_path::{CfgPath, CfgPathResolver};
7
use tor_rpc_connect::{
8
    ClientErrorAction, HasClientErrorAction, ParsedConnectPoint, SuperuserPermission,
9
    auth::RpcAuth,
10
    load::{LoadError, LoadOptions},
11
};
12

            
13
use crate::{
14
    RpcConn, RpcPoll, conn::ConnectError, ll_conn::BlockingConnection,
15
    msgs::response::UnparsedResponse,
16
};
17

            
18
use super::ConnectFailure;
19

            
20
/// An error occurred while trying to construct or manipulate an [`RpcConnBuilder`].
21
#[derive(Clone, Debug, thiserror::Error)]
22
#[non_exhaustive]
23
pub enum BuilderError {
24
    /// We couldn't decode a provided connect string.
25
    #[error("Invalid connect string.")]
26
    InvalidConnectString,
27
}
28

            
29
/// Possible preference for superuser value.
30
#[derive(Clone, Debug, Default, Eq, PartialEq)]
31
enum SuperuserPreference {
32
    /// We want to use the first connect point that works,
33
    /// regardless of superuser permission.
34
    #[default]
35
    None,
36
    /// Fail unless we find a connect point with superuser permission.
37
    Required,
38
    /// First, look for connect points with superuser permission.
39
    /// Only try other connect points if we can't find any.
40
    Preferred,
41
}
42

            
43
/// Information about how to construct a connection to an Arti instance.
44
//
45
// TODO RPC: Once we have our formats more settled, add a link to a piece of documentation
46
// explaining what a connect point is and how to make one.
47
#[derive(Default, Clone, Debug)]
48
pub struct RpcConnBuilder {
49
    /// Path entries provided programmatically.
50
    ///
51
    /// These are considered after entries in
52
    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable,
53
    /// but before any other entries.
54
    /// (See `RPCConnBuilder::new` for details.)
55
    ///
56
    /// These entries are stored in reverse order.
57
    prepend_path_reversed: Vec<SearchEntry>,
58
    /// Whether we prefer/require a connect point with superuser permission.
59
    superuser_preference: SuperuserPreference,
60
}
61

            
62
/// A single entry in the search path used to find connect points.
63
///
64
/// Includes information on where we got this entry
65
/// (environment variable, application, or default).
66
#[derive(Clone, Debug)]
67
struct SearchEntry {
68
    /// The source telling us this entry.
69
    source: ConnPtOrigin,
70
    /// The location to search.
71
    location: SearchLocation,
72
}
73

            
74
/// A single location in the search path used to find connect points.
75
#[derive(Clone, Debug)]
76
enum SearchLocation {
77
    /// A literal connect point entry to parse.
78
    Literal(String),
79
    /// A path to a connect file, or a directory full of connect files.
80
    Path {
81
        /// The path to load.
82
        path: CfgPath,
83

            
84
        /// If true, then this entry comes from a builtin default,
85
        /// and relative paths should cause the connect attempt to be declined.
86
        ///
87
        /// Otherwise, this entry comes from the user or application,
88
        /// and relative paths should cause the connect attempt to abort.
89
        is_default_entry: bool,
90
    },
91
}
92

            
93
/// Diagnostic: An explanation of where we found a connect point,
94
/// and why we looked there.
95
#[derive(Debug, Clone)]
96
pub struct ConnPtDescription {
97
    /// What told us to look in this location
98
    source: ConnPtOrigin,
99
    /// Where we found the connect point.
100
    location: ConnPtLocation,
101
}
102

            
103
impl std::fmt::Display for ConnPtDescription {
104
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105
        write!(
106
            f,
107
            "connect point in {}, from {}",
108
            &self.location, &self.source
109
        )
110
    }
111
}
112

            
113
/// Diagnostic: a source telling us where to look for a connect point.
114
#[derive(Clone, Copy, Debug)]
115
enum ConnPtOrigin {
116
    /// Found the search entry from an environment variable.
117
    EnvVar(&'static str),
118
    /// Application manually inserted the search entry.
119
    Application,
120
    /// The search entry was a built-in default
121
    Default,
122
}
123

            
124
impl std::fmt::Display for ConnPtOrigin {
125
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126
        match self {
127
            ConnPtOrigin::EnvVar(varname) => write!(f, "${}", varname),
128
            ConnPtOrigin::Application => write!(f, "application"),
129
            ConnPtOrigin::Default => write!(f, "default list"),
130
        }
131
    }
132
}
133

            
134
/// Diagnostic: Where we found a connect point.
135
#[derive(Clone, Debug)]
136
enum ConnPtLocation {
137
    /// The connect point was given as a literal string.
138
    Literal(String),
139
    /// We expanded a CfgPath to find the location of a connect file on disk.
140
    File {
141
        /// The path as configured
142
        path: CfgPath,
143
        /// The expanded path.
144
        expanded: Option<PathBuf>,
145
    },
146
    /// We expanded a CfgPath to find a directory, and found the connect file
147
    /// within that directory
148
    WithinDir {
149
        /// The path of the directory as configured.
150
        path: CfgPath,
151
        /// The location of the file.
152
        file: PathBuf,
153
    },
154
}
155

            
156
impl std::fmt::Display for ConnPtLocation {
157
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
158
        // Note: here we use Path::display(), which in other crates we forbid
159
        // and use tor_basic_utils::PathExt::display_lossy().
160
        //
161
        // Here we make an exception, since arti-rpc-client-core is meant to have
162
        // minimal dependencies on our other crates.
163
        #[allow(clippy::disallowed_methods)]
164
        match self {
165
            ConnPtLocation::Literal(s) => write!(f, "literal string {:?}", s),
166
            ConnPtLocation::File {
167
                path,
168
                expanded: Some(ex),
169
            } => {
170
                write!(f, "file {} [{}]", path, ex.display())
171
            }
172
            ConnPtLocation::File {
173
                path,
174
                expanded: None,
175
            } => {
176
                write!(f, "file {} [cannot expand]", path)
177
            }
178

            
179
            ConnPtLocation::WithinDir {
180
                path,
181
                file: expanded,
182
            } => {
183
                write!(f, "file {} in directory {}", expanded.display(), path)
184
            }
185
        }
186
    }
187
}
188

            
189
impl RpcConnBuilder {
190
    /// Create a new `RpcConnBuilder` to try connecting to an Arti instance.
191
    ///
192
    /// By default, we search:
193
    ///   - Any connect points listed in the environment variable `$ARTI_RPC_CONNECT_PATH_OVERRIDE`
194
    ///   - Any connect points passed to `RpcConnBuilder::prepend_*`
195
    ///     (Since these variables are _prepended_,
196
    ///     the ones that are prepended _last_ will be considered _first_.)
197
    ///   - Any connect points listed in the environment variable `$ARTI_RPC_CONNECT_PATH`
198
    ///   - Any connect files in `${ARTI_LOCAL_DATA}/rpc/connect.d`
199
    ///   - Any connect files in `/etc/arti-rpc/connect.d` (unix only)
200
    ///   - [`tor_rpc_connect::USER_DEFAULT_CONNECT_POINT`]
201
    ///   - [`tor_rpc_connect::SYSTEM_DEFAULT_CONNECT_POINT`] if present
202
    //
203
    // TODO RPC: Once we have our formats more settled, add a link to a piece of documentation
204
    // explaining what a connect point is and how to make one.
205
    pub fn new() -> Self {
206
        Self::default()
207
    }
208

            
209
    /// Prepend a single literal connect point to the search path in this RpcConnBuilder.
210
    ///
211
    /// This entry will be considered before any entries in
212
    /// the `$ARTI_RPC_CONNECT_PATH` environment variable
213
    /// but after any entry in
214
    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable.
215
    ///
216
    /// This entry must be a literal connect point, expressed as a TOML table.
217
    pub fn prepend_literal_entry(&mut self, s: String) {
218
        self.prepend_internal(SearchLocation::Literal(s));
219
    }
220

            
221
    /// Prepend a single path entry to the search path in this RpcConnBuilder.
222
    ///
223
    /// This entry will be considered before any entries in
224
    /// the `$ARTI_RPC_CONNECT_PATH` environment variable,
225
    /// but after any entry in
226
    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable.
227
    ///
228
    /// This entry must be a path to a file or directory.
229
    /// It may contain variables to expand;
230
    /// they will be expanded according to the rules of [`CfgPath`],
231
    /// using the variables of [`tor_config_path::arti_client_base_resolver`].
232
    pub fn prepend_path(&mut self, p: String) {
233
        self.prepend_internal(SearchLocation::Path {
234
            path: CfgPath::new(p),
235
            is_default_entry: false,
236
        });
237
    }
238

            
239
    /// Prepend a single literal path entry to the search path in this RpcConnBuilder.
240
    ///
241
    /// This entry will be considered before any entries in
242
    /// the `$ARTI_RPC_CONNECT_PATH` environment variable,
243
    /// but after any entry in
244
    /// the `$ARTI_RPC_CONNECT_PATH_OVERRIDE` environment variable.
245
    ///
246
    /// Variables in this entry will not be expanded.
247
    pub fn prepend_literal_path(&mut self, p: PathBuf) {
248
        self.prepend_internal(SearchLocation::Path {
249
            path: CfgPath::new_literal(p),
250
            is_default_entry: false,
251
        });
252
    }
253

            
254
    /// Try to find a connect point that grants superuser permission.
255
    ///
256
    /// If none is found, and `required` is true, the connection attempt will fail
257
    pub fn prefer_superuser_permission(&mut self, required: bool) {
258
        if required {
259
            self.superuser_preference = SuperuserPreference::Required;
260
        } else {
261
            self.superuser_preference = SuperuserPreference::Preferred;
262
        }
263
    }
264

            
265
    /// Prepend the application-provided [`SearchLocation`] to the path.
266
    fn prepend_internal(&mut self, location: SearchLocation) {
267
        self.prepend_path_reversed.push(SearchEntry {
268
            source: ConnPtOrigin::Application,
269
            location,
270
        });
271
    }
272

            
273
    /// Return the list of default path entries that we search _after_
274
    /// all user-provided entries.
275
    fn default_path_entries() -> Vec<SearchEntry> {
276
        use SearchLocation::*;
277
        let dflt = |location| SearchEntry {
278
            source: ConnPtOrigin::Default,
279
            location,
280
        };
281
        let mut result = vec![
282
            dflt(Path {
283
                path: CfgPath::new("${ARTI_LOCAL_DATA}/rpc/connect.d/".to_owned()),
284
                is_default_entry: true,
285
            }),
286
            #[cfg(unix)]
287
            dflt(Path {
288
                path: CfgPath::new_literal("/etc/arti-rpc/connect.d/"),
289
                is_default_entry: true,
290
            }),
291
            dflt(Literal(
292
                tor_rpc_connect::USER_DEFAULT_CONNECT_POINT.to_owned(),
293
            )),
294
        ];
295
        if let Some(p) = tor_rpc_connect::SYSTEM_DEFAULT_CONNECT_POINT {
296
            result.push(dflt(Literal(p.to_owned())));
297
        }
298
        result
299
    }
300

            
301
    /// Return a vector of every PathEntry that we should try to connect to.
302
    fn all_entries(&self) -> Result<Vec<SearchEntry>, ConnectError> {
303
        let mut entries = SearchEntry::from_env_var("ARTI_RPC_CONNECT_PATH_OVERRIDE")?;
304
        entries.extend(self.prepend_path_reversed.iter().rev().cloned());
305
        entries.extend(SearchEntry::from_env_var("ARTI_RPC_CONNECT_PATH")?);
306
        entries.extend(Self::default_path_entries());
307
        Ok(entries)
308
    }
309

            
310
    /// Try to connect to an Arti process as specified by this Builder.
311
    pub fn connect(&self) -> Result<RpcConn, ConnectFailure> {
312
        match self.superuser_preference {
313
            SuperuserPreference::None => self.connect_impl(false),
314
            SuperuserPreference::Required => self.connect_impl(true),
315
            SuperuserPreference::Preferred => {
316
                // This implementation isn't optimal: if there are failing
317
                // su-capable connect points then it will try them twice.
318
                // But it is much, much simpler than the alternatives.
319
                if let Ok(v) = self.connect_impl(true) {
320
                    return Ok(v);
321
                }
322
                self.connect_impl(false)
323
            }
324
        }
325
    }
326

            
327
    /// Helper: as `connect`, but if `require_su` is absent, pretend that all non-superuser
328
    /// non-abort entries aren't there.
329
    fn connect_impl(&self, require_su: bool) -> Result<RpcConn, ConnectFailure> {
330
        let resolver = tor_config_path::arti_client_base_resolver();
331
        // TODO RPC: Make this configurable.  (Currently, you can override it with
332
        // the environment variable FS_MISTRUST_DISABLE_PERMISSIONS_CHECKS.)
333
        let mistrust = Mistrust::default();
334
        let options = HashMap::new();
335
        let all_entries = self.all_entries().map_err(|e| ConnectFailure {
336
            declined: vec![],
337
            final_desc: None,
338
            final_error: e,
339
        })?;
340
        let mut declined = Vec::new();
341
        for (description, load_result) in all_entries
342
            .into_iter()
343
            .flat_map(|ent| ent.load(&resolver, &mistrust, &options))
344
        {
345
            if let Ok(parsed) = &load_result
346
                && require_su
347
                && parsed.superuser_permission() != SuperuserPermission::Allowed
348
                && !parsed.is_explicit_abort()
349
            {
350
                continue;
351
            }
352

            
353
            match load_result.and_then(|parsed| try_connect(&parsed, &resolver, &mistrust)) {
354
                Ok(conn) => return Ok(conn),
355
                Err(e) => match e.client_action() {
356
                    ClientErrorAction::Abort => {
357
                        return Err(ConnectFailure {
358
                            declined,
359
                            final_desc: Some(description),
360
                            final_error: e,
361
                        });
362
                    }
363
                    ClientErrorAction::Decline => {
364
                        declined.push((description, e));
365
                    }
366
                },
367
            }
368
        }
369

            
370
        Err(ConnectFailure {
371
            declined,
372
            final_desc: None,
373
            final_error: ConnectError::AllAttemptsDeclined,
374
        })
375
    }
376

            
377
    /// As [`connect`](Self::connect), but return an `RpcConn`
378
    /// suitable for use with event-driven IO,
379
    /// and an [`RpcPoll`] to drive that IO.
380
    ///
381
    /// Requires an [`EventLoop`] which,
382
    /// when invoked, will cause the events registered for the `RpcPoll` to be changed.
383
    /// (See `EventLoop` documentation for implementation suggestions.)
384
    ///
385
    /// # Correct usage
386
    ///
387
    /// Once you have received an RpcPoll from this function,
388
    /// you _must_ honour the methods on [`EventLoop`]
389
    /// and call [`RpcPoll::poll()`] as documented;
390
    /// otherwise, no requests--even those created with `execute` methods--will receive responses.
391
    ///
392
    /// [`EventLoop`]: crate::EventLoop
393
    pub fn connect_polling(
394
        &self,
395
        event_loop: Box<dyn crate::EventLoop>,
396
    ) -> Result<(RpcConn, RpcPoll), ConnectFailure> {
397
        let mut conn = self.connect()?;
398

            
399
        let poll = conn
400
            .construct_rpc_poll(event_loop)
401
            // This can only occur if somebody else is blocking on the receiver for this RpcConn,
402
            // which should be impossible, since we just created it with Self::connect.
403
            .expect("Unable to construct RpcPoll implementation");
404
        Ok((conn, poll))
405
    }
406
}
407

            
408
/// Helper: Try to resolve any variables in parsed,
409
/// and open and authenticate an RPC connection to it.
410
///
411
/// This is a separate function from `RpcConnBuilder::connect` to make error handling easier to read.
412
fn try_connect(
413
    parsed: &ParsedConnectPoint,
414
    resolver: &CfgPathResolver,
415
    mistrust: &Mistrust,
416
) -> Result<RpcConn, ConnectError> {
417
    use tor_rpc_connect::client::Stream as S;
418
    let tor_rpc_connect::client::Connection { stream, auth, .. } =
419
        parsed.resolve(resolver)?.connect(mistrust)?;
420
    let wrap_io_err = |e| tor_rpc_connect::ConnectError::Io(Arc::new(e));
421

            
422
    let stream: Box<dyn crate::ll_conn::MioStream> = match stream {
423
        S::Tcp(tcp_stream) => {
424
            tcp_stream.set_nonblocking(true).map_err(wrap_io_err)?;
425
            Box::new(mio::net::TcpStream::from_std(tcp_stream))
426
        }
427
        #[cfg(unix)]
428
        S::Unix(unix_stream) => {
429
            unix_stream.set_nonblocking(true).map_err(wrap_io_err)?;
430
            Box::new(mio::net::UnixStream::from_std(unix_stream))
431
        }
432
        _ => return Err(ConnectError::StreamTypeUnsupported),
433
    };
434

            
435
    let mut stream = BlockingConnection::new(stream).map_err(wrap_io_err)?;
436
    let banner = stream
437
        .interact()
438
        .map_err(wrap_io_err)?
439
        .ok_or(ConnectError::InvalidBanner)?;
440
    check_banner(&banner)?;
441

            
442
    let mut conn = RpcConn::new(stream);
443

            
444
    // TODO RPC: remove this "scheme name" from the protocol?
445
    let session_id = match auth {
446
        RpcAuth::Inherent => conn.authenticate_inherent("auth:inherent")?,
447
        RpcAuth::Cookie {
448
            secret,
449
            server_address,
450
        } => conn.authenticate_cookie(secret.load()?.as_ref(), &server_address)?,
451
        _ => return Err(ConnectError::AuthenticationNotSupported),
452
    };
453
    conn.session = Some(session_id);
454

            
455
    Ok(conn)
456
}
457

            
458
/// Return Ok if `msg` is a banner indicating the correct protocol.
459
fn check_banner(msg: &UnparsedResponse) -> Result<(), ConnectError> {
460
    /// Structure to indicate that this is indeed an Arti RPC connection.
461
    #[derive(serde::Deserialize)]
462
    struct BannerMsg {
463
        /// Ignored value
464
        #[allow(dead_code)]
465
        arti_rpc: serde_json::Value,
466
    }
467
    let _: BannerMsg =
468
        serde_json::from_str(msg.as_str()).map_err(|_| ConnectError::InvalidBanner)?;
469
    Ok(())
470
}
471

            
472
impl SearchEntry {
473
    /// Return an iterator over ParsedConnPoints from this `SearchEntry`.
474
    fn load<'a>(
475
        &self,
476
        resolver: &CfgPathResolver,
477
        mistrust: &Mistrust,
478
        options: &'a HashMap<PathBuf, LoadOptions>,
479
    ) -> ConnPtIterator<'a> {
480
        // Create a ConnPtDescription given a connect point's location, so we can describe
481
        // an error origin.
482
        let descr = |location| ConnPtDescription {
483
            source: self.source,
484
            location,
485
        };
486

            
487
        match &self.location {
488
            SearchLocation::Literal(s) => ConnPtIterator::Singleton(
489
                descr(ConnPtLocation::Literal(s.clone())),
490
                // It's a literal entry, so we just try to parse it.
491
                ParsedConnectPoint::from_str(s).map_err(|e| ConnectError::from(LoadError::from(e))),
492
            ),
493
            SearchLocation::Path {
494
                path: cfgpath,
495
                is_default_entry,
496
            } => {
497
                // Create a ConnPtDescription given an optional expanded path.
498
                let descr_file = |expanded| {
499
                    descr(ConnPtLocation::File {
500
                        path: cfgpath.clone(),
501
                        expanded,
502
                    })
503
                };
504

            
505
                // It's a path, so we need to expand it...
506
                let path = match cfgpath.path(resolver) {
507
                    Ok(p) => p,
508
                    Err(e) => {
509
                        return ConnPtIterator::Singleton(
510
                            descr_file(None),
511
                            Err(ConnectError::CannotResolvePath(e)),
512
                        );
513
                    }
514
                };
515
                if !path.is_absolute() {
516
                    if *is_default_entry {
517
                        return ConnPtIterator::Done;
518
                    } else {
519
                        return ConnPtIterator::Singleton(
520
                            descr_file(Some(path)),
521
                            Err(ConnectError::RelativeConnectFile),
522
                        );
523
                    }
524
                }
525
                // ..then try to load it as a directory...
526
                match ParsedConnectPoint::load_dir(&path, mistrust, options) {
527
                    Ok(iter) => ConnPtIterator::Dir(self.source, cfgpath.clone(), iter),
528
                    Err(LoadError::NotADirectory) => {
529
                        // ... and if that fails, try to load it as a file.
530
                        let loaded =
531
                            ParsedConnectPoint::load_file(&path, mistrust).map_err(|e| e.into());
532
                        ConnPtIterator::Singleton(descr_file(Some(path)), loaded)
533
                    }
534
                    Err(other) => {
535
                        ConnPtIterator::Singleton(descr_file(Some(path)), Err(other.into()))
536
                    }
537
                }
538
            }
539
        }
540
    }
541

            
542
    /// Return a list of `SearchEntry` as specified in an environment variable with a given name.
543
    fn from_env_var(varname: &'static str) -> Result<Vec<Self>, ConnectError> {
544
        match std::env::var(varname) {
545
            Ok(s) if s.is_empty() => Ok(vec![]),
546
            Ok(s) => Self::from_env_string(varname, &s),
547
            Err(std::env::VarError::NotPresent) => Ok(vec![]),
548
            Err(_) => Err(ConnectError::BadEnvironment), // TODO RPC: Preserve more information?
549
        }
550
    }
551

            
552
    /// Return a list of `SearchEntry` as specified in the value `s` from an envvar called `varname`.
553
    fn from_env_string(varname: &'static str, s: &str) -> Result<Vec<Self>, ConnectError> {
554
        // TODO RPC: Possibly we should be using std::env::split_paths, if it behaves correctly
555
        // with our url-escaped entries.
556
        s.split(PATH_SEP_CHAR)
557
            .map(|s| {
558
                Ok(SearchEntry {
559
                    source: ConnPtOrigin::EnvVar(varname),
560
                    location: SearchLocation::from_env_string_elt(s)?,
561
                })
562
            })
563
            .collect()
564
    }
565
}
566

            
567
impl SearchLocation {
568
    /// Return a `SearchLocation` from a single entry within an environment variable.
569
    fn from_env_string_elt(s: &str) -> Result<SearchLocation, ConnectError> {
570
        match s.bytes().next() {
571
            Some(b'%') | Some(b'[') => Ok(Self::Literal(
572
                percent_encoding::percent_decode_str(s)
573
                    .decode_utf8()
574
                    .map_err(|_| ConnectError::BadEnvironment)?
575
                    .into_owned(),
576
            )),
577
            _ => Ok(Self::Path {
578
                path: CfgPath::new(s.to_owned()),
579
                is_default_entry: false,
580
            }),
581
        }
582
    }
583
}
584

            
585
/// Character used to separate path environment variables.
586
const PATH_SEP_CHAR: char = {
587
    cfg_if::cfg_if! {
588
         if #[cfg(windows)] { ';' } else { ':' }
589
    }
590
};
591

            
592
/// Iterator over connect points returned by PathEntry::load().
593
enum ConnPtIterator<'a> {
594
    /// Iterator over a directory
595
    Dir(
596
        /// Origin of the directory
597
        ConnPtOrigin,
598
        /// The directory as configured
599
        CfgPath,
600
        /// Iterator over the elements loaded from the directory
601
        tor_rpc_connect::load::ConnPointIterator<'a>,
602
    ),
603
    /// A single connect point or error
604
    Singleton(ConnPtDescription, Result<ParsedConnectPoint, ConnectError>),
605
    /// An exhausted iterator
606
    Done,
607
}
608

            
609
impl<'a> Iterator for ConnPtIterator<'a> {
610
    // TODO RPC yield the pathbuf too, for better errors.
611
    type Item = (ConnPtDescription, Result<ParsedConnectPoint, ConnectError>);
612

            
613
    fn next(&mut self) -> Option<Self::Item> {
614
        let mut t = ConnPtIterator::Done;
615
        std::mem::swap(self, &mut t);
616
        match t {
617
            ConnPtIterator::Dir(source, cfgpath, mut iter) => {
618
                let next = iter
619
                    .next()
620
                    .map(|(path, res)| (path, res.map_err(|e| e.into())));
621
                let Some((expanded, result)) = next else {
622
                    *self = ConnPtIterator::Done;
623
                    return None;
624
                };
625
                let description = ConnPtDescription {
626
                    source,
627
                    location: ConnPtLocation::WithinDir {
628
                        path: cfgpath.clone(),
629
                        file: expanded,
630
                    },
631
                };
632
                *self = ConnPtIterator::Dir(source, cfgpath, iter);
633
                Some((description, result))
634
            }
635
            ConnPtIterator::Singleton(desc, res) => Some((desc, res)),
636
            ConnPtIterator::Done => None,
637
        }
638
    }
639
}