arti-relay 0.28.0

Library for running a relay of the Tor network
//! The command-line interface.
//!
//! See [`Cli`].

use std::ffi::OsString;
use std::path::PathBuf;

use clap::{Args, Command, Parser, Subcommand, ValueEnum};
use fs_mistrust::anon_home::PathExt as _;
use once_cell::sync::Lazy;
use tor_config::{ConfigurationSource, ConfigurationSources};
use tor_config_path::CfgPathError;

use crate::config::default_config_paths;

/// A cached copy of the default config paths.
///
/// We cache the values to ensure they are consistent between the help text and the values used.
static DEFAULT_CONFIG_PATHS: Lazy<Result<Vec<PathBuf>, CfgPathError>> =
    Lazy::new(default_config_paths);

/// A Rust Tor relay implementation.
#[derive(Clone, Debug, Parser)]
#[command(author = "The Tor Project Developers")]
#[command(version)]
#[command(defer = cli_cmd_post_processing)]
pub(crate) struct Cli {
    /// Sub-commands.
    #[command(subcommand)]
    pub(crate) command: Commands,

    /// Global arguments available for all sub-commands.
    ///
    /// These arguments may be specified before or after the subcommand argument.
    #[clap(flatten)]
    pub(crate) global: GlobalArgs,
}

/// Perform post-processing on the [`Command`] generated by clap for [`Cli`].
///
/// We use this to append the default config paths to the help text.
fn cli_cmd_post_processing(cli: Command) -> Command {
    /// Append the paths to the help text.
    fn fmt_help(help: Option<&str>, paths: &[PathBuf]) -> String {
        let help = help.map(|x| format!("{x}\n\n")).unwrap_or("".to_string());
        let paths: Vec<_> = paths
            .iter()
            .map(|x| x.anonymize_home().to_string())
            .collect();
        let paths = paths.join("\n");

        /// TODO MSRV: remove this comment once MSRV is >= 1.84:
        /// https://github.com/rust-lang/rust-clippy/issues/13802
        const DESC: &str =
            "If no paths are provided, the following config paths will be used if they exist:";
        format!("{help}{DESC}\n\n{paths}")
    }

    // Show the default paths in the "--help" text.
    match &*DEFAULT_CONFIG_PATHS {
        Ok(paths) => cli.mut_arg("config", |arg| {
            if let Some(help) = arg.get_long_help() {
                let help = help.to_string();
                arg.long_help(fmt_help(Some(&help), paths))
            } else if let Some(help) = arg.get_help() {
                let help = help.to_string();
                arg.long_help(fmt_help(Some(&help), paths))
            } else {
                arg.long_help(fmt_help(None, paths))
            }
        }),
        Err(_e) => cli,
    }
}

/// Main subcommands.
#[derive(Clone, Debug, Subcommand)]
pub(crate) enum Commands {
    /// Run the relay.
    Run(RunArgs),
    /// Print build information.
    BuildInfo,
}

/// Global arguments for all commands.
// NOTE: `global = true` should be set for each field
#[derive(Clone, Debug, Args)]
pub(crate) struct GlobalArgs {
    /// Override the log level from the configuration.
    #[arg(long, short, global = true)]
    #[arg(value_name = "LEVEL")]
    pub(crate) log_level: Option<LogLevel>,

    /// Don't check permissions on the files we use.
    #[arg(long, global = true)]
    pub(crate) disable_fs_permission_checks: bool,

    /// Override config file parameters, using TOML-like syntax.
    #[arg(long = "option", short, global = true)]
    #[arg(value_name = "KEY=VALUE")]
    pub(crate) options: Vec<String>,

    /// Config files and directories to read.
    // NOTE: We append the default config paths to the help text in `cli_cmd_post_processing`.
    // NOTE: This value does not take into account the default config paths,
    // so this is private while the `GlobalArgs::config()` method is public instead.
    #[arg(long, short, global = true)]
    #[arg(value_name = "PATH")]
    config: Vec<OsString>,
}

impl GlobalArgs {
    /// Get the configuration sources.
    ///
    /// You may also want to set a [`Mistrust`](fs_mistrust::Mistrust)
    /// and any additional configuration option overrides
    /// using [`push_option`](ConfigurationSources::push_option).
    pub(crate) fn config(&self) -> Result<ConfigurationSources, CfgPathError> {
        // Use `try_from_cmdline` to be consistent with Arti.
        let mut cfg_sources = ConfigurationSources::try_from_cmdline(
            || {
                Ok(DEFAULT_CONFIG_PATHS
                    .as_ref()
                    .map_err(Clone::clone)?
                    .iter()
                    .map(ConfigurationSource::from_path))
            },
            &self.config,
            &self.options,
        )?;

        // TODO: These text strings may become stale if the configuration structure changes,
        // and they're not checked at compile time.
        // Can we change `ConfigurationSources` in some way to allow overrides from an existing
        // builder?
        if self.disable_fs_permission_checks {
            cfg_sources.push_option("storage.permissions.dangerously_trust_everyone=true");
        }

        if let Some(log_level) = self.log_level {
            cfg_sources.push_option(format!("logging.console={log_level}"));
        }

        Ok(cfg_sources)
    }
}

/// Arguments when running an Arti relay.
#[derive(Clone, Debug, Args)]
pub(crate) struct RunArgs {}

/// Log levels allowed by the cli.
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum LogLevel {
    /// See [`tracing::Level::ERROR`].
    #[value(help = None)]
    Error,
    /// See [`tracing::Level::WARN`].
    #[value(help = None)]
    Warn,
    /// See [`tracing::Level::INFO`].
    #[value(help = None)]
    Info,
    /// See [`tracing::Level::DEBUG`].
    #[value(help = None)]
    Debug,
    /// See [`tracing::Level::TRACE`].
    #[value(help = None)]
    Trace,
}

impl std::fmt::Display for LogLevel {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Error => write!(f, "error"),
            Self::Warn => write!(f, "warn"),
            Self::Info => write!(f, "info"),
            Self::Debug => write!(f, "debug"),
            Self::Trace => write!(f, "trace"),
        }
    }
}

impl From<LogLevel> for tracing::metadata::Level {
    fn from(x: LogLevel) -> Self {
        match x {
            LogLevel::Error => Self::ERROR,
            LogLevel::Warn => Self::WARN,
            LogLevel::Info => Self::INFO,
            LogLevel::Debug => Self::DEBUG,
            LogLevel::Trace => Self::TRACE,
        }
    }
}

#[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::mixed_attributes_style)]
    #![allow(clippy::print_stderr)]
    #![allow(clippy::print_stdout)]
    #![allow(clippy::single_char_pattern)]
    #![allow(clippy::unwrap_used)]
    #![allow(clippy::unchecked_duration_subtraction)]
    #![allow(clippy::useless_vec)]
    #![allow(clippy::needless_pass_by_value)]
    //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->

    use super::*;

    #[test]
    fn common_flags() {
        Cli::parse_from(["arti-relay", "build-info"]);
        Cli::parse_from(["arti-relay", "run"]);

        let cli = Cli::parse_from(["arti-relay", "--log-level", "warn", "run"]);
        assert_eq!(cli.global.log_level, Some(LogLevel::Warn));
        let cli = Cli::parse_from(["arti-relay", "run", "--log-level", "warn"]);
        assert_eq!(cli.global.log_level, Some(LogLevel::Warn));

        let cli = Cli::parse_from(["arti-relay", "--disable-fs-permission-checks", "run"]);
        assert!(cli.global.disable_fs_permission_checks);
        let cli = Cli::parse_from(["arti-relay", "run", "--disable-fs-permission-checks"]);
        assert!(cli.global.disable_fs_permission_checks);
    }

    #[test]
    fn clap_bug() {
        let cli = Cli::parse_from(["arti-relay", "-o", "foo=1", "run"]);
        assert_eq!(cli.global.options, vec!["foo=1"]);

        let cli = Cli::parse_from(["arti-relay", "-o", "foo=1", "-o", "bar=2", "run"]);
        assert_eq!(cli.global.options, vec!["foo=1", "bar=2"]);

        // this is https://github.com/clap-rs/clap/issues/3938
        // TODO: this is a footgun, and we should consider alternatives to clap's 'global' args
        let cli = Cli::parse_from(["arti-relay", "-o", "foo=1", "run", "-o", "bar=2"]);
        assert_eq!(cli.global.options, vec!["bar=2"]);
    }

    #[test]
    fn global_args_are_global() {
        let cmd = Command::new("test");
        let cmd = GlobalArgs::augment_args(cmd);

        // check that each argument in `GlobalArgs` has "global" set
        for arg in cmd.get_arguments() {
            assert!(
                arg.is_global_set(),
                "'global' must be set for {:?}",
                arg.get_long()
            );
        }
    }
}