diff --git a/Cargo.lock b/Cargo.lock index 105d17a..e8df6c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,6 +325,15 @@ dependencies = [ "syn 2.0.58", ] +[[package]] +name = "atomic" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d818003e740b63afc82337e3160717f4f63078720a810b7b903e70a5d1d2994" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -424,6 +433,12 @@ version = "3.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" +[[package]] +name = "bytemuck" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d6d68c57235a3a081186990eca2867354726650f42f7516ca50c28d6281fd15" + [[package]] name = "bytes" version = "1.6.0" @@ -794,6 +809,19 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984" +[[package]] +name = "figment" +version = "0.10.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdefe49ed1057d124dc81a0681c30dd07de56ad96e32adc7b64e8f28eaab31c4" +dependencies = [ + "atomic", + "serde", + "toml", + "uncased", + "version_check", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1026,7 +1054,9 @@ dependencies = [ "clap", "codspeed-criterion-compat", "criterion", + "figment", "httpmock", + "indoc", "reqwest", "rstest", "serde", @@ -1270,6 +1300,12 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + [[package]] name = "instant" version = "0.1.12" @@ -2081,6 +2117,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -2348,6 +2393,40 @@ dependencies = [ "tracing", ] +[[package]] +name = "toml" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9dd1545e8208b4a5af1aa9bbd0b4cf7e9ea08fabc5d0a5c67fcaafa17433aa3" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e40bb779c5187258fd7aad0eb68cb8706a0a81fa712fbea808ab43c4b8374c4" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "winnow", +] + [[package]] name = "tower" version = "0.4.13" @@ -2402,6 +2481,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -2458,6 +2546,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + [[package]] name = "waker-fn" version = "1.1.1" @@ -2737,6 +2831,15 @@ version = "0.52.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" +[[package]] +name = "winnow" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c976aaaa0e1f90dbb21e9587cdaf1d9679a1cde8875c0d6bd83ab96a208352" +dependencies = [ + "memchr", +] + [[package]] name = "winreg" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 31b6ba7..b43403e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,8 @@ edition = "2021" [dependencies] async-trait = "0.1.79" chrono = "0.4.37" -clap = { version = "4.5.4", features = ["derive"] } +clap = { version = "4.5.4", features = ["derive", "env", "string"] } +figment = { version = "0.10.16", features = ["toml"] } reqwest = { version = "0.12.2", features = ["json"] } serde = { version = "1.0.197", features = ["derive"] } tokio = { version = "1.37.0", features = ["full"] } @@ -17,9 +18,14 @@ tokio = { version = "1.37.0", features = ["full"] } codspeed-criterion-compat = "2.4.1" criterion = "0.5.1" httpmock = "0.7.0" +indoc = "2.0.5" rstest = "0.19.0" tempfile = "3.10.1" [[bench]] name = "write_allowed_signers" harness = false + +[[bench]] +name = "load_configuration" +harness = false diff --git a/README.md b/README.md new file mode 100644 index 0000000..afe1914 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# Example Configuration + +```toml +users = [ + { name = "torvalds", sources = ["github"] }, + { name = "gvanrossum", sources = ["github", "gitlab"] }, + { name = "graydon", sources = ["github"] }, + { name = "cwoods", sources = ["acme-corp"] }, + { name = "rdavis", sources = ["acme-corp"] }, + { name = "pbrock", sources = ["acme-corp"] } +] +organizations = [ + { name = "rust-lang", sources = ["github"] } +] +local = [ + "jdoe@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJHDGMF+tZQL3dcr1arPst+YP8v33Is0kAJVvyTKrxMw" +] + +[[sources]] +name = "acme-corp" +provider = "gitlab" +url = "https://git.acme.corp" +``` diff --git a/benches/load_configuration.rs b/benches/load_configuration.rs new file mode 100644 index 0000000..ceb26f0 --- /dev/null +++ b/benches/load_configuration.rs @@ -0,0 +1,38 @@ +use codspeed_criterion_compat::{criterion_group, criterion_main, Criterion}; +use hanko::Config; +use indoc::indoc; +use std::{io::Write, path::Path}; + +pub fn criterion_benchmark(c: &mut Criterion) { + let toml = indoc! {r#" + users = [ + { name = "torvalds", sources = ["github"] }, + { name = "gvanrossum", sources = ["github", "gitlab"] }, + { name = "graydon", sources = ["github"] }, + { name = "cwoods", sources = ["acme-corp"] }, + { name = "rdavis", sources = ["acme-corp"] }, + { name = "pbrock", sources = ["acme-corp"] } + ] + organizations = [ + { name = "rust-lang", sources = ["github"] } + ] + local = [ + "jdoe@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJHDGMF+tZQL3dcr1arPst+YP8v33Is0kAJVvyTKrxMw" + ] + + [[sources]] + name = "acme-corp" + provider = "gitlab" + url = "https://git.acme.corp" + "#}; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(toml.as_bytes()).unwrap(); + let path: &Path = &file.into_temp_path(); + + c.bench_function("load the example configuration", |b| { + b.iter(|| Config::load(path).unwrap()); + }); +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); diff --git a/src/cli/main.rs b/src/cli/main.rs index 866be38..c8ff64c 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -1,13 +1,22 @@ use super::{manage_signers::ManageSigners, manage_sources::ManageSources}; -use clap::{Args, Parser, Subcommand}; -use std::path::PathBuf; +use clap::{ + builder::{OsStr, Resettable}, + Args, Parser, Subcommand, +}; +use std::{env, path::PathBuf}; #[derive(Parser)] #[command(version, about, long_about = None)] pub struct Cli { /// The path to the configuration file. - #[arg(short, long, value_name = "FILE")] - config: Option, + #[arg( + short, + long, + value_name = "FILE", + env = "HANKO_CONFIG", + default_value = default_config_path() + )] + pub config: PathBuf, #[command(flatten)] logging: Logging, @@ -42,6 +51,21 @@ enum Commands { Source(ManageSources), } +/// The default configuration file path according to the XDG Base Directory Specification. +/// If neither `$XDG_CONFIG_HOME` nor `$HOME` are set, `Resettable::Reset` is returned, forcing the user to specify the path. +fn default_config_path() -> Resettable { + let dirname = env!("CARGO_PKG_NAME"); + let filename = "config.toml"; + + if let Ok(xdg_config_home) = env::var("XDG_CONFIG_HOME") { + Resettable::Value(format!("{}/{}/{}", xdg_config_home, dirname, filename).into()) + } else if let Ok(home) = env::var("HOME") { + Resettable::Value(format!("{}/.config/{}/{}", home, dirname, filename).into()) + } else { + Resettable::Reset + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..fdace70 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,165 @@ +use crate::GitProvider; +use figment::{ + providers::{Format, Serialized, Toml}, + Figment, +}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// The main configuration. +#[derive(Debug, Deserialize, Serialize, PartialEq)] +pub struct Config { + users: Option>, + organizations: Option>, + local: Option>, + sources: Option>, +} + +impl Default for Config { + /// The default configuration containing common sources. + fn default() -> Self { + Config { + users: None, + organizations: None, + local: None, + sources: Some(vec![ + Source { + name: "github".to_string(), + provider: GitProvider::Github, + url: "https://api.github.com".to_string(), + }, + Source { + name: "gitlab".to_string(), + provider: GitProvider::Gitlab, + url: "https://gitlab.com".to_string(), + }, + ]), + } + } +} + +impl Config { + /// Load the configuration from a TOML file at the given path. + pub fn load(path: &Path) -> figment::Result { + Figment::from(Serialized::defaults(Config::default())) + .admerge(Toml::file(path)) + .extract() + } + + /// Create the configuration from a TOML string. + fn from_toml(toml: &str) -> figment::Result { + Figment::from(Serialized::defaults(Config::default())) + .admerge(Toml::string(toml)) + .extract() + } + + /// Save the configuration. + fn save(&self) -> Result<(), Box> { + todo!("Save the configuration while preserving formatting."); + } +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +struct User { + name: String, + sources: Vec, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +struct Organization { + name: String, + sources: Vec, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq)] +struct Source { + name: String, + provider: GitProvider, + url: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use indoc::indoc; + + #[test] + fn example_config() { + let toml = indoc! {r#" + users = [ + { name = "torvalds", sources = ["github"] }, + { name = "gvanrossum", sources = ["github", "gitlab"] }, + { name = "graydon", sources = ["github"] }, + { name = "cwoods", sources = ["acme-corp"] }, + { name = "rdavis", sources = ["acme-corp"] }, + { name = "pbrock", sources = ["acme-corp"] } + ] + organizations = [ + { name = "rust-lang", sources = ["github"] } + ] + local = [ + "jdoe@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJHDGMF+tZQL3dcr1arPst+YP8v33Is0kAJVvyTKrxMw" + ] + + [[sources]] + name = "acme-corp" + provider = "gitlab" + url = "https://git.acme.corp" + "#}; + let expected = Config { + users: Some(vec![ + User { + name: "torvalds".to_string(), + sources: vec!["github".to_string()], + }, + User { + name: "gvanrossum".to_string(), + sources: vec!["github".to_string(), "gitlab".to_string()], + }, + User { + name: "graydon".to_string(), + sources: vec!["github".to_string()], + }, + User { + name: "cwoods".to_string(), + sources: vec!["acme-corp".to_string()], + }, + User { + name: "rdavis".to_string(), + sources: vec!["acme-corp".to_string()], + }, + User { + name: "pbrock".to_string(), + sources: vec!["acme-corp".to_string()], + }, + ]), + organizations: Some(vec![ + Organization { + name: "rust-lang".to_string(), + sources: vec!["github".to_string()], + } + ]), + local: Some(vec!["jdoe@example.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJHDGMF+tZQL3dcr1arPst+YP8v33Is0kAJVvyTKrxMw".parse().unwrap()]), + sources: Some(vec![ + Source { + name: "github".to_string(), + provider: GitProvider::Github, + url: "https://api.github.com".to_string(), + }, + Source { + name: "gitlab".to_string(), + provider: GitProvider::Gitlab, + url: "https://gitlab.com".to_string(), + }, + Source { + name: "acme-corp".to_string(), + provider: GitProvider::Gitlab, + url: "https://git.acme.corp".to_string(), + }, + ]) + }; + + let config = Config::from_toml(toml).unwrap(); + assert_eq!(config, expected); + } +} diff --git a/src/core.rs b/src/core.rs index 4f1d385..3b93b8c 100644 --- a/src/core.rs +++ b/src/core.rs @@ -4,7 +4,7 @@ use std::{fmt, str::FromStr}; pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION")); -#[derive(Debug, Deserialize, PartialEq, Eq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] pub struct SshPublicKey { key: String, } @@ -32,7 +32,8 @@ pub trait GetPublicKeys { } /// A Git provider. -#[derive(Debug, Clone, Copy, Deserialize, Serialize, clap::ValueEnum)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, clap::ValueEnum)] +#[serde(rename_all = "lowercase")] pub enum GitProvider { Github, Gitlab, diff --git a/src/lib.rs b/src/lib.rs index dd2d969..1fa6c54 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,10 @@ pub use allowed_signers::{AllowedSigner, AllowedSignersFile}; +pub use config::Config; pub use core::*; mod allowed_signers; pub mod cli; +mod config; mod core; mod github; mod gitlab;