diff options
-rw-r--r-- | Cargo.lock | 17 | ||||
-rw-r--r-- | Cargo.toml | 1 | ||||
-rw-r--r-- | examples/config.json | 4 | ||||
-rw-r--r-- | flake.nix | 6 | ||||
-rw-r--r-- | src/config.rs | 143 |
5 files changed, 79 insertions, 92 deletions
@@ -261,6 +261,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "humantime-serde" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a3db5ea5923d99402c94e9feb261dc5ee9b4efa158b0315f788cf549cc200c" +dependencies = [ + "humantime", + "serde", +] + +[[package]] name = "hyper" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -688,6 +704,7 @@ version = "0.1.0" dependencies = [ "hmac", "http-body-util", + "humantime-serde", "hyper", "hyper-util", "lazy_static", @@ -14,3 +14,4 @@ nix = { version = "0.29.0", features = ["socket", "fs", "ioctl", "process", "net lazy_static = "1.5.0" serde = { version = "1.0.210", features = ["serde_derive"] } serde_json = "1.0.128" +humantime-serde = "1.1.1" diff --git a/examples/config.json b/examples/config.json index 6cbdade..ee0d890 100644 --- a/examples/config.json +++ b/examples/config.json @@ -1,5 +1,7 @@ { - "secret_file": "./examples/secret.txt", + "secret_path": "./examples/secret.txt", + + "max_idle_time": "1h", "commands": [ { @@ -132,7 +132,7 @@ }); }; - secret-file = mkOption { + secret-path = mkOption { description = "Path to file containing the secret given to GitHub."; type = types.path; example = "/run/github_secret.txt"; @@ -192,7 +192,7 @@ serviceConfig = let config = { - "secret_file" = cfg.secret-file; + "secret_path" = cfg.secret-path; "commands" = cfg.commands; }; @@ -250,7 +250,7 @@ ]; # The secret to be used when authenticating event's signature. - secret-file = toString (pkgs.writeText "secret.txt" "mysecret"); + secret-path = toString (pkgs.writeText "secret.txt" "mysecret"); }; environment.systemPackages = [ diff --git a/src/config.rs b/src/config.rs index e70043e..bddfb40 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,38 +3,52 @@ use std::fs::{self, File}; use std::io; use std::fmt::{self, Display}; use serde::Deserialize; +use std::time::Duration; /// All the application configuration is stored in this structure. -#[derive(PartialEq, Clone, Debug)] +#[derive(Deserialize, PartialEq, Clone, Debug)] pub struct Config { + /// Path to the file containing the GitHub secret. + pub secret_path: PathBuf, + /// The secret string shared with GitHub that is used to verify signed requests. + #[serde(skip_deserializing)] pub secret: String, /// Event-command pairs. Each element of this array should be matched (and optionally executed) /// against the commands in gaide. pub commands: Vec<Command>, + + /// The maximum time the server should spend sitting idle waiting for a connection before + /// shutting itself down. + /// + /// This is pretty relevant as webhook event are relatively rare. Shutting down and waiting for + /// socket (re)activation spares a few ressources. + #[serde(default)] + #[serde(with = "humantime_serde")] + pub max_idle_time: Option<Duration>, } impl Config { pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> { - let raw_config = RawConfig::from_path(path)?; - let secret = fs::read_to_string(raw_config.secret_file)?; - Ok(Config { - secret, - commands: raw_config.commands, - }) + let file = File::open(path.as_ref()).map_err(ConfigError::IoReadingConfig)?; + let mut config: Config = serde_json::from_reader(file)?; + + config.secret = fs::read_to_string(&config.secret_path) + .map_err(ConfigError::IoReadingSecret)?; + + Ok(config) } } -/// This struct reflects the actual JSON on disk. It is further processed before being returned to -/// the rest of the application. -#[derive(Deserialize, Clone, Debug, PartialEq)] -struct RawConfig { - /// Path to file containing the secret that was shared with GitHub. - secret_file: PathBuf, - - /// Event-command pairs. - commands: Vec<Command>, +impl From<serde_json::Error> for ConfigError { + fn from(e: serde_json::Error) -> ConfigError { + use serde_json::error::Category; + match e.classify() { + Category::Io => ConfigError::IoReadingConfig(e.into()), + _ => ConfigError::SerdeError(e), + } + } } /// Represents an event-command pair. The command is run whenever the given event is received from @@ -55,86 +69,32 @@ pub struct Command { pub args: Vec<String>, } -/* -/// Serde helper which disallows empty strings for [`PathBuf`s](std::path::PathBuf). Based on [this -/// StackOverflow post][so]. -/// -/// [so]: https://stackoverflow.com/a/46755370 -fn string_as_nonempty_pathbuf<'de, D>(deserializer: D) -> Result<PathBuf, D::Error> -where - D: Deserializer<'de> -{ - let raw: &str = Deserialize::deserialize(deserializer)?; - if raw.is_empty() { - Err(de::Error::custom("path cannot be empty")) - } else { - Ok(PathBuf::from(raw)) - } -} -*/ - /// Errors that can occur when reading configuration. #[derive(Debug)] pub enum ConfigError { - /// An IO error occured, such as failing to read the file. - Io(io::Error), + /// An IO error occured while reading the configuration, such as failing to read the file. + IoReadingConfig(io::Error), + /// An IO error occured while reading the secret file linked via `secret_path`. + IoReadingSecret(io::Error), /// Decoding the file failed, e.g. if JSON is missing comma. SerdeError(serde_json::Error), } -impl From<io::Error> for ConfigError { - fn from(e: io::Error) -> ConfigError { - ConfigError::Io(e) - } -} - -impl From<serde_json::Error> for ConfigError { - fn from(e: serde_json::Error) -> ConfigError { - use serde_json::error::Category; - match e.classify() { - Category::Io => ConfigError::Io(e.into()), - _ => ConfigError::SerdeError(e), - } - } -} - impl Display for ConfigError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> Result<(), fmt::Error> { match self { - ConfigError::Io(e) => write!(f, "io error: {}", e), + ConfigError::IoReadingConfig(e) => write!(f, "io error while reading configuration file: {}", e), + ConfigError::IoReadingSecret(e) => write!(f, "io error while reading secret file: {}", e), ConfigError::SerdeError(e) => write!(f, "decoding error: {}", e), } } } -impl RawConfig { - pub fn from_path<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> { - let file = File::open(path.as_ref())?; - let config: Self = serde_json::from_reader(file)?; - config.validate()?; - Ok(config) - } - - #[allow(dead_code)] // Useful for tests. - pub(self) fn from_str(s: &str) -> Result<Self, ConfigError> { - let config: Self = serde_json::from_str(s)?; - config.validate()?; - Ok(config) - } - - fn validate(&self) -> Result<(), ConfigError> { - if self.secret_file.is_relative() { - eprintln!("warning: configuration key `.secret_file` is relative path. This will be resolved relative to server's CWD at runtime which is most likely not what you want."); - // " <- Fix shitty Vim syntax highlighting - } - Ok(()) - } -} - #[cfg(test)] mod tests { - use super::{Config, Command, RawConfig, ConfigError}; - use std::path::Path; + use super::{Config, Command, ConfigError}; + use std::path::{Path, PathBuf}; + use std::time::Duration; macro_rules! assert_matches { ( $e:expr , $pat:pat ) => { @@ -161,10 +121,12 @@ mod tests { } #[test] - fn load_valid_raw_config() { + fn deserialize_valid_config() { let config_json = r#" { - "secret_file": "/path/to/secret.txt", + "secret_path": "/path/to/secret.txt", + + "max_idle_time": "10min", "commands": [ { @@ -175,9 +137,11 @@ mod tests { ] } "#; - let parsed_config = RawConfig::from_str(config_json).expect("valid config"); - let expected_config = RawConfig { - secret_file: Path::new("/path/to/secret.txt").to_path_buf(), + let parsed_config = serde_json::from_str::<Config>(config_json).expect("valid config"); + let expected_config = Config { + secret_path: Path::new("/path/to/secret.txt").to_path_buf(), + secret: "".to_string(), // We didn't ask it to read file + max_idle_time: Some(Duration::from_secs(600)), commands: vec![ Command { event: "ping".to_string(), @@ -190,7 +154,7 @@ mod tests { } #[test] - fn args_are_optional() { + fn deserialize_command_without_optional_args() { let command_json = r#" { "event": "ping", @@ -208,15 +172,16 @@ mod tests { } #[test] - fn invalid_json_gives_error() { + fn deserialize_invalid_json_gives_error() { // This JSON has a trailing comma, which isn't allowed. let config_json = r#" { - "secret_file": "blah", + "secret_path": "blah", "commands": [], } "#; - let result = RawConfig::from_str(config_json); + // This way we also test the error wrapping code in our implementation of `std::convert::from::From`. + let result: Result<Config, ConfigError> = serde_json::from_str::<Config>(config_json).map_err(|e| e.into()); let err = assert_matches!(result, Err(ConfigError::SerdeError(e)) => e); assert_eq!(err.line(), 5); assert_eq!(err.column(), 13); @@ -228,7 +193,9 @@ mod tests { let parse_result = Config::from_path("examples/config.json"); let parsed_config = assert_matches!(parse_result, Ok(c @ Config { .. }) => c); let expected_config = Config { + secret_path: PathBuf::from("./examples/secret.txt"), secret: "mysecret".to_string(), + max_idle_time: Some(Duration::from_secs(60 * 60)), commands: vec![ Command { event: "ping".to_string(), |