diff --git a/Cargo.lock b/Cargo.lock index 15d8120c..81903cee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1603,9 +1603,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.64" +version = "1.0.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +checksum = "336b10da19a12ad094b59d870ebde26a45402e5b470add4b5fd03c5048a32127" dependencies = [ "itoa", "ryu", @@ -2074,6 +2074,7 @@ dependencies = [ "hyper", "hyper-tls", "itertools", + "serde_json", "structopt", "tokio", "tracing", @@ -2105,6 +2106,8 @@ dependencies = [ "semver 0.10.0", "serde", "serde_derive", + "serde_json", + "tempfile", "thiserror", "tokio", "toml", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 28aa1271..19c36f76 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -21,6 +21,7 @@ tracing-futures = "0.2.5" hyper = {version = "0.14.0", features = ["full"]} hyper-tls = "0.5.0" wat = "1.0.38" +serde_json = "1.0.66" [dev-dependencies] anyhow = "1.0.31" diff --git a/cli/src/main.rs b/cli/src/main.rs index 66b634e3..8bef7153 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -43,10 +43,12 @@ pub async fn serve(opts: Opts) -> Result<(), Error> { if let Some(config_path) = opts.config_path() { let config = FastlyConfig::from_file(config_path)?; let backends = config.backends(); + let dictionaries = config.dictionaries(); let backend_names = itertools::join(backends.keys(), ", "); ctx = ctx .with_backends(backends.clone()) + .with_dictionaries(dictionaries.clone()) .with_config_path(config_path.into()); if backend_names.is_empty() { diff --git a/cli/tests/trap-test/Cargo.lock b/cli/tests/trap-test/Cargo.lock index 479a69b7..45171146 100644 --- a/cli/tests/trap-test/Cargo.lock +++ b/cli/tests/trap-test/Cargo.lock @@ -1389,6 +1389,12 @@ dependencies = [ "semver 1.0.3", ] +[[package]] +name = "ryu" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" + [[package]] name = "schannel" version = "0.1.19" @@ -1489,6 +1495,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "336b10da19a12ad094b59d870ebde26a45402e5b470add4b5fd03c5048a32127" +dependencies = [ + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha2" version = "0.9.5" @@ -1863,6 +1880,7 @@ dependencies = [ "semver 0.10.0", "serde", "serde_derive", + "serde_json", "thiserror", "tokio", "toml", diff --git a/lib/Cargo.toml b/lib/Cargo.toml index b47934c7..97c6b148 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -30,6 +30,7 @@ regex = "1.3.9" semver = "0.10.0" serde = "1.0.114" serde_derive = "1.0.114" +serde_json = "1.0.59" thiserror = "1.0.20" tokio = {version = "1.2", features = ["full"]} toml = "0.5.6" @@ -41,6 +42,9 @@ wasmtime = "0.29.0" wasmtime-wasi = {version = "0.29.0", features = ["tokio"]} wiggle = {version = "0.29.0", features = ["wasmtime_async"]} +[dev-dependencies] +tempfile = "3.2.0" + [features] default = [] test-fatalerror-config = [] diff --git a/lib/src/config.rs b/lib/src/config.rs index 20ec96c1..419f25a1 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -1,7 +1,7 @@ //! Fastly-specific configuration utilities. use { - self::backends::BackendsConfig, + self::{backends::BackendsConfig, dictionaries::DictionariesConfig}, crate::error::FastlyConfigError, serde_derive::Deserialize, std::{collections::HashMap, convert::TryInto, fs, path::Path, str::FromStr, sync::Arc}, @@ -12,6 +12,15 @@ use { #[cfg(test)] mod unit_tests; +/// Fastly limits +mod limits; + +/// Types and deserializers for dictionaries configuration settings. +mod dictionaries; +pub use self::dictionaries::Dictionary; +pub use self::dictionaries::DictionaryName; +pub type Dictionaries = HashMap; + /// Types and deserializers for backend configuration settings. mod backends; pub use self::backends::Backend; @@ -55,6 +64,11 @@ impl FastlyConfig { &self.local_server.backends.0 } + /// Get the dictionaries configuration. + pub fn dictionaries(&self) -> &Dictionaries { + &self.local_server.dictionaries.0 + } + /// Parse a `fastly.toml` file into a `FastlyConfig`. pub fn from_file(path: impl AsRef) -> Result { fs::read_to_string(path.as_ref()) @@ -130,6 +144,7 @@ impl TryInto for TomlFastlyConfig { #[derive(Clone, Debug, Default)] pub struct LocalServerConfig { backends: BackendsConfig, + dictionaries: DictionariesConfig, } /// Internal deserializer used to read the `[testing]` section of a `fastly.toml` file. @@ -138,15 +153,31 @@ pub struct LocalServerConfig { /// a [`LocalServerConfig`] with [`TryInto::try_into`]. #[derive(Deserialize)] struct RawLocalServerConfig { - backends: Table, + backends: Option, + dictionaries: Option
, } impl TryInto for RawLocalServerConfig { type Error = FastlyConfigError; fn try_into(self) -> Result { - let Self { backends } = self; - backends - .try_into() - .map(|backends| LocalServerConfig { backends }) + let Self { + backends, + dictionaries, + } = self; + let backends = if let Some(backends) = backends { + backends.try_into()? + } else { + BackendsConfig::default() + }; + let dictionaries = if let Some(dictionaries) = dictionaries { + dictionaries.try_into()? + } else { + DictionariesConfig::default() + }; + + Ok(LocalServerConfig { + backends, + dictionaries, + }) } } diff --git a/lib/src/config/dictionaries.rs b/lib/src/config/dictionaries.rs new file mode 100644 index 00000000..b323bcf8 --- /dev/null +++ b/lib/src/config/dictionaries.rs @@ -0,0 +1,222 @@ +use std::{collections::HashMap, fmt, path::PathBuf}; + +#[derive(Clone, Debug, Eq, PartialEq, Hash)] +pub struct DictionaryName(String); + +impl fmt::Display for DictionaryName { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl DictionaryName { + pub fn new(name: String) -> Self { + Self(name) + } +} + +/// A single Dictionary definition. +/// +/// A Dictionary consists of a file and format, but more fields may be added in the future. +#[derive(Clone, Debug)] +pub struct Dictionary { + pub file: PathBuf, + pub format: DictionaryFormat, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DictionaryFormat { + Json, +} + +/// A map of [`Dictionary`] definitions, keyed by their name. +#[derive(Clone, Debug, Default)] +pub struct DictionariesConfig(pub HashMap); + +/// This module contains [`TryFrom`] implementations used when deserializing a `fastly.toml`. +/// +/// These implementations are called indirectly by [`FastlyConfig::from_file`][super::FastlyConfig], +/// and help validate that we have been given an appropriate TOML schema. If the configuration is +/// not valid, a [`FastlyConfigError`] will be returned. +mod deserialization { + + use { + super::{DictionariesConfig, Dictionary, DictionaryFormat, DictionaryName}, + crate::{ + config::limits::{ + DICTIONARY_ITEM_KEY_MAX_LEN, DICTIONARY_ITEM_VALUE_MAX_LEN, DICTIONARY_MAX_LEN, + }, + error::{DictionaryConfigError, FastlyConfigError}, + }, + std::{convert::TryFrom, convert::TryInto, fs, str::FromStr}, + toml::value::{Table, Value}, + tracing::{event, Level}, + }; + + /// Helper function for converting a TOML [`Value`] into a [`Table`]. + /// + /// This function checks that a value is a [`Value::Table`] variant and returns the underlying + /// [`Table`], or returns an error if the given value was not of the right type — e.g., a + /// [`Boolean`][Value::Boolean] or a [`String`][Value::String]). + fn into_table(value: Value) -> Result { + match value { + Value::Table(table) => Ok(table), + _ => Err(DictionaryConfigError::InvalidEntryType), + } + } + + /// Return an [`DictionaryConfigError::UnrecognizedKey`] error if any unrecognized keys are found. + /// + /// This should be called after we have removed and validated the keys we expect in a [`Table`]. + fn check_for_unrecognized_keys(table: &Table) -> Result<(), DictionaryConfigError> { + if let Some(key) = table.keys().next() { + // While other keys might still exist, we can at least return a helpful error including + // the name of *one* unrecognized keys we found. + Err(DictionaryConfigError::UnrecognizedKey(key.to_owned())) + } else { + Ok(()) + } + } + + impl TryFrom
for DictionariesConfig { + type Error = FastlyConfigError; + fn try_from(toml: Table) -> Result { + /// Process a dictionary's definitions, or return a [`FastlyConfigError`]. + fn process_entry( + (name, defs): (String, Value), + ) -> Result<(DictionaryName, Dictionary), FastlyConfigError> { + into_table(defs) + .and_then(|mut toml| { + let format = toml + .remove("format") + .ok_or(DictionaryConfigError::MissingFormat) + .and_then(|format| match format { + Value::String(format) => Ok(format.parse()?), + _ => Err(DictionaryConfigError::InvalidFormatEntry), + })?; + + let file = toml + .remove("file") + .ok_or(DictionaryConfigError::MissingFile) + .and_then(|file| match file { + Value::String(file) => { + if file.is_empty() { + Err(DictionaryConfigError::EmptyFileEntry) + } else { + Ok(file.into()) + } + } + _ => Err(DictionaryConfigError::InvalidFileEntry), + })?; + check_for_unrecognized_keys(&toml)?; + event!( + Level::INFO, + "checking if the dictionary '{}' adheres to Fastly's API", + name + ); + let data = fs::read_to_string(&file).map_err(|err| { + DictionaryConfigError::IoError { + name: name.to_string(), + error: err.to_string(), + } + })?; + + match format { + DictionaryFormat::Json => parse_dict_as_json(&name, &data)?, + } + + let name = name.parse()?; + Ok((name, Dictionary { file, format })) + }) + .map_err(|err| FastlyConfigError::InvalidDictionaryDefinition { + name: name.clone(), + err, + }) + } + + toml.into_iter() + .map(process_entry) + .collect::>() + .map(Self) + } + } + + fn parse_dict_as_json(name: &str, data: &str) -> Result<(), DictionaryConfigError> { + let json: serde_json::Value = serde_json::from_str(data).map_err(|_| { + DictionaryConfigError::DictionaryFileWrongFormat { + name: name.to_string(), + } + })?; + let dict = + json.as_object() + .ok_or_else(|| DictionaryConfigError::DictionaryFileWrongFormat { + name: name.to_string(), + })?; + if dict.len() > DICTIONARY_MAX_LEN { + return Err(DictionaryConfigError::DictionaryCountTooLong { + name: name.to_string(), + size: DICTIONARY_MAX_LEN.try_into().unwrap(), + }); + } + + event!( + Level::INFO, + "checking if the items in dictionary '{}' adhere to Fastly's API", + name + ); + for (key, value) in dict.iter() { + if key.chars().count() > DICTIONARY_ITEM_KEY_MAX_LEN { + return Err(DictionaryConfigError::DictionaryItemKeyTooLong { + name: name.to_string(), + key: key.clone(), + size: DICTIONARY_ITEM_KEY_MAX_LEN.try_into().unwrap(), + }); + } + let value = value.as_str().ok_or_else(|| { + DictionaryConfigError::DictionaryItemValueWrongFormat { + name: name.to_string(), + key: key.clone(), + } + })?; + if value.chars().count() > DICTIONARY_ITEM_VALUE_MAX_LEN { + return Err(DictionaryConfigError::DictionaryItemValueTooLong { + name: name.to_string(), + key: key.clone(), + size: DICTIONARY_ITEM_VALUE_MAX_LEN.try_into().unwrap(), + }); + } + } + Ok(()) + } + + impl FromStr for DictionaryName { + type Err = DictionaryConfigError; + fn from_str(name: &str) -> Result { + // Name must start with alphabetical and contain only alphanumeric, underscore, and whitespace + if name.starts_with(char::is_alphabetic) + && name + .chars() + .all(|c| char::is_alphanumeric(c) || c == '_' || char::is_whitespace(c)) + { + Ok(Self(name.to_owned())) + } else { + Err(DictionaryConfigError::InvalidName(name.to_owned())) + } + } + } + + impl FromStr for DictionaryFormat { + type Err = DictionaryConfigError; + fn from_str(name: &str) -> Result { + if name.is_empty() { + return Err(DictionaryConfigError::EmptyFormatEntry); + } + match name { + "json" => Ok(DictionaryFormat::Json), + _ => Err(DictionaryConfigError::InvalidDictionaryFileFormat( + name.to_owned(), + )), + } + } + } +} diff --git a/lib/src/config/limits.rs b/lib/src/config/limits.rs new file mode 100644 index 00000000..d4f5dbd8 --- /dev/null +++ b/lib/src/config/limits.rs @@ -0,0 +1,4 @@ +// From https://docs.fastly.com/en/guides/resource-limits#vcl-and-configuration-limits +pub const DICTIONARY_MAX_LEN: usize = 1000; +pub const DICTIONARY_ITEM_KEY_MAX_LEN: usize = 256; +pub const DICTIONARY_ITEM_VALUE_MAX_LEN: usize = 8000; diff --git a/lib/src/config/unit_tests.rs b/lib/src/config/unit_tests.rs index ffba7b72..5b01246d 100644 --- a/lib/src/config/unit_tests.rs +++ b/lib/src/config/unit_tests.rs @@ -1,4 +1,11 @@ -use super::{FastlyConfig, LocalServerConfig, RawLocalServerConfig}; +use crate::config::dictionaries::DictionaryFormat; + +use { + super::{FastlyConfig, LocalServerConfig, RawLocalServerConfig}, + crate::config::DictionaryName, + std::{fs::File, io::Write}, + tempfile::tempdir, +}; #[test] fn fastly_toml_files_can_be_read() { @@ -28,7 +35,7 @@ fn fastly_toml_files_can_be_read() { /// Show that we can successfully parse a `fastly.toml` with backend configurations. /// -/// This provides an example `fastly.toml` file including a `#[testing.backends]` section. This +/// This provides an example `fastly.toml` file including a `#[local_server.backends]` section. This /// includes various backend definitions, that may or may not include an environment key. #[test] fn fastly_toml_files_with_simple_backend_configurations_can_be_read() { @@ -76,18 +83,61 @@ fn fastly_toml_files_with_simple_backend_configurations_can_be_read() { ); } -/// Unit tests for the `testing` section of a `fastly.toml` package manifest. +/// Show that we can successfully parse a `fastly.toml` with local_server.dictionaries configurations. +/// +/// This provides an example `fastly.toml` file including a `#[local_server.dictionaries]` section. +#[test] +fn fastly_toml_files_with_simple_dictionary_configurations_can_be_read() { + let dir = tempdir().unwrap(); + + let file_path = dir.path().join("a.json"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{{}}").unwrap(); + let config = FastlyConfig::from_str(format!( + r#" + manifest_version = "1.2.3" + name = "dictionary-config-example" + description = "a toml example with dictionary configuration" + authors = [ + "Amelia Watson ", + "Inugami Korone ", + ] + language = "rust" + + [local_server] + [local_server.dictionaries] + [local_server.dictionaries.a] + file='{}' + format = "json" + "#, + &file_path.to_str().unwrap() + )) + .expect("can read toml data containing local dictionary configurations"); + + let dictionary = config + .dictionaries() + .get(&DictionaryName::new("a".to_string())) + .expect("dictionary configurations can be accessed"); + assert_eq!(dictionary.file, file_path); + assert_eq!(dictionary.format, DictionaryFormat::Json); +} + +/// Unit tests for the `local_server` section of a `fastly.toml` package manifest. /// /// In particular, these tests check that we deserialize and validate the backend configurations /// section of the TOML data properly. In the interest of brevity, this section works with TOML data -/// that would be placed beneath the `testing` key, rather than an entire package manifest as in +/// that would be placed beneath the `local_server` key, rather than an entire package manifest as in /// the tests above. -mod testing_config_tests { +mod local_server_config_tests { + use std::fs::File; + use std::io::Write; + use tempfile::tempdir; + use { super::{LocalServerConfig, RawLocalServerConfig}, crate::error::{ - BackendConfigError, - FastlyConfigError::{self, InvalidBackendDefinition}, + BackendConfigError, DictionaryConfigError, + FastlyConfigError::{self, InvalidBackendDefinition, InvalidDictionaryDefinition}, }, std::convert::TryInto, }; @@ -98,17 +148,29 @@ mod testing_config_tests { .try_into() } - /// Check that the `testing` section can be deserialized. + /// Check that the `local_server` section can be deserialized. // This case is technically redundant, but it is nice to have a unit test that demonstrates the // happy path for this group of unit tests. #[test] - fn backend_configs_can_be_deserialized() { - static BACKENDS: &str = r#" + fn local_server_configs_can_be_deserialized() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("secrets.json"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{{}}").unwrap(); + + let local_server = format!( + r#" [backends] [backends.dog] url = "http://localhost:7878/dog-mocks" - "#; - match read_toml_config(BACKENDS) { + [dicionaries] + [dicionaries.secrets] + file = '{}' + format = "json" + "#, + file_path.to_str().unwrap() + ); + match read_toml_config(&local_server) { Ok(_) => {} res => panic!("unexpected result: {:?}", res), } @@ -243,4 +305,192 @@ mod testing_config_tests { res => panic!("unexpected result: {:?}", res), } } + + /// Check that dictionary definitions must be given as TOML tables. + #[test] + fn dictionary_configs_must_use_toml_tables() { + use DictionaryConfigError::InvalidEntryType; + static BAD_DEF: &str = r#" + [dictionaries] + "thing" = "stuff" + "#; + match read_toml_config(BAD_DEF) { + Err(InvalidDictionaryDefinition { + err: InvalidEntryType, + .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + + /// Check that dictionary definitions cannot contain unrecognized keys. + #[test] + fn dictionary_configs_cannot_contain_unrecognized_keys() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("secrets.json"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{{}}").unwrap(); + + use DictionaryConfigError::UnrecognizedKey; + let bad_default = format!( + r#" + [dictionaries] + thing = {{ file = '{}', format = "json", shrimp = true }} + "#, + file_path.to_str().unwrap() + ); + match read_toml_config(&bad_default) { + Err(InvalidDictionaryDefinition { + err: UnrecognizedKey(key), + .. + }) if key == "shrimp" => {} + res => panic!("unexpected result: {:?}", res), + } + } + + /// Check that dictionary definitions *must* include a `file` field. + #[test] + fn dictionary_configs_must_provide_a_file() { + use DictionaryConfigError::MissingFile; + static NO_FILE: &str = r#" + [dictionaries] + thing = {format = "json"} + "#; + match read_toml_config(NO_FILE) { + Err(InvalidDictionaryDefinition { + err: MissingFile, .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that dictionary definitions *must* include a `format` field. + #[test] + fn dictionary_configs_must_provide_a_format() { + use DictionaryConfigError::MissingFormat; + let dir = tempdir().unwrap(); + let file_path = dir.path().join("secrets.json"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{{}}").unwrap(); + + let no_format_field = format!( + r#" + [dictionaries] + "thing" = {{ file = '{}' }} + "#, + file_path.to_str().unwrap() + ); + match read_toml_config(&no_format_field) { + Err(InvalidDictionaryDefinition { + err: MissingFormat, .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that dictionary definitions must include a *valid* `name` field. + #[test] + fn dictionary_configs_must_provide_a_valid_name() { + use DictionaryConfigError::InvalidName; + let dir = tempdir().unwrap(); + let file_path = dir.path().join("secrets.json"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{{}}").unwrap(); + + let bad_name_field = format!( + r#" + [dictionaries] + "1" = {{ file = '{}', format = "json" }} + "#, + file_path.to_str().unwrap() + ); + match read_toml_config(&bad_name_field) { + Err(InvalidDictionaryDefinition { + err: InvalidName(_), + .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that file field is a string. + #[test] + fn dictionary_configs_must_provide_file_as_a_string() { + use DictionaryConfigError::InvalidFileEntry; + static BAD_FILE_FIELD: &str = r#" + [dictionaries] + "thing" = { file = 3, format = "json" } + "#; + match read_toml_config(BAD_FILE_FIELD) { + Err(InvalidDictionaryDefinition { + err: InvalidFileEntry, + .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that file field is non empty. + #[test] + fn dictionary_configs_must_provide_a_non_empty_file() { + use DictionaryConfigError::EmptyFileEntry; + static EMPTY_FILE_FIELD: &str = r#" + [dictionaries] + "thing" = { file = "", format = "json" } + "#; + match read_toml_config(EMPTY_FILE_FIELD) { + Err(InvalidDictionaryDefinition { + err: EmptyFileEntry, + .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that format field is a string. + #[test] + fn dictionary_configs_must_provide_format_as_a_string() { + use DictionaryConfigError::InvalidFormatEntry; + static BAD_FORMAT_FIELD: &str = r#" + [dictionaries] + "thing" = { format = 3} + "#; + match read_toml_config(BAD_FORMAT_FIELD) { + Err(InvalidDictionaryDefinition { + err: InvalidFormatEntry, + .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that format field is non empty. + #[test] + fn dictionary_configs_must_provide_a_non_empty_format() { + use DictionaryConfigError::EmptyFormatEntry; + static EMPTY_FORMAT_FIELD: &str = r#" + [dictionaries] + "thing" = { format = "" } + "#; + match read_toml_config(EMPTY_FORMAT_FIELD) { + Err(InvalidDictionaryDefinition { + err: EmptyFormatEntry, + .. + }) => {} + res => panic!("unexpected result: {:?}", res), + } + } + /// Check that format field set to json is valid. + #[test] + fn valid_dictionary_config_with_format_set_to_json() { + let dir = tempdir().unwrap(); + let file_path = dir.path().join("secrets.json"); + let mut file = File::create(&file_path).unwrap(); + writeln!(file, "{{}}").unwrap(); + + let dictionary = format!( + r#" + [dictionaries] + "thing" = {{ file = '{}', format = "json" }} + "#, + file_path.to_str().unwrap() + ); + read_toml_config(&dictionary).expect( + "can read toml data containing local dictionary configurations using json format", + ); + } } diff --git a/lib/src/error.rs b/lib/src/error.rs index 16f24129..f448a084 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,6 +1,13 @@ //! Error types. -use {crate::wiggle_abi::types::FastlyStatus, url::Url, wiggle::GuestError}; +use { + crate::{ + config::DictionaryName, + wiggle_abi::types::{DictionaryHandle, FastlyStatus}, + }, + url::Url, + wiggle::GuestError, +}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -80,6 +87,15 @@ pub enum Error { #[error("Unknown backend: {0}")] UnknownBackend(String), + #[error("Unknown dictionary: {0}")] + UnknownDictionary(DictionaryName), + + #[error("Unknown dictionary item: {0}")] + UnknownDictionaryItem(String), + + #[error("Unknown dictionary handle: {0}")] + UnknownDictionaryHandle(DictionaryHandle), + #[error{"Expected UTF-8"}] Utf8Expected(#[from] std::str::Utf8Error), @@ -209,6 +225,13 @@ pub enum FastlyConfigError { err: BackendConfigError, }, + #[error("invalid configuration for '{name}': {err}")] + InvalidDictionaryDefinition { + name: String, + #[source] + err: DictionaryConfigError, + }, + /// An error that occurred while deserializing the file. /// /// This represents errors caused by syntactically invalid TOML data, missing fields, etc. @@ -256,6 +279,78 @@ pub enum BackendConfigError { UnrecognizedKey(String), } +/// Errors that may occur while validating dictionary configurations. +#[derive(Debug, thiserror::Error)] +pub enum DictionaryConfigError { + /// An I/O error that occured while reading the file. + #[error("error reading `{name}`: {error}")] + IoError { name: String, error: String }, + + #[error("definition was not provided as a TOML table")] + InvalidEntryType, + + #[error("invalid string: {0}")] + InvalidName(String), + + #[error("'name' field was not a string")] + InvalidNameEntry, + + #[error("'{0}' is not a valid format for the dictionary file. Supported format(s) are: JSON.")] + InvalidDictionaryFileFormat(String), + + #[error("'file' field is empty")] + EmptyFileEntry, + + #[error("'format' field is empty")] + EmptyFormatEntry, + + #[error("'file' field was not a string")] + InvalidFileEntry, + + #[error("'format' field was not a string")] + InvalidFormatEntry, + + #[error("no default definition provided")] + MissingDefault, + + #[error("missing 'name' field")] + MissingName, + + #[error("missing 'file' field")] + MissingFile, + + #[error("missing 'format' field")] + MissingFormat, + + #[error("unrecognized key '{0}'")] + UnrecognizedKey(String), + + #[error("Item key named '{key}' in dictionary named '{name}' is too long, max size is {size}")] + DictionaryItemKeyTooLong { + key: String, + name: String, + size: i32, + }, + + #[error("The dictionary named '{name}' has too many items, max amount is {size}")] + DictionaryCountTooLong { name: String, size: i32 }, + + #[error("Item value under key named '{key}' in dictionary named '{name}' is of the wrong format. The value is expected to be a JSON String")] + DictionaryItemValueWrongFormat { key: String, name: String }, + + #[error( + "Item value named '{key}' in dictionary named '{name}' is too long, max size is {size}" + )] + DictionaryItemValueTooLong { + key: String, + name: String, + size: i32, + }, + + #[error("The file for the dictionary named '{name}' is of the wrong format. The file is expected to contain a single JSON Object")] + DictionaryFileWrongFormat { name: String }, +} + /// Errors related to the downstream request. #[derive(Debug, thiserror::Error)] pub enum DownstreamRequestError { diff --git a/lib/src/execute.rs b/lib/src/execute.rs index b985a974..1dbaace2 100644 --- a/lib/src/execute.rs +++ b/lib/src/execute.rs @@ -3,7 +3,7 @@ use { crate::{ body::Body, - config::Backends, + config::{Backends, Dictionaries}, downstream::prepare_request, error::ExecutionError, linking::{create_store, dummy_store, link_host_functions, WasmCtx}, @@ -36,6 +36,8 @@ pub struct ExecuteCtx { instance_pre: Arc>, /// The backends for this execution. backends: Arc, + /// The dictionaries for this execution. + dictionaries: Arc, /// Path to the config, defaults to None config_path: Arc>, /// Whether to treat stdout as a logging endpoint @@ -91,6 +93,7 @@ impl ExecuteCtx { engine, instance_pre: Arc::new(instance_pre), backends: Arc::new(Backends::default()), + dictionaries: Arc::new(Dictionaries::default()), config_path: Arc::new(None), log_stdout: false, log_stderr: false, @@ -116,6 +119,19 @@ impl ExecuteCtx { } } + /// Get the dictionaries for this execution context. + pub fn dictionaries(&self) -> &Dictionaries { + &self.dictionaries + } + + /// Set the dictionaries for this execution context. + pub fn with_dictionaries(self, dictionaries: Dictionaries) -> Self { + Self { + dictionaries: Arc::new(dictionaries), + ..self + } + } + /// Set the path to the config for this execution context. pub fn with_config_path(self, config_path: PathBuf) -> Self { Self { @@ -237,6 +253,7 @@ impl ExecuteCtx { sender, remote, self.backends.clone(), + self.dictionaries.clone(), self.config_path.clone(), ); // We currently have to postpone linking and instantiation to the guest task diff --git a/lib/src/session.rs b/lib/src/session.rs index bde3bb22..c106e985 100644 --- a/lib/src/session.rs +++ b/lib/src/session.rs @@ -7,13 +7,14 @@ use { self::{body_variant::BodyVariant, downstream::DownstreamResponse}, crate::{ body::Body, - config::{Backend, Backends}, + config::{Backend, Backends, Dictionaries, Dictionary, DictionaryName}, error::{Error, HandleError}, logging::LogEndpoint, streaming_body::StreamingBody, upstream::{PendingRequest, SelectTarget}, wiggle_abi::types::{ - BodyHandle, EndpointHandle, PendingRequestHandle, RequestHandle, ResponseHandle, + BodyHandle, DictionaryHandle, EndpointHandle, PendingRequestHandle, RequestHandle, + ResponseHandle, }, }, cranelift_entity::PrimaryMap, @@ -67,6 +68,14 @@ pub struct Session { /// /// Populated prior to guest execution, and never modified. pub(crate) backends: Arc, + /// The dictionaries configured for this execution. + /// + /// Populated prior to guest execution, and never modified. + pub(crate) dictionaries: Arc, + /// The dictionaries configured for this execution. + /// + /// Populated prior to guest execution, and never modified. + dictionaries_by_name: PrimaryMap, /// The path to the configuration file used for this invocation of Viceroy. /// /// Created prior to guest execution, and never modified. @@ -85,6 +94,7 @@ impl Session { resp_sender: Sender>, client_ip: IpAddr, backends: Arc, + dictionaries: Arc, config_path: Arc>, ) -> Session { let (parts, body) = req.into_parts(); @@ -108,6 +118,8 @@ impl Session { log_endpoints: PrimaryMap::new(), log_endpoints_by_name: HashMap::new(), backends, + dictionaries, + dictionaries_by_name: PrimaryMap::new(), config_path, pending_reqs: PrimaryMap::new(), req_id, @@ -128,6 +140,7 @@ impl Session { sender, "0.0.0.0".parse().unwrap(), Arc::new(HashMap::new()), + Arc::new(HashMap::new()), Arc::new(None), ) } @@ -494,6 +507,25 @@ impl Session { self.backends.get(name).map(std::ops::Deref::deref) } + // ----- Dictionaries API ----- + + /// Look up a dictionary-handle by name. + pub fn dictionary_handle(&mut self, name: &str) -> Result { + let name = DictionaryName::new(name.to_string()); + Ok(self.dictionaries_by_name.push(name)) + } + + /// Look up a dictionary by dictionary-handle. + pub fn dictionary(&self, handle: DictionaryHandle) -> Result<&Dictionary, Error> { + match self.dictionaries_by_name.get(handle) { + Some(name) => match self.dictionaries.get(name) { + Some(dictionary) => Ok(dictionary), + None => Err(Error::UnknownDictionaryHandle(handle)), + }, + None => Err(Error::UnknownDictionaryHandle(handle)), + } + } + // ----- Pending Requests API ----- /// Insert a [`PendingRequest`] into the session. diff --git a/lib/src/wiggle_abi/dictionary_impl.rs b/lib/src/wiggle_abi/dictionary_impl.rs index c59b7dba..e5d4e37e 100644 --- a/lib/src/wiggle_abi/dictionary_impl.rs +++ b/lib/src/wiggle_abi/dictionary_impl.rs @@ -6,16 +6,22 @@ use { session::Session, wiggle_abi::{fastly_dictionary::FastlyDictionary, types::DictionaryHandle}, }, + std::{convert::TryFrom, fs, path::Path}, wiggle::GuestPtr, }; +fn read_json_file>(file: P) -> serde_json::Map { + let data = fs::read_to_string(file).expect("Unable to read file"); + let json: serde_json::Value = serde_json::from_str(&data).expect("JSON was not well-formatted"); + let obj = json.as_object().expect("Expected the JSON to be an Object"); + obj.clone() +} + impl FastlyDictionary for Session { - #[allow(unused_variables)] // FIXME: Remove this directive once implemented. fn open(&mut self, name: &GuestPtr) -> Result { - Err(Error::NotAvailable("Dictionary lookup")) + self.dictionary_handle(&name.as_str()?) } - #[allow(unused_variables)] // FIXME: Remove this directive once implemented. fn get( &mut self, dictionary: DictionaryHandle, @@ -23,6 +29,27 @@ impl FastlyDictionary for Session { buf: &GuestPtr, buf_len: u32, ) -> Result { - Err(Error::NotAvailable("Dictionary lookup")) + let key: &str = &key.as_str()?; + let dict = self.dictionary(dictionary)?; + let file = dict.file.clone(); + let obj = read_json_file(file); + let item = obj + .get(key) + .ok_or_else(|| Error::UnknownDictionaryItem(key.to_string()))?; + let item = item.as_str().unwrap(); + let item_bytes = item.as_bytes(); + + if item_bytes.len() > buf_len as usize { + return Err(Error::BufferLengthError { + buf: "dictionary_item", + len: "dictionary_item_max_len", + }); + } + let item_len = u32::try_from(item_bytes.len()) + .expect("smaller than dictionary_item_max_len means it must fit"); + + let mut buf_slice = buf.as_array(item_len).as_slice_mut()?; + buf_slice.copy_from_slice(item_bytes); + Ok(item_len) } } diff --git a/lib/src/wiggle_abi/entity.rs b/lib/src/wiggle_abi/entity.rs index e4045c47..bdc76c9b 100644 --- a/lib/src/wiggle_abi/entity.rs +++ b/lib/src/wiggle_abi/entity.rs @@ -3,7 +3,8 @@ //! [ref]: https://docs.rs/cranelift-entity/latest/cranelift_entity/trait.EntityRef.html use super::types::{ - BodyHandle, EndpointHandle, PendingRequestHandle, RequestHandle, ResponseHandle, + BodyHandle, DictionaryHandle, EndpointHandle, PendingRequestHandle, RequestHandle, + ResponseHandle, }; /// Macro which implements a 32-bit entity reference for handles generated by Wiggle. @@ -43,3 +44,4 @@ wiggle_entity!(RequestHandle); wiggle_entity!(ResponseHandle); wiggle_entity!(EndpointHandle); wiggle_entity!(PendingRequestHandle); +wiggle_entity!(DictionaryHandle);