diff --git a/Cargo.lock b/Cargo.lock index 63dc54ba4..ccc25d9e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2965,6 +2965,7 @@ dependencies = [ "serde_with 2.3.3", "serial_test", "tendermint-rpc", + "tower-http", "tracing", ] @@ -3050,6 +3051,7 @@ dependencies = [ "tendermint-rpc", "thiserror", "tokio", + "tower-http", "tracing", "tracing-subscriber", ] @@ -4594,9 +4596,9 @@ dependencies = [ [[package]] name = "http" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8947b1a6fad4393052c7ba1f4cd97bed3e953a95c79c92ad9b051a04611d9fbb" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" dependencies = [ "bytes", "fnv", @@ -4614,6 +4616,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add0ab9360ddbd88cfeb3bd9574a1d85cfdfa14db10b3e21d3700dbc4328758f" + [[package]] name = "httparse" version = "1.8.0" @@ -5062,6 +5070,7 @@ dependencies = [ "fvm_ipld_encoding", "fvm_shared", "hex", + "http", "indoc", "ipc-api", "ipc-types", @@ -5083,6 +5092,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.18.0", "toml 0.8.10", + "tower-http", "tracing", "url", "zeroize", @@ -9720,6 +9730,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" +dependencies = [ + "bitflags 2.4.2", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-range-header", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.2" diff --git a/Cargo.toml b/Cargo.toml index 1e9cfca82..af3e3d141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -78,6 +78,7 @@ futures-util = "0.3" gcra = "0.4" hex = "0.4" hex-literal = "0.4.1" +http = "0.2.12" im = "15.1.0" integer-encoding = { version = "3.0.3", default-features = false } jsonrpc-v2 = { version = "0.11", default-features = false, features = [ @@ -152,6 +153,7 @@ tokio = { version = "1", features = [ "io-std", "sync", ] } +tower-http = { version = "0.4.0", features = ["cors"] } tokio-stream = "0.1.14" tokio-util = { version = "0.7.8", features = ["compat"] } tokio-tungstenite = { version = "0.18.0", features = ["native-tls"] } diff --git a/fendermint/app/config/default.toml b/fendermint/app/config/default.toml index 37c8e2595..6282e18dc 100644 --- a/fendermint/app/config/default.toml +++ b/fendermint/app/config/default.toml @@ -138,6 +138,17 @@ host = "127.0.0.1" # JSON-RPC (POST) and WebSockets (GET) requests. port = 8545 +[eth.cors] +# A list of origins a cross-domain request can be executed from +# Default value '[]' disables cors support +# Use '["*"]' to allow any origin +allowed_origins = [] +# A list of methods the client is allowed to use with cross-domain requests +# Suggested methods if allowing origins: "GET", "OPTIONS", "HEAD", "POST" +allowed_methods = [] +# A list of non-simple headers the client is allowed to use with cross-domain requests +# Suggested headers if allowing origins: "Accept", "Authorization", "Content-Type", "Origin" +allowed_headers = [] # IPLD Resolver Configuration [resolver] @@ -154,7 +165,6 @@ local_key = "keys/network.sk" # so we can derive a name using the rootnet ID and use this as an override. network_name = "" - # Peer Discovery [resolver.discovery] # Bootstrap node addresses for peer discovery, or the entire list for a static network @@ -169,7 +179,6 @@ target_connections = 50 # Option to disable Kademlia, for example in a fixed static network. enable_kademlia = true - # IPC Subnet Membership [resolver.membership] # User defined list of subnets which will never be pruned from the cache. @@ -214,7 +223,6 @@ max_peers_per_query = 5 # consumer gets an error because it's falling behind. event_buffer_capacity = 100 - # Serving Content [resolver.content] # Number of bytes that can be consumed by remote peers in a time period. 0 means no limit. @@ -237,7 +245,6 @@ vote_interval = 1 # pausing the syncer, preventing new events to trigger votes. vote_timeout = 60 - # # Setting which are only allowed if the `--network` CLI parameter is `testnet`. # [testing] diff --git a/fendermint/app/settings/Cargo.toml b/fendermint/app/settings/Cargo.toml index e4f2680de..ba66ec4fa 100644 --- a/fendermint/app/settings/Cargo.toml +++ b/fendermint/app/settings/Cargo.toml @@ -21,12 +21,13 @@ serde_json = { workspace = true } serde_with = { workspace = true } serial_test = { workspace = true } tendermint-rpc = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } fvm_shared = { workspace = true } fvm_ipld_encoding = { workspace = true } ipc-api = { workspace = true } ipc-provider = { workspace = true } -tracing = { workspace = true } fendermint_vm_encoding = { path = "../../vm/encoding" } fendermint_vm_topdown = { path = "../../vm/topdown" } diff --git a/fendermint/app/settings/src/eth.rs b/fendermint/app/settings/src/eth.rs index 10e14b541..a0acefc34 100644 --- a/fendermint/app/settings/src/eth.rs +++ b/fendermint/app/settings/src/eth.rs @@ -2,9 +2,13 @@ // SPDX-License-Identifier: Apache-2.0, MIT use fvm_shared::econ::TokenAmount; +use ipc_provider::config::deserialize::{ + deserialize_cors_headers, deserialize_cors_methods, deserialize_cors_origins, +}; use serde::Deserialize; use serde_with::{serde_as, DurationSeconds}; use std::time::Duration; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin}; use crate::{IsHumanReadable, SocketAddress}; @@ -18,6 +22,7 @@ pub struct EthSettings { pub cache_capacity: usize, pub gas: GasOpt, pub max_nonce_gap: u64, + pub cors: CorsOpt, } #[serde_as] @@ -29,3 +34,14 @@ pub struct GasOpt { pub num_blocks_max_prio_fee: u64, pub max_fee_hist_size: u64, } + +#[serde_as] +#[derive(Debug, Clone, Deserialize)] +pub struct CorsOpt { + #[serde(deserialize_with = "deserialize_cors_origins")] + pub allowed_origins: AllowOrigin, + #[serde(deserialize_with = "deserialize_cors_methods")] + pub allowed_methods: AllowMethods, + #[serde(deserialize_with = "deserialize_cors_headers")] + pub allowed_headers: AllowHeaders, +} diff --git a/fendermint/app/settings/src/lib.rs b/fendermint/app/settings/src/lib.rs index e83f53c05..6326fa24e 100644 --- a/fendermint/app/settings/src/lib.rs +++ b/fendermint/app/settings/src/lib.rs @@ -52,15 +52,15 @@ impl Display for SocketAddress { } } -impl std::net::ToSocketAddrs for SocketAddress { - type Iter = ::Iter; +impl ToSocketAddrs for SocketAddress { + type Iter = ::Iter; fn to_socket_addrs(&self) -> std::io::Result { self.to_string().to_socket_addrs() } } -impl TryInto for SocketAddress { +impl TryInto for SocketAddress { type Error = std::io::Error; fn try_into(self) -> Result { @@ -328,7 +328,10 @@ impl Settings { .list_separator(",") // need to list keys explicitly below otherwise it can't pase simple `String` type .with_list_parse_key("resolver.connection.external_addresses") .with_list_parse_key("resolver.discovery.static_addresses") - .with_list_parse_key("resolver.membership.static_subnets"), + .with_list_parse_key("resolver.membership.static_subnets") + .with_list_parse_key("eth.cors.allowed_origins") + .with_list_parse_key("eth.cors.allowed_methods") + .with_list_parse_key("eth.cors.allowed_headers"), )) // Set the home directory based on what was passed to the CLI, // so everything in the config can be relative to it. @@ -381,7 +384,7 @@ mod tests { use crate::DbCompaction; - use super::Settings; + use super::{ConfigError, Settings}; fn try_parse_config(run_mode: &str) -> Result { let current_dir = PathBuf::from("."); @@ -422,21 +425,41 @@ mod tests { let settings = with_env_vars(vec![ ("FM_RESOLVER__CONNECTION__EXTERNAL_ADDRESSES", "/ip4/198.51.100.0/tcp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N,/ip6/2604:1380:2000:7a00::1/udp/4001/quic/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb"), ("FM_RESOLVER__DISCOVERY__STATIC_ADDRESSES", "/ip4/198.51.100.1/tcp/4242/p2p/QmYyQSo1c1Ym7orWxLYvCrM2EmxFTANf8wXmmE7DWjhx5N,/ip6/2604:1380:2000:7a00::2/udp/4001/quic/p2p/QmbLHAnMoJPWSCR5Zhtx6BHJX9KiKNN6tpvbUcqanj75Nb"), + ("FM_RESOLVER__MEMBERSHIP__STATIC_SUBNETS", "/r314/f410fijl3evsntewwhqxy6cx5ijdq5qp5cjlocbgzgey,/r314/f410fwplxlims2wnigaha2gofgktue7hiusmttwridkq"), + ("FM_ETH__CORS__ALLOWED_ORIGINS", "https://example.com,https://www.example.org"), + ("FM_ETH__CORS__ALLOWED_METHODS", "GET,POST"), + ("FM_ETH__CORS__ALLOWED_HEADERS", "Accept,Content-Type"), // Set a normal string key as well to make sure we have configured the library correctly and it doesn't try to parse everything as a list. ("FM_RESOLVER__NETWORK__NETWORK_NAME", "test"), ], || try_parse_config("")).unwrap(); - assert_eq!(settings.resolver.discovery.static_addresses.len(), 2); assert_eq!(settings.resolver.connection.external_addresses.len(), 2); + assert_eq!(settings.resolver.discovery.static_addresses.len(), 2); + assert_eq!(settings.resolver.membership.static_subnets.len(), 2); + assert_eq!( + format!("{:?}", settings.eth.cors.allowed_origins), + "List([\"https://example.com\", \"https://www.example.org\"])" + ); + assert_eq!( + format!("{:?}", settings.eth.cors.allowed_methods), + "Const(Some(\"GET,POST\"))" + ); + assert_eq!( + format!("{:?}", settings.eth.cors.allowed_headers), + "Const(Some(\"accept,content-type\"))" + ); } #[test] fn parse_empty_comma_separated() { let settings = with_env_vars( vec![ - ("FM_RESOLVER__DISCOVERY__STATIC_ADDRESSES", ""), ("FM_RESOLVER__CONNECTION__EXTERNAL_ADDRESSES", ""), + ("FM_RESOLVER__DISCOVERY__STATIC_ADDRESSES", ""), ("FM_RESOLVER__MEMBERSHIP__STATIC_SUBNETS", ""), + ("FM_ETH__CORS__ALLOWED_ORIGINS", ""), + ("FM_ETH__CORS__ALLOWED_METHODS", ""), + ("FM_ETH__CORS__ALLOWED_HEADERS", ""), ], || try_parse_config(""), ) @@ -445,6 +468,18 @@ mod tests { assert_eq!(settings.resolver.connection.external_addresses.len(), 0); assert_eq!(settings.resolver.discovery.static_addresses.len(), 0); assert_eq!(settings.resolver.membership.static_subnets.len(), 0); + assert_eq!( + format!("{:?}", settings.eth.cors.allowed_origins), + "List([])" + ); + assert_eq!( + format!("{:?}", settings.eth.cors.allowed_methods), + "Const(None)" + ); + assert_eq!( + format!("{:?}", settings.eth.cors.allowed_headers), + "Const(None)" + ); } #[test] @@ -471,4 +506,51 @@ mod tests { multiaddr!(Dns4("bar.ai"), Tcp(5678u16)) ); } + + #[test] + fn parse_cors_origins_variants() { + // relative URL without a base + let settings = with_env_vars( + vec![("FM_ETH__CORS__ALLOWED_ORIGINS", "example.com")], + || try_parse_config(""), + ); + assert!( + matches!(settings, Err(ConfigError::Message(ref msg)) if msg == "relative URL without a base") + ); + + // opaque origin + let settings = with_env_vars( + vec![( + "FM_ETH__CORS__ALLOWED_ORIGINS", + "javascript:console.log(\"invalid origin\")", + )], + || try_parse_config(""), + ); + assert!( + matches!(settings, Err(ConfigError::Message(ref msg)) if msg == "opaque origins are not allowed") + ); + + // Allow all with "*" + let settings = with_env_vars(vec![("FM_ETH__CORS__ALLOWED_ORIGINS", "*")], || { + try_parse_config("") + }); + assert!(settings.is_ok()); + + // IPv4 + let settings = with_env_vars( + vec![("FM_ETH__CORS__ALLOWED_ORIGINS", "http://192.0.2.1:1234")], + || try_parse_config(""), + ); + assert!(settings.is_ok()); + + // IPv6 + let settings = with_env_vars( + vec![( + "FM_ETH__CORS__ALLOWED_ORIGINS", + "http://[2001:0db8:85a3:0000:0000:8a2e:0370:7334]:1234", + )], + || try_parse_config(""), + ); + assert!(settings.is_ok()); + } } diff --git a/fendermint/app/src/cmd/eth.rs b/fendermint/app/src/cmd/eth.rs index 4baecd000..dc0846bca 100644 --- a/fendermint/app/src/cmd/eth.rs +++ b/fendermint/app/src/cmd/eth.rs @@ -38,6 +38,11 @@ async fn run(settings: EthSettings, client: HybridClient) -> anyhow::Result<()> num_blocks_max_prio_fee: settings.gas.num_blocks_max_prio_fee, max_fee_hist_size: settings.gas.max_fee_hist_size, }; + let cors = fendermint_eth_api::CorsOpt { + allowed_origins: settings.cors.allowed_origins, + allowed_methods: settings.cors.allowed_methods, + allowed_headers: settings.cors.allowed_headers, + }; fendermint_eth_api::listen( settings.listen, client, @@ -45,6 +50,7 @@ async fn run(settings: EthSettings, client: HybridClient) -> anyhow::Result<()> settings.cache_capacity, settings.max_nonce_gap, gas, + cors, ) .await } diff --git a/fendermint/eth/api/Cargo.toml b/fendermint/eth/api/Cargo.toml index 3f38eb08e..2df5af433 100644 --- a/fendermint/eth/api/Cargo.toml +++ b/fendermint/eth/api/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true anyhow = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } +cid = { workspace = true } ethers-core = { workspace = true } ethers-contract = { workspace = true } erased-serde = { workspace = true } @@ -19,16 +20,16 @@ jsonrpc-v2 = { workspace = true } lazy_static = { workspace = true } lru_time_cache = { workspace = true } paste = { workspace = true } -serde = { workspace = true } rand = { workspace = true } regex = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } tendermint = { workspace = true } tendermint-rpc = { workspace = true } tokio = { workspace = true } +tower-http = { workspace = true } -cid = { workspace = true } fil_actors_evm_shared = { workspace = true } fvm_shared = { workspace = true } fvm_ipld_encoding = { workspace = true } @@ -45,11 +46,11 @@ ethers = { workspace = true, features = ["abigen"] } hex = { workspace = true } lazy_static = { workspace = true } rand = { workspace = true } -tracing = { workspace = true } -tracing-subscriber = { workspace = true } quickcheck = { workspace = true } quickcheck_macros = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } fendermint_testing = { path = "../../testing", features = ["arb"] } fendermint_vm_message = { path = "../../vm/message", features = ["arb"] } diff --git a/fendermint/eth/api/src/lib.rs b/fendermint/eth/api/src/lib.rs index 369dcd54a..99504643a 100644 --- a/fendermint/eth/api/src/lib.rs +++ b/fendermint/eth/api/src/lib.rs @@ -6,6 +6,7 @@ use axum::routing::{get, post}; use fvm_shared::econ::TokenAmount; use jsonrpc_v2::Data; use std::{net::ToSocketAddrs, sync::Arc, time::Duration}; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer}; mod apis; mod cache; @@ -42,6 +43,13 @@ pub struct GasOpt { pub max_fee_hist_size: u64, } +#[derive(Debug, Clone)] +pub struct CorsOpt { + pub allowed_origins: AllowOrigin, + pub allowed_methods: AllowMethods, + pub allowed_headers: AllowHeaders, +} + /// Start listening to JSON-RPC requests. pub async fn listen( listen_addr: A, @@ -50,6 +58,7 @@ pub async fn listen( cache_capacity: usize, max_nonce_gap: Nonce, gas_opt: GasOpt, + cors_opt: CorsOpt, ) -> anyhow::Result<()> { if let Some(listen_addr) = listen_addr.to_socket_addrs()?.next() { let rpc_state = Arc::new(JsonRpcState::new( @@ -72,7 +81,7 @@ pub async fn listen( rpc_server, rpc_state, }; - let router = make_router(app_state); + let router = make_router(app_state, cors_opt); let server = axum::Server::try_bind(&listen_addr)?.serve(router.into_make_service()); tracing::info!(?listen_addr, "bound Ethereum API"); server.await?; @@ -90,9 +99,15 @@ fn make_server(state: Arc>) -> JsonRpcServer { } /// Register routes in the `axum` HTTP router to handle JSON-RPC and WebSocket calls. -fn make_router(state: AppState) -> axum::Router { +fn make_router(state: AppState, cors_opt: CorsOpt) -> axum::Router { axum::Router::new() .route("/", post(handlers::http::handle)) .route("/", get(handlers::ws::handle)) + .layer( + CorsLayer::new() + .allow_origin(cors_opt.allowed_origins) + .allow_methods(cors_opt.allowed_methods) + .allow_headers(cors_opt.allowed_headers), + ) .with_state(state) } diff --git a/ipc/provider/Cargo.toml b/ipc/provider/Cargo.toml index cacfe9aeb..31ce83737 100644 --- a/ipc/provider/Cargo.toml +++ b/ipc/provider/Cargo.toml @@ -11,37 +11,38 @@ license-file.workspace = true anyhow = { workspace = true } async-channel = { workspace = true } async-trait = { workspace = true } +base64 = { workspace = true } +bytes = { workspace = true } +cid = { workspace = true } +dirs = { workspace = true } +ethers = { workspace = true } +ethers-contract = { workspace = true } futures-util = { workspace = true } -reqwest = { workspace = true } - +hex = { workspace = true } +http = { workspace = true } libsecp256k1 = { workspace = true } log = { workspace = true } -tracing = { workspace = true } +num-traits = { workspace = true } +num-derive = { workspace = true } +reqwest = { workspace = true } serde = { workspace = true } +serde_bytes = { workspace = true } serde_json = { workspace = true } -cid = { workspace = true } +serde_tuple = { workspace = true } +serde_with = { workspace = true } +strum = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } tokio-tungstenite = { workspace = true } -num-traits = { workspace = true } -num-derive = { workspace = true } -base64 = { workspace = true } -strum = { workspace = true } toml = { workspace = true } +tower-http = { workspace = true } +tracing = { workspace = true } url = { workspace = true } -bytes = { workspace = true } -dirs = { workspace = true } -serde_bytes = { workspace = true } -thiserror = { workspace = true } -hex = { workspace = true } -serde_tuple = { workspace = true } -serde_with = { workspace = true } zeroize = { workspace = true } -ethers-contract = { workspace = true } -ethers = { workspace = true } -fvm_shared = { workspace = true } fil_actors_runtime = { workspace = true } fvm_ipld_encoding = { workspace = true } +fvm_shared = { workspace = true } ipc-types = { workspace = true } ipc-wallet = { workspace = true, features = ["with-ethers"] } diff --git a/ipc/provider/src/config/deserialize.rs b/ipc/provider/src/config/deserialize.rs index 2ef9d8552..6bbb84db5 100644 --- a/ipc/provider/src/config/deserialize.rs +++ b/ipc/provider/src/config/deserialize.rs @@ -3,14 +3,18 @@ //! Deserialization utils for config mod. use crate::config::Subnet; +use anyhow::anyhow; use fvm_shared::address::Address; +use http::HeaderValue; use ipc_api::subnet_id::SubnetID; use ipc_types::EthAddress; -use serde::de::Error; +use serde::de::{Error, SeqAccess}; use serde::{Deserialize, Deserializer}; use std::collections::HashMap; use std::fmt::Formatter; use std::str::FromStr; +use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin}; +use url::Url; /// A serde deserialization method to deserialize a hashmap of subnets with subnet id as key and /// Subnet struct as value from a vec of subnets @@ -92,7 +96,7 @@ where formatter.write_str("a string") } - fn visit_str(self, v: &str) -> std::result::Result + fn visit_str(self, v: &str) -> Result where E: Error, { @@ -106,3 +110,108 @@ fn eth_addr_str_to_address(s: &str) -> anyhow::Result
{ let addr = EthAddress::from_str(s)?; Ok(Address::from(addr)) } + +/// A serde deserialization method to deserialize cors origins from a sequence of strings, +/// e.g., [], ["*"], ["https://example.com", "https://www.example.org"]. +pub fn deserialize_cors_origins<'de, D>(deserializer: D) -> anyhow::Result +where + D: Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = AllowOrigin; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of strings") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut origins = Vec::new(); + while let Some(v) = seq.next_element::()? { + if v == "*" { + return Ok(AllowOrigin::any()); + } else { + origins.push(parse_origin(&v).map_err(Error::custom)?); + } + } + Ok(AllowOrigin::list(origins)) + } + } + deserializer.deserialize_seq(Visitor) +} + +fn parse_origin(s: &str) -> anyhow::Result { + // First parse as url to extract the validated origin + let origin = s.parse::()?.origin(); + if !origin.is_tuple() { + return Err(anyhow!("opaque origins are not allowed")); + } + Ok(HeaderValue::from_str(&origin.ascii_serialization())?) +} + +/// A serde deserialization method to deserialize cors methods from a sequence of strings, +/// e.g., [], ["*"], ["GET", "POST"]. +pub fn deserialize_cors_methods<'de, D>(deserializer: D) -> anyhow::Result +where + D: Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = AllowMethods; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of strings") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut methods = Vec::new(); + while let Some(v) = seq.next_element::()? { + if v == "*" { + return Ok(AllowMethods::any()); + } else { + methods.push(v.parse().map_err(Error::custom)?); + } + } + Ok(AllowMethods::list(methods)) + } + } + deserializer.deserialize_seq(Visitor) +} + +/// A serde deserialization method to deserialize cors headers from a sequence of strings, +/// e.g., [], ["*"], ["Accept", "Content-Type"]. +pub fn deserialize_cors_headers<'de, D>(deserializer: D) -> anyhow::Result +where + D: Deserializer<'de>, +{ + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = AllowHeaders; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of strings") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut headers = Vec::new(); + while let Some(v) = seq.next_element::()? { + if v == "*" { + return Ok(AllowHeaders::any()); + } else { + headers.push(v.parse().map_err(Error::custom)?); + } + } + Ok(AllowHeaders::list(headers)) + } + } + deserializer.deserialize_seq(Visitor) +}