arti 1.1.0

A rust implementation of the Tor privacy tools.
Documentation
//! Configuration for the Arti command line application
//
// (This module is called `cfg` to avoid name clash with the `config` crate, which we use.)

use paste::paste;

use derive_builder::Builder;
use serde::{Deserialize, Serialize};

use arti_client::TorClientConfig;
use tor_config::resolve_alternative_specs;
pub(crate) use tor_config::{impl_standard_builder, ConfigBuildError, Listen};

use crate::{LoggingConfig, LoggingConfigBuilder};

/// Example file demonstrating our our configuration and the default options.
///
/// The options in this example file are all commented out;
/// the actual defaults are done via builder attributes in all the Rust config structs.
pub const ARTI_EXAMPLE_CONFIG: &str = concat!(include_str!("./arti-example-config.toml"),);

/// Test case file for the oldest version of the config we still support.
///
/// (When updating, copy `arti-example-config.toml` from the earliest version we want to
/// be compatible with.)
//
// Probably, in the long run, we will want to make this architecture more general: we'll want
// to have a larger number of examples to test, and we won't want to write a separate constant
// for each. Probably in that case, we'll want a directory of test examples, and we'll want to
// traverse the whole directory.
//
// Compare C tor, look at conf_examples and conf_failures - each of the subdirectories there is
// an example configuration situation that we wanted to validate.
//
// NB here in Arti the OLDEST_SUPPORTED_CONFIG and the ARTI_EXAMPLE_CONFIG are tested
// somewhat differently: we test that the current example is *exhaustive*, not just
// parsable.
#[cfg(test)]
const OLDEST_SUPPORTED_CONFIG: &str = concat!(include_str!("./oldest-supported-config.toml"),);

/// Structure to hold our application configuration options
#[derive(Debug, Clone, Builder, Eq, PartialEq)]
#[builder(build_fn(error = "ConfigBuildError"))]
#[builder(derive(Debug, Serialize, Deserialize))]
pub struct ApplicationConfig {
    /// If true, we should watch our configuration files for changes, and reload
    /// our configuration when they change.
    ///
    /// Note that this feature may behave in unexpected ways if the path to the
    /// directory holding our configuration files changes its identity (because
    /// an intermediate symlink is changed, because the directory is removed and
    /// recreated, or for some other reason).
    #[builder(default)]
    pub(crate) watch_configuration: bool,

    /// If true, we should allow other applications not owned by the system
    /// administrator to monitor the Arti application and inspect its memory.
    ///
    /// Otherwise, we take various steps (including disabling core dumps) to
    /// make it harder for other programs to view our internal state.
    ///
    /// This option has no effect when arti is built without the `harden`
    /// feature.  When `harden` is not enabled, debugger attachment is permitted
    /// whether this option is set or not.
    #[builder(default)]
    pub(crate) permit_debugging: bool,

    /// If true, then we do not exit when we are running as `root`.
    ///
    /// This has no effect on Windows.
    #[builder(default)]
    pub(crate) allow_running_as_root: bool,
}
impl_standard_builder! { ApplicationConfig }

/// Resolves values from `$field_listen` and `$field_port` (compat) into a `Listen`
///
/// For `dns` and `proxy`.
///
/// Handles defaulting, and normalization, using `resolve_alternative_specs`
/// and `Listen::new_localhost_option`.
///
/// Broken out into a macro so as to avoid having to state the field name four times,
/// which is a recipe for programming slips.
macro_rules! resolve_listen_port {
    { $self:expr, $field:ident, $def_port:expr } => { paste!{
        resolve_alternative_specs(
            [
                (
                    concat!(stringify!($field), "_listen"),
                    $self.[<$field _listen>].clone(),
                ),
                (
                    concat!(stringify!($field), "_port"),
                    $self.[<$field _port>].map(Listen::new_localhost_optional),
                ),
            ],
            || Listen::new_localhost($def_port),
        )?
    } }
}

/// Configuration for one or more proxy listeners.
#[derive(Debug, Clone, Builder, Eq, PartialEq)]
#[builder(build_fn(error = "ConfigBuildError"))]
#[builder(derive(Debug, Serialize, Deserialize))]
#[allow(clippy::option_option)] // Builder port fields: Some(None) = specified to disable
pub struct ProxyConfig {
    /// Addresses to listen on for incoming SOCKS connections.
    #[builder(field(build = r#"resolve_listen_port!(self, socks, 9150)"#))]
    pub(crate) socks_listen: Listen,

    /// Port to listen on (at localhost) for incoming SOCKS connections.
    ///
    /// This field is deprecated, and will, eventually, be removed.
    /// Use `socks_listen` instead, which accepts the same values,
    /// but which will also be able to support more flexible listening in the future.
    #[builder(
        setter(strip_option),
        field(type = "Option<Option<u16>>", build = "()")
    )]
    #[builder_setter_attr(deprecated)]
    pub(crate) socks_port: (),

    /// Addresses to listen on for incoming DNS connections.
    #[builder(field(build = r#"resolve_listen_port!(self, dns, 0)"#))]
    pub(crate) dns_listen: Listen,

    /// Port to listen on (at localhost) for incoming DNS connections.
    ///
    /// This field is deprecated, and will, eventually, be removed.
    /// Use `dns_listen` instead, which accepts the same values,
    /// but which will also be able to support more flexible listening in the future.
    #[builder(
        setter(strip_option),
        field(type = "Option<Option<u16>>", build = "()")
    )]
    #[builder_setter_attr(deprecated)]
    pub(crate) dns_port: (),
}
impl_standard_builder! { ProxyConfig }

/// Configuration for system resources used by Tor.
///
/// You cannot change this section on a running Arti client.
#[derive(Debug, Clone, Builder, Eq, PartialEq)]
#[builder(build_fn(error = "ConfigBuildError"))]
#[builder(derive(Debug, Serialize, Deserialize))]
#[non_exhaustive]
pub struct SystemConfig {
    /// Maximum number of file descriptors we should launch with
    #[builder(setter(into), default = "default_max_files()")]
    pub(crate) max_files: u64,
}
impl_standard_builder! { SystemConfig }

/// Return the default maximum number of file descriptors to launch with.
fn default_max_files() -> u64 {
    16384
}

/// Structure to hold Arti's configuration options, whether from a
/// configuration file or the command line.
//
/// These options are declared in a public crate outside of `arti` so that other
/// applications can parse and use them, if desired.  If you're only embedding
/// arti via `arti-client`, and you don't want to use Arti's configuration
/// format, use [`arti_client::TorClientConfig`] instead.
///
/// By default, Arti will run using the default Tor network, store state and
/// cache information to a per-user set of directories shared by all
/// that user's applications, and run a SOCKS client on a local port.
///
/// NOTE: These are NOT the final options or their final layout. Expect NO
/// stability here.
#[derive(Debug, Builder, Clone, Eq, PartialEq)]
#[builder(derive(Serialize, Deserialize, Debug))]
#[builder(build_fn(error = "ConfigBuildError"))]
pub struct ArtiConfig {
    /// Configuration for application behavior.
    #[builder(sub_builder)]
    #[builder_field_attr(serde(default))]
    application: ApplicationConfig,

    /// Configuration for proxy listeners
    #[builder(sub_builder)]
    #[builder_field_attr(serde(default))]
    proxy: ProxyConfig,

    /// Logging configuration
    #[builder(sub_builder)]
    #[builder_field_attr(serde(default))]
    logging: LoggingConfig,

    /// Information on system resources used by Arti.
    #[builder(sub_builder)]
    #[builder_field_attr(serde(default))]
    pub(crate) system: SystemConfig,
}
impl_standard_builder! { ArtiConfig }

impl tor_config::load::TopLevel for ArtiConfig {
    type Builder = ArtiConfigBuilder;
    const DEPRECATED_KEYS: &'static [&'static str] = &["proxy.socks_port", "proxy.dns_port"];
}

/// Convenience alias for the config for a whole `arti` program
///
/// Used primarily as a type parameter on calls to [`tor_config::resolve`]
pub type ArtiCombinedConfig = (ArtiConfig, TorClientConfig);

impl ArtiConfig {
    /// Return the [`ApplicationConfig`] for this configuration.
    pub fn application(&self) -> &ApplicationConfig {
        &self.application
    }

    /// Return the [`LoggingConfig`] for this configuration.
    pub fn logging(&self) -> &LoggingConfig {
        &self.logging
    }

    /// Return the [`ProxyConfig`] for this configuration.
    pub fn proxy(&self) -> &ProxyConfig {
        &self.proxy
    }
}

#[cfg(test)]
mod test {
    // @@ begin test lint list maintained by maint/add_warning @@
    #![allow(clippy::bool_assert_comparison)]
    #![allow(clippy::clone_on_copy)]
    #![allow(clippy::dbg_macro)]
    #![allow(clippy::print_stderr)]
    #![allow(clippy::print_stdout)]
    #![allow(clippy::single_char_pattern)]
    #![allow(clippy::unwrap_used)]
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->

    // Saves adding many individual #[cfg], or a sub-module
    #![cfg_attr(not(feature = "pt-client"), allow(dead_code))]

    use arti_client::config::dir;
    use arti_client::config::TorClientConfigBuilder;
    use itertools::{chain, Itertools};
    use regex::Regex;
    use std::collections::HashSet;
    use std::iter;
    use std::time::Duration;
    use tor_config::load::{ConfigResolveError, ResolutionResults};

    use super::*;

    fn uncomment_example_settings(template: &str) -> String {
        let re = Regex::new(r#"(?m)^\#([^ \n])"#).unwrap();
        re.replace_all(template, |cap: &regex::Captures<'_>| -> _ {
            cap.get(1).unwrap().as_str().to_string()
        })
        .into()
    }

    #[test]
    fn default_config() {
        // See comment for OLDEST_SUPPORTED_CONFIG for likely future evolution
        let empty_config = config::Config::builder().build().unwrap();
        let empty_config: ArtiCombinedConfig = tor_config::resolve(empty_config).unwrap();

        let default = (ArtiConfig::default(), TorClientConfig::default());

        let parses_to_defaults = |example: &str, known_unrecognized_options: &[&str]| {
            let cfg = config::Config::builder()
                .add_source(config::File::from_str(example, config::FileFormat::Toml))
                .build()
                .unwrap();

            // This tests that the example settings do not *contradict* the defaults.
            //
            // Also we should ideally test that every setting from the config appears here in
            // the file.  Possibly that could be done with some kind of stunt Deserializer,
            // but it's not trivial.
            let results: ResolutionResults<ArtiCombinedConfig> =
                tor_config::resolve_return_results(cfg).unwrap();

            assert_eq!(&results.value, &default);
            assert_eq!(&results.value, &empty_config);

            // We serialize the DisfavouredKey entries to strings to compare them against
            // `known_unrecognized_options`.
            let unrecognized = results
                .unrecognized
                .iter()
                .map(|k| k.to_string())
                .collect_vec();

            assert_eq!(&unrecognized, &known_unrecognized_options);

            results.value
        };

        #[allow(unused_mut)]
        let mut known_unrecognized_options_all = vec![];

        #[allow(unused_mut)]
        let mut known_unrecognized_options_new = vec![];

        #[cfg(target_family = "windows")]
        known_unrecognized_options_all.extend([
            "storage.permissions.trust_group",
            "storage.permissions.trust_user",
        ]);

        // Additional cfg blocks will need to be added whenever we add features
        // which have example config, since if the feature isn't enabled,
        // those keys are ignored (unrecognized).

        // The unrecognized options in new are those that are only new, plus those in all
        known_unrecognized_options_new.extend(known_unrecognized_options_all.clone());

        let unrecognized_sections = |options| {
            let options: &[&str] = options;
            options
                .iter()
                .cloned()
                .filter(|o| !o.contains("."))
                .collect_vec()
        };

        let _ = parses_to_defaults(
            ARTI_EXAMPLE_CONFIG,
            &unrecognized_sections(&known_unrecognized_options_new),
        );
        let _ = parses_to_defaults(
            OLDEST_SUPPORTED_CONFIG,
            &unrecognized_sections(&known_unrecognized_options_all),
        );

        let built_default = (
            ArtiConfigBuilder::default().build().unwrap(),
            TorClientConfigBuilder::default().build().unwrap(),
        );

        let parsed = parses_to_defaults(
            &uncomment_example_settings(ARTI_EXAMPLE_CONFIG),
            &known_unrecognized_options_new,
        );
        let parsed_old = parses_to_defaults(
            &uncomment_example_settings(OLDEST_SUPPORTED_CONFIG),
            &known_unrecognized_options_all,
        );

        assert_eq!(&parsed, &built_default);
        assert_eq!(&parsed_old, &built_default);

        assert_eq!(&default, &built_default);
    }

    #[test]
    fn builder() {
        use tor_config::CfgPath;
        let sec = std::time::Duration::from_secs(1);

        let auth = dir::Authority::builder()
            .name("Fred")
            .v3ident([22; 20].into())
            .clone();
        let mut fallback = dir::FallbackDir::builder();
        fallback
            .rsa_identity([23; 20].into())
            .ed_identity([99; 32].into())
            .orports()
            .push("127.0.0.7:7".parse().unwrap());

        let mut bld = ArtiConfig::builder();
        let mut bld_tor = TorClientConfig::builder();

        bld.proxy().socks_listen(Listen::new_localhost(9999));
        bld.logging().console("warn");

        bld_tor.tor_network().set_authorities(vec![auth]);
        bld_tor.tor_network().set_fallback_caches(vec![fallback]);
        bld_tor
            .storage()
            .cache_dir(CfgPath::new("/var/tmp/foo".to_owned()))
            .state_dir(CfgPath::new("/var/tmp/bar".to_owned()));
        bld_tor.download_schedule().retry_certs().attempts(10);
        bld_tor.download_schedule().retry_certs().initial_delay(sec);
        bld_tor.download_schedule().retry_certs().parallelism(3);
        bld_tor.download_schedule().retry_microdescs().attempts(30);
        bld_tor
            .download_schedule()
            .retry_microdescs()
            .initial_delay(10 * sec);
        bld_tor
            .download_schedule()
            .retry_microdescs()
            .parallelism(9);
        bld_tor
            .override_net_params()
            .insert("wombats-per-quokka".to_owned(), 7);
        bld_tor
            .path_rules()
            .ipv4_subnet_family_prefix(20)
            .ipv6_subnet_family_prefix(48);
        bld_tor.preemptive_circuits().disable_at_threshold(12);
        bld_tor
            .preemptive_circuits()
            .set_initial_predicted_ports(vec![80, 443]);
        bld_tor
            .preemptive_circuits()
            .prediction_lifetime(Duration::from_secs(3600))
            .min_exit_circs_for_port(2);
        bld_tor
            .circuit_timing()
            .max_dirtiness(90 * sec)
            .request_timeout(10 * sec)
            .request_max_retries(22)
            .request_loyalty(3600 * sec);
        bld_tor.address_filter().allow_local_addrs(true);

        let val = bld.build().unwrap();

        assert_ne!(val, ArtiConfig::default());
    }

    #[test]
    fn articonfig_application() {
        let config = ArtiConfig::default();

        let application = config.application();
        assert_eq!(&config.application, application);
    }

    #[test]
    fn articonfig_logging() {
        let config = ArtiConfig::default();

        let logging = config.logging();
        assert_eq!(&config.logging, logging);
    }

    #[test]
    fn articonfig_proxy() {
        let config = ArtiConfig::default();

        let proxy = config.proxy();
        assert_eq!(&config.proxy, proxy);
    }

    /// Comprehensive tests for the various `socks_port` and `dns_port`
    ///
    /// The "this isn't set at all, just use the default" cases are tested elsewhere.
    fn compat_ports_listen(
        f: &str,
        get_listen: &dyn Fn(&ArtiConfig) -> &Listen,
        bld_get_port: &dyn Fn(&ArtiConfigBuilder) -> &Option<Option<u16>>,
        bld_get_listen: &dyn Fn(&ArtiConfigBuilder) -> &Option<Listen>,
        setter_port: &dyn Fn(&mut ArtiConfigBuilder, Option<u16>) -> &mut ProxyConfigBuilder,
        setter_listen: &dyn Fn(&mut ArtiConfigBuilder, Listen) -> &mut ProxyConfigBuilder,
    ) {
        let from_toml = |s: &str| -> ArtiConfigBuilder {
            let cfg: toml::Value = toml::from_str(dbg!(s)).unwrap();
            let cfg: ArtiConfigBuilder = cfg.try_into().unwrap();
            cfg
        };

        let conflicting_cfgs = [
            format!("proxy.{}_port = 0 \n proxy.{}_listen = 200", f, f),
            format!("proxy.{}_port = 100 \n proxy.{}_listen = 0", f, f),
            format!("proxy.{}_port = 100 \n proxy.{}_listen = 200", f, f),
        ];

        let chk = |cfg: &ArtiConfigBuilder, expected: &Listen| {
            dbg!(bld_get_listen(cfg), bld_get_port(cfg));
            let cfg = cfg.build().unwrap();
            assert_eq!(get_listen(&cfg), expected);
        };

        let check_setters = |port, expected: &_| {
            for cfg in chain!(
                iter::once(ArtiConfig::builder()),
                conflicting_cfgs.iter().map(|cfg| from_toml(cfg)),
            ) {
                for listen in match port {
                    None => vec![Listen::new_none(), Listen::new_localhost(0)],
                    Some(port) => vec![Listen::new_localhost(port)],
                } {
                    let mut cfg = cfg.clone();
                    setter_port(&mut cfg, dbg!(port));
                    setter_listen(&mut cfg, dbg!(listen));
                    chk(&cfg, expected);
                }
            }
        };

        {
            let expected = Listen::new_localhost(100);

            let cfg = from_toml(&format!("proxy.{}_port = 100", f));
            assert_eq!(bld_get_port(&cfg), &Some(Some(100)));
            chk(&cfg, &expected);

            let cfg = from_toml(&format!("proxy.{}_listen = 100", f));
            assert_eq!(bld_get_listen(&cfg), &Some(Listen::new_localhost(100)));
            chk(&cfg, &expected);

            let cfg = from_toml(&format!(
                "proxy.{}_port = 100\n proxy.{}_listen = 100",
                f, f
            ));
            chk(&cfg, &expected);

            check_setters(Some(100), &expected);
        }

        {
            let expected = Listen::new_none();

            let cfg = from_toml(&format!("proxy.{}_port = 0", f));
            chk(&cfg, &expected);

            let cfg = from_toml(&format!("proxy.{}_listen = 0", f));
            chk(&cfg, &expected);

            let cfg = from_toml(&format!("proxy.{}_port = 0 \n proxy.{}_listen = 0", f, f));
            chk(&cfg, &expected);

            check_setters(None, &expected);
        }

        for cfg in &conflicting_cfgs {
            let cfg = from_toml(cfg);
            let err = dbg!(cfg.build()).unwrap_err();
            assert!(err.to_string().contains("specifying different values"));
        }
    }

    #[test]
    #[allow(deprecated)]
    fn ports_listen_socks() {
        compat_ports_listen(
            "socks",
            &|cfg| &cfg.proxy.socks_listen,
            &|bld| &bld.proxy.socks_port,
            &|bld| &bld.proxy.socks_listen,
            &|bld, arg| bld.proxy.socks_port(arg),
            &|bld, arg| bld.proxy.socks_listen(arg),
        );
    }

    #[test]
    #[allow(deprecated)]
    fn compat_ports_listen_dns() {
        compat_ports_listen(
            "dns",
            &|cfg| &cfg.proxy.dns_listen,
            &|bld| &bld.proxy.dns_port,
            &|bld| &bld.proxy.dns_listen,
            &|bld, arg| bld.proxy.dns_port(arg),
            &|bld, arg| bld.proxy.dns_listen(arg),
        );
    }

    /// Config keys which would be recognised by the parser, but are missing from the examples
    ///
    /// Used by `exhaustive_1`.
    const CONFIG_KEYS_EXPECT_NO_EXAMPLE: &[&str] = &[
        // TODO: Provide a test case that parses the `[bridges.transports]` example.
        // See and bullet points 2 and 3 in the doc for `exhaustive_1`, below.
        // https://gitlab.torproject.org/tpo/core/arti/-/issues/674
        #[cfg(feature = "pt-client")]
        "bridges.transports",
        "tor_network.authorities",
        "tor_network.fallback_caches",
    ];

    /// Config file exhaustiveness and default checking
    ///
    /// `example_file` is a putative configuration file text.
    /// It is expected to contain "example lines",
    /// which are lines in start with `#` *not followed by whitespace*.
    ///
    /// This function checks that:
    ///
    /// Positive check on the example lines that are present.
    ///  * `example_file`, when example lines are uncommented, can be parsed.
    ///  * The example values are the same as the default values.
    ///
    /// Check for missing examples:
    ///  * Every key `in `TorClientConfig` or `ArtiConfig` has a corresponding example value.
    ///  * Except: entries in union(`expect_missing` `CONFIG_KEYS_EXPECT_NO_EXAMPLE`)
    ///    do *not* have an example value.
    ///
    /// It handles straightforward cases, where the example line is in a `[section]`
    /// and is something like `#key = value`.
    ///
    /// It does not handle more complex keys, eg those listed in `CONFIG_KEYS_EXPECT_NO_EXAMPLE`,
    /// and which don't appear in "example lines" starting with just `#`:
    ///
    /// For complex config keys, it may not be sufficient to simply write the default value in
    /// the example files (along with perhaps some other information).  In that case,
    ///   1. Write a bespoke example (with lines starting `# `) in the config file.
    ///   2. Write a bespoke test, to test the parsing of the bespoke example.
    ///      This will probably involve using `ExampleSectionLines` and may be quite ad-hoc.
    ///      The test function bridges(), below, is a complex worked example.
    ///   3. Either add a trivial example for the affected key(s) (starting with just `#`)
    ///      or add the affected key(s) to the manual overrides `CONFIG_KEYS_EXPECT_NO_EXAMPLE`.
    fn exhaustive_1(example_file: &str, expect_missing: &[&str]) {
        use serde_json::Value as JsValue;
        use std::collections::BTreeSet;

        let example = uncomment_example_settings(example_file);
        let example: toml::Value = toml::from_str(&example).unwrap();
        // dbg!(&example);
        let example = serde_json::to_value(example).unwrap();
        // dbg!(&example);

        // "Exhaustive" taxonomy of the recognized configuration keys
        //
        // We use the JSON serialization of the default builders, because Rust's toml
        // implementation likes to omit more things, that we want to see.
        //
        // I'm not sure this is quite perfect but it is pretty good,
        // and has found a number of un-exampled config keys.
        let exhausts = [
            serde_json::to_value(TorClientConfig::builder()).unwrap(),
            serde_json::to_value(ArtiConfig::builder()).unwrap(),
        ];

        #[derive(Default, Debug)]
        struct Walk {
            current_path: Vec<String>,
            problems: Vec<(String, String)>,
        }

        impl Walk {
            /// Records a problem
            fn bad(&mut self, m: &str) {
                self.problems
                    .push((self.current_path.join("."), m.to_string()));
            }

            /// Recurses, looking for problems
            ///
            /// Visited for every node in either or both of the starting `exhausts`.
            ///
            /// `E` is the number of elements in `exhausts`, ie the number of different
            /// top-level config types that Arti uses.  Ie, 2.
            fn walk<const E: usize>(
                &mut self,
                example: Option<&JsValue>,
                exhausts: [Option<&JsValue>; E],
            ) {
                assert! { exhausts.into_iter().any(|e| e.is_some()) }

                let example = if let Some(e) = example {
                    e
                } else {
                    self.bad("missing from example");
                    return;
                };

                let tables = exhausts.map(|e| e?.as_object());

                // Union of the keys of both exhausts' tables (insofar as they *are* tables)
                let table_keys = tables
                    .iter()
                    .flat_map(|t| t.map(|t| t.keys().cloned()).into_iter().flatten())
                    .collect::<BTreeSet<String>>();

                for key in table_keys {
                    let example = if let Some(e) = example.as_object() {
                        e
                    } else {
                        // At least one of the exhausts was a nonempty table,
                        // but the corresponding example node isn't a table.
                        self.bad("expected table in example");
                        continue;
                    };

                    // Descend the same key in all the places.
                    self.current_path.push(key.clone());
                    self.walk(example.get(&key), tables.map(|t| t?.get(&key)));
                    self.current_path.pop().unwrap();
                }
            }
        }

        let exhausts = exhausts.iter().map(Some).collect_vec().try_into().unwrap();

        let mut walk = Walk::default();
        walk.walk::<2>(Some(&example), exhausts);
        let mut problems = walk.problems;

        // When adding things here, check that `arti-example-config.toml`
        // actually has something about these particular config keys.
        dbg!(&expect_missing);
        let expect_missing: Vec<&str> = CONFIG_KEYS_EXPECT_NO_EXAMPLE
            .iter()
            .cloned()
            .chain(expect_missing.iter().cloned())
            .collect_vec();

        // Things might appear in expect_missing for different reasons, and sometimes
        // at different levels.  For example, `bridges.transports` is expected to be
        // missing because we document that a different way in the example; but
        // `bridges` is expected to be missing from the OLDEST_SUPPORTED_CONFIG,
        // because that config predates bridge support.
        //
        // When this happens, we need to remove `bridges.transports` in favour of
        // the over-arching `bridges`.
        let expect_missing = expect_missing
            .iter()
            .cloned()
            .filter({
                let original: HashSet<_> = expect_missing.iter().cloned().collect();
                move |found| {
                    !found
                        .match_indices('.')
                        .any(|(doti, _)| original.contains(&found[0..doti]))
                }
            })
            .collect_vec();
        dbg!(&expect_missing);

        for exp in expect_missing {
            let was = problems.len();
            problems.retain(|(path, _)| path != exp);
            if problems.len() == was {
                problems.push((
                    exp.into(),
                    "expected to be missing but found in default".into(),
                ));
            }
        }

        let problems = problems
            .into_iter()
            .map(|(path, m)| format!("    config key {:?}: {}", path, m))
            .collect_vec();

        // If this assert fails, it might be because in `fn exhaustive`, below,
        // a newly-defined config item has not been added to the list for OLDEST_SUPPORTED_CONFIG.
        assert! { problems.is_empty(),
        "example config exhaustiveness check failed: {}\n-----8<-----\n{}\n-----8<-----\n",
                  problems.join("\n"), example_file}
    }

    #[test]
    fn exhaustive() {
        let mut deprecated = vec![];
        <(ArtiConfig, TorClientConfig) as tor_config::load::Resolvable>::enumerate_deprecated_keys(
            &mut |l| {
                for k in l {
                    deprecated.push(k.to_string());
                }
            },
        );
        let deprecated = deprecated.iter().map(|s| &**s).collect_vec();

        // Check that:
        //  - The primary example config file has good examples for everything
        //  - Except for deprecated config keys
        //  - (And, except for those that we never expect: CONFIG_KEYS_EXPECT_NO_EXAMPLE.)
        exhaustive_1(ARTI_EXAMPLE_CONFIG, &deprecated);

        // Check that:
        //  - That oldest supported example config file has good examples for everything
        //  - Except for keys that we have introduced since that file was written
        //  - (And, except for those that we never expect: CONFIG_KEYS_EXPECT_NO_EXAMPLE.)
        exhaustive_1(
            OLDEST_SUPPORTED_CONFIG,
            // add *new*, not present in old file, settings here
            &[
                "application.allow_running_as_root",
                "bridges",
                "proxy.socks_listen",
                "proxy.dns_listen",
            ],
        );
    }

    /// Check that the `Report` of `err` contains the string `exp`, and otherwise panic
    #[cfg_attr(feature = "pt-client", allow(dead_code))]
    fn expect_err_contains(err: ConfigResolveError, exp: &str) {
        use std::error::Error as StdError;
        let err: Box<dyn StdError> = Box::new(err);
        let err = tor_error::Report(err).to_string();
        assert!(
            err.contains(exp),
            "wrong message, got {:?}, exp {:?}",
            err,
            exp,
        );
    }

    #[test]
    fn bridges() {
        // We make assumptions about the contents of `arti-example-config.toml` !
        //
        // 1. There are nontrivial, non-default examples of `bridges.bridges`.
        // 2. These are in the `[bridges]` section, after a line `# For example:`
        // 3. There's precisely one ``` example, with conventional TOML formatting.
        // 4. There's precisely one [ ] example, with conventional TOML formatting.
        // 5. Both these examples specify the same set of bridges.
        // 6. There are three bridges.
        // 7. Lines starting with a digit or `[` are direct bridges; others are PT.
        //
        // Below, we annotate with `[1]` etc. where these assumptions are made.

        // Filter examples that we don't want to test in this configuration
        let filter_examples = |#[allow(unused_mut)] mut examples: ExampleSectionLines| -> _ {
            // [7], filter out the PTs
            if cfg!(all(feature = "bridge-client", not(feature = "pt-client"))) {
                let looks_like_addr =
                    |l: &str| l.starts_with(|c: char| c.is_ascii_digit() || c == '[');
                examples.lines.retain(|l| looks_like_addr(l));
            }

            examples
        };

        // Tests that one example parses, and returns what it parsed.
        // If bridge support is completely disabled, checks that this configuration
        // is rejected, as it should be, and returns a dummy value `((),)`
        // (so that the rest of the test has something to "compare that we parsed it the same").
        let resolve_examples = |examples: &ExampleSectionLines| {
            // [7], check that the PT bridge is properly rejected
            #[cfg(all(feature = "bridge-client", not(feature = "pt-client")))]
            {
                let err = examples.resolve::<TorClientConfig>().unwrap_err();
                expect_err_contains(err, "support disabled in cargo features");
            }

            let examples = filter_examples(examples.clone());

            #[cfg(feature = "bridge-client")]
            {
                examples.resolve::<TorClientConfig>().unwrap()
            }

            #[cfg(not(feature = "bridge-client"))]
            {
                let err = examples.resolve::<TorClientConfig>().unwrap_err();
                expect_err_contains(err, "support disabled in cargo features");
                // Use ((),) as the dummy unit value because () gives clippy conniptions
                ((),)
            }
        };

        // [1], [2], narrow to just the nontrivial, non-default, examples
        let mut examples = ExampleSectionLines::new("bridges");
        examples.narrow((r#"^# For example:"#, true), NARROW_NONE);

        let compare = {
            // [3], narrow to the multi-line string
            let mut examples = examples.clone();
            examples.narrow((r#"^#  bridges = '''"#, true), (r#"^#  '''"#, true));
            examples.uncomment();

            let parsed = resolve_examples(&examples);

            // Now we fish out the lines ourselves as a double-check
            // We must strip off the bridges = ''' and ''' lines.
            examples.lines.remove(0);
            examples.lines.remove(examples.lines.len() - 1);
            // [6], check we got the number of examples we expected
            examples.expect_lines(3);

            // If we have the bridge API, try parsing each line and using the API to insert it
            #[cfg(feature = "bridge-client")]
            {
                let examples = filter_examples(examples);
                let mut built = TorClientConfig::builder();
                for l in &examples.lines {
                    built.bridges().bridges().push(l.trim().parse().expect(l));
                }
                let built = built.build().unwrap();

                assert_eq!(&parsed, &built);
            }

            parsed
        };

        // [4], [5], narrow to the [ ] section, parse again, and compare
        {
            examples.narrow((r#"^#  bridges = \["#, true), (r#"^#  \]"#, true));
            examples.uncomment();
            let parsed = resolve_examples(&examples);
            assert_eq!(&parsed, &compare);
        }
    }

    /// Helper for fishing out parts of the config file and uncommenting them
    ///
    /// This can be used to find part of the config file by ad-hoc regexp matching,
    /// uncomment it, and parse it.  This is useful as part of a test to check
    /// that we can parse more complex config.
    #[derive(Debug, Clone)]
    struct ExampleSectionLines {
        section: String,
        lines: Vec<String>,
    }

    type NarrowInstruction<'s> = (&'s str, bool);
    const NARROW_NONE: NarrowInstruction<'static> = ("?<none>", false);

    impl ExampleSectionLines {
        fn new(section: &str) -> Self {
            let section = format!("[{}]", section);

            let mut first = Some(());
            let lines = ARTI_EXAMPLE_CONFIG
                .lines()
                .skip_while(|l| l != &section)
                .take_while(|l| first.take().is_some() || !l.starts_with("["))
                .map(|l| l.to_string())
                .collect_vec();

            ExampleSectionLines { section, lines }
        }

        fn narrow(&mut self, start: NarrowInstruction, end: NarrowInstruction) {
            let find_index = |(re, include), adjust: [isize; 2]| {
                if (re, include) == NARROW_NONE {
                    return None;
                }

                let re = Regex::new(re).expect(re);
                let i = self
                    .lines
                    .iter()
                    .enumerate()
                    .filter(|(_, l)| re.is_match(l))
                    .map(|(i, _)| i);
                let i = i.clone().exactly_one().unwrap_or_else(|_| {
                    panic!("RE={:?} I={:#?} L={:#?}", re, i.collect_vec(), &self.lines)
                });

                let adjust = adjust[usize::from(include)];
                let i = (i as isize + adjust) as usize;
                Some(i)
            };

            eprint!("narrow {:?} {:?}: ", start, end);
            let start = find_index(start, [1, 0]).unwrap_or(0);
            let end = find_index(end, [0, 1]).unwrap_or(self.lines.len());
            eprintln!("{:?} {:?}", start, end);
            // don't tolerate empty
            assert!(start < end, "empty, from {:#?}", &self.lines);
            self.lines = self.lines.drain(..).take(end).skip(start).collect_vec();
        }

        fn expect_lines(&self, n: usize) {
            assert_eq!(self.lines.len(), n);
        }

        fn uncomment(&mut self) {
            for l in &mut self.lines {
                *l = l.strip_prefix('#').expect(l).to_string();
            }
        }

        fn parse(&self) -> config::Config {
            let s: String = chain!(iter::once(&self.section), self.lines.iter(),).join("\n");
            eprintln!("parsing\n  --\n{}\n  --", &s);
            let c: toml::Value = toml::from_str(&s).expect(&s);
            config::Config::try_from(&c).expect(&s)
        }

        fn resolve<R: tor_config::load::Resolvable>(&self) -> Result<R, ConfigResolveError> {
            tor_config::load::resolve(self.parse())
        }
    }
}