use crate::{Github, Gitlab, Protocol, Source, allowed_signers::Signer, parent_dir};
use anyhow::{Context, Error, Result, bail};
use reqwest::Url;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::{
collections::{HashMap, HashSet},
fs,
io::{self, Write},
path::{Path, PathBuf},
sync::Arc,
};
use tempfile::NamedTempFile;
use tracing::{info, trace};
#[derive(Debug, Default)]
struct TomlFile {
path: PathBuf,
document: toml_edit::DocumentMut,
}
impl TomlFile {
fn add_signer(&mut self, signer: &SignerConfiguration) {
use toml_edit::{ArrayOfTables, Item, Value};
let table = toml_edit::ser::to_document(signer)
.expect("SignerConfiguration is always serializable")
.as_table()
.clone();
match self.document.get_mut("signers") {
None => {
let mut item = ArrayOfTables::new();
item.push(table);
self.document.insert("signers", Item::ArrayOfTables(item));
}
Some(Item::Value(Value::Array(a))) if a.iter().all(Value::is_inline_table) => {
a.push(table.into_inline_table());
}
Some(Item::ArrayOfTables(a)) => a.push(table),
_ => unreachable!("signers key has invalid format"),
}
}
fn load(path: PathBuf) -> Result<Self> {
info!("Loading TOML configuration file");
let content = fs::read_to_string(&path)?;
let document = content.parse()?;
Ok(Self { path, document })
}
fn save(&self) -> Result<()> {
info!("Saving TOML configuration file");
let dir = parent_dir(&self.path)?;
let mut file = NamedTempFile::new_in(dir)?;
write!(file, "{}", self.document)?;
file.persist(&self.path)?;
Ok(())
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Configuration {
signers: Vec<SignerConfiguration>,
sources: Vec<SourceConfiguration>,
#[serde(skip)]
file: TomlFile,
}
impl TryFrom<TomlFile> for Configuration {
type Error = Error;
fn try_from(file: TomlFile) -> Result<Self> {
let deserializer = toml_edit::de::Deserializer::from(file.document.clone());
let mut s = Self::deserialize(deserializer)?;
s.file = file;
Ok(s)
}
}
type NamedSources = HashMap<String, Arc<dyn Source>>;
impl Configuration {
fn default_sources() -> Vec<SourceConfiguration> {
vec![
SourceConfiguration {
name: "github".to_string(),
provider: SourceType::Github,
url: "https://api.github.com".parse().unwrap(),
protocol: Protocol::Http2,
},
SourceConfiguration {
name: "gitlab".to_string(),
provider: SourceType::Gitlab,
url: "https://gitlab.com".parse().unwrap(),
protocol: Protocol::Http2,
},
]
}
fn all_source_configurations(&self) -> Vec<SourceConfiguration> {
Self::default_sources()
.into_iter()
.chain(self.sources.iter().cloned())
.collect()
}
#[must_use]
pub fn sources(&self) -> NamedSources {
self.all_source_configurations()
.into_iter()
.map(|c| {
let source = Arc::from(c.build_source());
(c.name, source)
})
.collect()
}
pub fn add_signer(
&mut self,
name: String,
principals: Vec<String>,
source_names: Vec<String>,
) -> Result<bool> {
let signer = SignerConfiguration {
name,
principals,
source_names,
};
self.check_sources_exist(signer.source_names.iter().map(String::as_str))?;
if self.check_signer_already_exists(&signer)? {
return Ok(false);
}
self.file.add_signer(&signer);
self.signers.push(signer);
Ok(true)
}
#[must_use]
pub fn signers(&self, sources: &NamedSources) -> Vec<Signer> {
let configs = &self.signers;
configs
.iter()
.map(|c| {
Signer {
name: c.name.clone(),
principals: c.principals.clone(),
sources: c
.source_names
.iter()
.map(|name| {
sources
.get(name)
.expect("signer references source that does not exist, config not validated correctly")
.clone()
})
.collect(),
}
})
.collect()
}
#[tracing::instrument]
pub fn load(path: &Path) -> Result<Self> {
let file = TomlFile::load(path.to_path_buf())?;
let c = Self::try_from(file)?;
c.validate_semantics()?;
Ok(c)
}
pub fn load_or_default(path: &Path) -> Result<Self> {
Self::load(path).or_else(|err| match err.downcast_ref::<io::Error>() {
Some(io_err) if io_err.kind() == io::ErrorKind::NotFound => {
info!("Configuration file does not exist yet and will be created");
let dir = parent_dir(path)?;
fs::create_dir_all(dir).context(format!(
"Failed to create configuration directory {}",
dir.display()
))?;
Ok(Configuration {
file: TomlFile {
path: path.to_path_buf(),
..Default::default()
},
..Default::default()
})
}
_ => Err(err),
})
}
pub fn save(&self) -> Result<()> {
self.file.save()
}
fn validate_semantics(&self) -> Result<()> {
trace!(?self, "Validating configuration semantics");
self.check_no_sources_conflict_w_default()?;
self.check_sources_exist(
self.signers
.iter()
.flat_map(|c| c.source_names.iter().map(String::as_str)),
)?;
self.check_signers_have_one_or_more_principals()?;
Ok(())
}
fn check_sources_exist<'a>(
&self,
source_names: impl IntoIterator<Item = &'a str>,
) -> Result<()> {
let a = self.all_source_configurations();
let existing_sources: HashSet<&str> = a.iter().map(|c| c.name.as_str()).collect();
let mut missing_sources: Vec<&str> = source_names
.into_iter()
.filter(|name| !existing_sources.contains(name))
.collect();
if !missing_sources.is_empty() {
missing_sources.sort_unstable();
bail!("Missing sources: {}", missing_sources.join(", "))
}
Ok(())
}
fn check_signer_already_exists(&self, signer: &SignerConfiguration) -> Result<bool> {
if let Some(existing) = self.signers.iter().find(|s| s.name == signer.name) {
if existing == signer {
return Ok(true);
}
bail!(
"Signer {} already exists with different attributes, please update the configuration manually",
signer.name
);
}
Ok(false)
}
fn check_no_sources_conflict_w_default(&self) -> Result<()> {
let d = Self::default_sources();
let reserved: HashSet<&str> = d.iter().map(|s| s.name.as_str()).collect();
for source in &self.sources {
if reserved.contains(source.name.as_str()) {
bail!(
"\"{}\" is a built-in source name and cannot be redefined in configuration",
source.name
);
}
}
Ok(())
}
fn check_signers_have_one_or_more_principals(&self) -> Result<()> {
for config in &self.signers {
if config.principals.is_empty() {
bail!("Signer {} missing principals", config.name)
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
pub enum SourceType {
Github,
Gitlab,
}
#[must_use]
pub fn default_user_source() -> Vec<String> {
vec!["github".to_string()]
}
fn is_default_sources(sources: &[String]) -> bool {
sources == default_user_source()
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(default, deny_unknown_fields)]
pub struct SignerConfiguration {
pub name: String,
pub principals: Vec<String>,
#[serde(rename = "sources", skip_serializing_if = "is_default_sources")]
pub source_names: Vec<String>,
}
impl Default for SignerConfiguration {
fn default() -> Self {
Self {
name: String::default(),
principals: Vec::default(),
source_names: default_user_source(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
#[serde(deny_unknown_fields)]
struct SourceConfiguration {
name: String,
provider: SourceType,
#[serde(serialize_with = "serialize_url", deserialize_with = "deserialize_url")]
url: Url,
#[serde(default)]
protocol: Protocol,
}
fn deserialize_url<'de, D>(deserializer: D) -> Result<Url, D::Error>
where
D: Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
let url = reqwest::Url::parse(&s).map_err(serde::de::Error::custom)?;
Ok(url)
}
fn serialize_url<U, S>(url: U, serializer: S) -> Result<S::Ok, S::Error>
where
U: AsRef<str>,
S: Serializer,
{
serializer.serialize_str(url.as_ref())
}
impl SourceConfiguration {
fn build_source(&self) -> Box<dyn Source> {
let url = self.url.clone();
match self.provider {
SourceType::Github => Box::new(Github::new(url, self.protocol)),
SourceType::Gitlab => Box::new(Gitlab::new(url, self.protocol)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use indoc::indoc;
use rstest::*;
use std::io::Write;
use tempfile::{NamedTempFile, TempDir};
#[fixture]
fn tmp_config_toml() -> NamedTempFile {
tempfile::Builder::new()
.prefix("config")
.suffix(".toml")
.tempfile()
.unwrap()
}
#[rstest]
#[case(
indoc!{r#"
signers = [
{ name = "torvalds", principals = ["torvalds@linux-foundation.org"], sources = ["github"] },
]
"#}
)]
fn loaded_configuration_has_default_sources(
mut tmp_config_toml: NamedTempFile,
#[case] config: &str,
) {
writeln!(tmp_config_toml, "{config}").unwrap();
let config = Configuration::load(tmp_config_toml.path()).unwrap();
for default_source in Configuration::default_sources() {
assert!(config.sources().contains_key(&default_source.name));
}
}
#[rstest]
fn loading_non_existent_configuration_returns_error() {
let tmpdir = TempDir::new().unwrap();
let path = tmpdir.path().join("config.toml");
assert!(!path.exists());
let err = Configuration::load(&path).unwrap_err();
assert_eq!(
err.downcast_ref::<io::Error>().unwrap().kind(),
io::ErrorKind::NotFound
);
}
#[rstest]
#[case(
indoc!{r#"
[[sources]]
name = "github"
provider = "github"
url = "https://github.example.com"
"#},
"\"github\" is a built-in source name and cannot be redefined in configuration"
)]
#[case(
indoc!{r#"
[[sources]]
name = "gitlab"
provider = "gitlab"
url = "https://gitlab.example.com"
"#},
"\"gitlab\" is a built-in source name and cannot be redefined in configuration"
)]
fn loading_configuration_with_reserved_source_name_returns_error(
mut tmp_config_toml: NamedTempFile,
#[case] config: &str,
#[case] expected_msg: &str,
) {
writeln!(tmp_config_toml, "{config}").unwrap();
let err = Configuration::load(tmp_config_toml.path()).unwrap_err();
assert_eq!(err.to_string(), expected_msg);
}
#[rstest]
#[case(
indoc!{r#"
signers = [
{ name = "cwoods", principals = ["cwoods@acme.corp"], sources = ["acme-corp"] },
{ name = "rdavis", principals = ["rdavis@lumon.industries"], sources = ["lumon-industries"] }
]
[[sources]]
name = "acme-corp"
provider = "gitlab"
url = "https://git.acme.corp"
"#},
vec!["lumon-industries".to_string()]
)]
#[case(
indoc!{r#"
signers = [
{ name = "cwoods", principals = ["cwoods@acme.corp"], sources = ["acme-corp"] },
{ name = "rdavis", principals = ["rdavis@lumon.industries"], sources = ["lumon-industries"] }
]
"#},
vec!["acme-corp".to_string(), "lumon-industries".to_string()]
)]
fn loading_configuration_with_missing_source_returns_error(
mut tmp_config_toml: NamedTempFile,
#[case] config: &str,
#[case] mut expected_missing: Vec<String>,
) {
expected_missing.sort();
writeln!(tmp_config_toml, "{config}").unwrap();
let err = Configuration::load(tmp_config_toml.path()).unwrap_err();
assert_eq!(
err.to_string(),
format!("Missing sources: {}", expected_missing.join(", "))
);
}
#[rstest]
#[case(
indoc!{r#"
[[signers]]
name = "octocat"
"#},
)]
#[case(
indoc!{r#"
[[signers]]
name = "octocat"
principals = []
"#},
)]
fn loading_configuration_with_signer_missing_principal_returns_error(
mut tmp_config_toml: NamedTempFile,
#[case] config: &str,
) {
writeln!(tmp_config_toml, "{config}").unwrap();
let err = Configuration::load(tmp_config_toml.path()).unwrap_err();
assert_eq!(err.to_string(), "Signer octocat missing principals");
}
#[rstest]
#[case(
indoc!{r#"
[[signers]]
name = "cwoods"
principals = ["cwoods@acme.corp"]
nonsense = ["acme-corp"]
[[sources]]
name = "acme-corp"
provider = "gitlab"
url = "https://git.acme.corp"
"#},
"unknown field `nonsense`"
)]
fn loading_configuration_with_unknown_field_returns_error(
mut tmp_config_toml: NamedTempFile,
#[case] config: &str,
#[case] expected_msg: &str,
) {
writeln!(tmp_config_toml, "{config}").unwrap();
let err = Configuration::load(tmp_config_toml.path()).unwrap_err();
assert!(err.to_string().contains(expected_msg));
}
#[rstest]
#[case(
indoc! {r#"
signers = [
{ name = "torvalds", principals = ["torvalds@linux-foundation.org"] },
]
"#}
)]
fn signers_have_default_github_source(
mut tmp_config_toml: NamedTempFile,
#[case] config: &str,
) {
writeln!(tmp_config_toml, "{config}").unwrap();
let mut config = Configuration::load(tmp_config_toml.path()).unwrap();
let signer_sources = config.signers.pop().unwrap().source_names;
assert_eq!(signer_sources, vec!["github"]);
}
#[rstest]
#[case(
indoc! {r#"
[[signers]]
name = "octocat"
principals = ["octocat@github.com"]
"#}
)]
#[case(
indoc! {r#"
signers = [
{ name = "torvalds", principals = ["torvalds@linux-foundation.org"] },
]
"#}
)]
fn saving_configuration_preserves_formatting(
mut tmp_config_toml: NamedTempFile,
#[case] content: &str,
) {
write!(tmp_config_toml, "{content}").unwrap();
let config = Configuration::load(tmp_config_toml.path()).unwrap();
tmp_config_toml.as_file().set_len(0).unwrap();
config.save().unwrap();
let result = fs::read_to_string(tmp_config_toml.path()).unwrap();
assert_eq!(result, content);
}
#[rstest]
#[case(
SignerConfiguration {
name: "octocat".to_string(),
principals: vec!["octocat@github.com".to_string()],
..Default::default()
}
)]
fn adding_signer_adds_to_signers(#[case] signer: SignerConfiguration) {
let mut config = Configuration::default();
assert!(
config
.add_signer(
signer.name.clone(),
signer.principals.clone(),
signer.source_names.clone(),
)
.unwrap()
);
assert!(config.signers.contains(&signer));
}
#[rstest]
#[case(
"",
SignerConfiguration {
name: "octocat".to_string(),
principals: vec!["octocat@github.com".to_string()],
..Default::default()
},
indoc! {r#"
[[signers]]
name = "octocat"
principals = ["octocat@github.com"]
"#},
)]
#[case(
indoc! {r#"
[[signers]]
name = "torvalds"
principals = ["torvalds@linux-foundation.org"]
"#},
SignerConfiguration {
name: "octocat".to_string(),
principals: vec!["octocat@github.com".to_string()],
..Default::default()
},
indoc! {r#"
[[signers]]
name = "torvalds"
principals = ["torvalds@linux-foundation.org"]
[[signers]]
name = "octocat"
principals = ["octocat@github.com"]
"#},
)]
#[case(
indoc! {r#"
[[signers]]
name = "torvalds"
principals = ["torvalds@linux-foundation.org"]
[[sources]]
name = "acme-corp"
provider = "gitlab"
url = "https://git.acme.corp"
"#},
SignerConfiguration {
name: "octocat".to_string(),
principals: vec!["octocat@github.com".to_string()],
source_names: vec!["acme-corp".to_string()],
},
indoc! {r#"
[[signers]]
name = "torvalds"
principals = ["torvalds@linux-foundation.org"]
[[signers]]
name = "octocat"
principals = ["octocat@github.com"]
sources = ["acme-corp"]
[[sources]]
name = "acme-corp"
provider = "gitlab"
url = "https://git.acme.corp"
"#},
)]
#[case(
indoc! {r#"
signers = [
{ name = "torvalds", principals = ["torvalds@linux-foundation.org"] },
{ name = "cwoods", principals = ["cwoods@acme.corp"] },
]
"#},
SignerConfiguration {
name: "octocat".to_string(),
principals: vec!["octocat@github.com".to_string()],
..Default::default()
},
indoc! {r#"
signers = [
{ name = "torvalds", principals = ["torvalds@linux-foundation.org"] },
{ name = "cwoods", principals = ["cwoods@acme.corp"] }, { name = "octocat", principals = ["octocat@github.com"] },
]
"#},
)]
fn adding_signer_adds_to_file(
#[case] toml: &str,
#[case] signer: SignerConfiguration,
#[case] expected: &str,
) {
let mut config = Configuration::try_from(TomlFile {
document: toml.parse().unwrap(),
..Default::default()
})
.unwrap();
assert!(
config
.add_signer(signer.name, signer.principals, signer.source_names)
.unwrap()
);
assert_eq!(config.file.document.to_string(), expected);
}
#[rstest]
#[case(
Configuration::default(),
SignerConfiguration {
name: "cwoods".to_string(),
principals: vec!["cwoods@acme.corp".to_string()],
source_names: vec!["acme-corp".to_string()],
},
vec!["acme-corp".to_string()]
)]
fn adding_signer_with_missing_source_returns_error(
#[case] mut config: Configuration,
#[case] signer: SignerConfiguration,
#[case] mut expected_missing: Vec<String>,
) {
expected_missing.sort();
let err = config
.add_signer(signer.name, signer.principals, signer.source_names)
.unwrap_err();
assert_eq!(
err.to_string(),
format!("Missing sources: {}", expected_missing.join(", "))
);
}
}