diff --git a/crates/bashkit-cli/Cargo.toml b/crates/bashkit-cli/Cargo.toml index 00f012ce..378af41e 100644 --- a/crates/bashkit-cli/Cargo.toml +++ b/crates/bashkit-cli/Cargo.toml @@ -19,3 +19,5 @@ bashkit = { path = "../bashkit" } tokio.workspace = true clap.workspace = true anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/bashkit-cli/src/main.rs b/crates/bashkit-cli/src/main.rs index 7cb39a99..b0e3511c 100644 --- a/crates/bashkit-cli/src/main.rs +++ b/crates/bashkit-cli/src/main.rs @@ -3,10 +3,13 @@ //! Usage: //! bashkit -c 'echo hello' # Execute a command string //! bashkit script.sh # Execute a script file -//! bashkit # Interactive REPL (not yet implemented) +//! bashkit mcp # Run as MCP server +//! bashkit # Interactive REPL (not yet implemented) + +mod mcp; use anyhow::{Context, Result}; -use clap::Parser; +use clap::{Parser, Subcommand}; use std::path::PathBuf; /// BashKit - Sandboxed bash interpreter @@ -25,12 +28,26 @@ struct Args { /// Arguments to pass to the script #[arg(trailing_var_arg = true)] args: Vec, + + #[command(subcommand)] + subcommand: Option, +} + +#[derive(Subcommand, Debug)] +enum SubCmd { + /// Run as MCP (Model Context Protocol) server + Mcp, } #[tokio::main] async fn main() -> Result<()> { let args = Args::parse(); + // Handle subcommands first + if let Some(SubCmd::Mcp) = args.subcommand { + return mcp::run().await; + } + let mut bash = bashkit::Bash::new(); // Execute command string if provided @@ -61,6 +78,6 @@ async fn main() -> Result<()> { // Interactive REPL (not yet implemented) eprintln!("bashkit: interactive mode not yet implemented"); - eprintln!("Usage: bashkit -c 'command' or bashkit script.sh"); + eprintln!("Usage: bashkit -c 'command' or bashkit script.sh or bashkit mcp"); std::process::exit(1); } diff --git a/crates/bashkit-cli/src/mcp.rs b/crates/bashkit-cli/src/mcp.rs new file mode 100644 index 00000000..6a8b88a2 --- /dev/null +++ b/crates/bashkit-cli/src/mcp.rs @@ -0,0 +1,255 @@ +//! MCP (Model Context Protocol) server implementation +//! +//! Implements a JSON-RPC 2.0 server that exposes bashkit as an MCP tool. +//! +//! Protocol: +//! - Input: JSON-RPC requests on stdin +//! - Output: JSON-RPC responses on stdout + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::io::{BufRead, Write}; + +/// JSON-RPC 2.0 request +#[derive(Debug, Deserialize)] +struct JsonRpcRequest { + #[allow(dead_code)] // Required by JSON-RPC spec but not used in routing + jsonrpc: String, + id: serde_json::Value, + method: String, + #[serde(default)] + params: serde_json::Value, +} + +/// JSON-RPC 2.0 response +#[derive(Debug, Serialize)] +struct JsonRpcResponse { + jsonrpc: String, + id: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +/// JSON-RPC 2.0 error +#[derive(Debug, Serialize)] +struct JsonRpcError { + code: i32, + message: String, + #[serde(skip_serializing_if = "Option::is_none")] + data: Option, +} + +impl JsonRpcResponse { + fn success(id: serde_json::Value, result: serde_json::Value) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + result: Some(result), + error: None, + } + } + + fn error(id: serde_json::Value, code: i32, message: String) -> Self { + Self { + jsonrpc: "2.0".to_string(), + id, + result: None, + error: Some(JsonRpcError { + code, + message, + data: None, + }), + } + } +} + +/// MCP tool definition +#[derive(Debug, Serialize)] +struct Tool { + name: String, + description: String, + #[serde(rename = "inputSchema")] + input_schema: serde_json::Value, +} + +/// MCP server capabilities +#[derive(Debug, Serialize)] +struct ServerCapabilities { + tools: serde_json::Value, +} + +/// MCP server info +#[derive(Debug, Serialize)] +struct ServerInfo { + name: String, + version: String, +} + +/// MCP initialize result +#[derive(Debug, Serialize)] +struct InitializeResult { + #[serde(rename = "protocolVersion")] + protocol_version: String, + capabilities: ServerCapabilities, + #[serde(rename = "serverInfo")] + server_info: ServerInfo, +} + +/// Tool call arguments for bash execution +#[derive(Debug, Deserialize)] +struct BashToolArgs { + script: String, +} + +/// Tool call result +#[derive(Debug, Serialize)] +struct ToolResult { + content: Vec, + #[serde(rename = "isError", skip_serializing_if = "Option::is_none")] + is_error: Option, +} + +#[derive(Debug, Serialize)] +struct ContentItem { + #[serde(rename = "type")] + content_type: String, + text: String, +} + +/// Run the MCP server +pub async fn run() -> Result<()> { + let stdin = std::io::stdin(); + let mut stdout = std::io::stdout(); + + for line in stdin.lock().lines() { + let line = line.context("Failed to read line from stdin")?; + if line.trim().is_empty() { + continue; + } + + let request: JsonRpcRequest = match serde_json::from_str(&line) { + Ok(req) => req, + Err(e) => { + let response = JsonRpcResponse::error( + serde_json::Value::Null, + -32700, + format!("Parse error: {}", e), + ); + writeln!(stdout, "{}", serde_json::to_string(&response)?)?; + stdout.flush()?; + continue; + } + }; + + let response = handle_request(request).await; + writeln!(stdout, "{}", serde_json::to_string(&response)?)?; + stdout.flush()?; + } + + Ok(()) +} + +async fn handle_request(request: JsonRpcRequest) -> JsonRpcResponse { + match request.method.as_str() { + "initialize" => handle_initialize(request.id), + "initialized" => JsonRpcResponse::success(request.id, serde_json::Value::Null), + "tools/list" => handle_tools_list(request.id), + "tools/call" => handle_tools_call(request.id, request.params).await, + "shutdown" => JsonRpcResponse::success(request.id, serde_json::Value::Null), + _ => JsonRpcResponse::error(request.id, -32601, "Method not found".to_string()), + } +} + +fn handle_initialize(id: serde_json::Value) -> JsonRpcResponse { + let result = InitializeResult { + protocol_version: "2024-11-05".to_string(), + capabilities: ServerCapabilities { + tools: serde_json::json!({}), + }, + server_info: ServerInfo { + name: "bashkit".to_string(), + version: env!("CARGO_PKG_VERSION").to_string(), + }, + }; + + JsonRpcResponse::success(id, serde_json::to_value(result).unwrap()) +} + +fn handle_tools_list(id: serde_json::Value) -> JsonRpcResponse { + let tools = vec![Tool { + name: "bash".to_string(), + description: "Execute a bash script in a sandboxed environment".to_string(), + input_schema: serde_json::json!({ + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The bash script to execute" + } + }, + "required": ["script"] + }), + }]; + + JsonRpcResponse::success(id, serde_json::json!({ "tools": tools })) +} + +async fn handle_tools_call(id: serde_json::Value, params: serde_json::Value) -> JsonRpcResponse { + // Extract tool name and arguments + let tool_name = params.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let arguments = params.get("arguments").cloned().unwrap_or_default(); + + if tool_name != "bash" { + return JsonRpcResponse::error(id, -32602, format!("Unknown tool: {}", tool_name)); + } + + // Parse arguments + let args: BashToolArgs = match serde_json::from_value(arguments) { + Ok(a) => a, + Err(e) => { + return JsonRpcResponse::error(id, -32602, format!("Invalid arguments: {}", e)); + } + }; + + // Execute the script + let mut bash = bashkit::Bash::new(); + let result = match bash.exec(&args.script).await { + Ok(r) => r, + Err(e) => { + let tool_result = ToolResult { + content: vec![ContentItem { + content_type: "text".to_string(), + text: format!("Error: {}", e), + }], + is_error: Some(true), + }; + return JsonRpcResponse::success(id, serde_json::to_value(tool_result).unwrap()); + } + }; + + // Format output + let mut output = result.stdout; + if !result.stderr.is_empty() { + output.push_str("\n[stderr]\n"); + output.push_str(&result.stderr); + } + if result.exit_code != 0 { + output.push_str(&format!("\n[exit code: {}]", result.exit_code)); + } + + let tool_result = ToolResult { + content: vec![ContentItem { + content_type: "text".to_string(), + text: output, + }], + is_error: if result.exit_code != 0 { + Some(true) + } else { + None + }, + }; + + JsonRpcResponse::success(id, serde_json::to_value(tool_result).unwrap()) +} diff --git a/crates/bashkit/Cargo.toml b/crates/bashkit/Cargo.toml index ded83651..94d6aa27 100644 --- a/crates/bashkit/Cargo.toml +++ b/crates/bashkit/Cargo.toml @@ -30,6 +30,16 @@ regex = { workspace = true } # Glob matching globset = { workspace = true } +# HTTP client (for curl) - optional, enabled with network feature +reqwest = { workspace = true, optional = true } + +# URL parsing +url = "2" + +[features] +default = [] +network = ["reqwest"] + [dev-dependencies] tokio-test = { workspace = true } pretty_assertions = { workspace = true } diff --git a/crates/bashkit/src/error.rs b/crates/bashkit/src/error.rs index 8b0b1576..cfbad505 100644 --- a/crates/bashkit/src/error.rs +++ b/crates/bashkit/src/error.rs @@ -28,4 +28,8 @@ pub enum Error { /// Resource limit exceeded. #[error("resource limit exceeded: {0}")] ResourceLimit(#[from] LimitExceeded), + + /// Network error. + #[error("network error: {0}")] + Network(String), } diff --git a/crates/bashkit/src/lib.rs b/crates/bashkit/src/lib.rs index cf67f7d5..92c4510e 100644 --- a/crates/bashkit/src/lib.rs +++ b/crates/bashkit/src/lib.rs @@ -22,12 +22,17 @@ mod error; mod fs; mod interpreter; mod limits; +mod network; mod parser; pub use error::{Error, Result}; pub use fs::{FileSystem, InMemoryFs, MountableFs, OverlayFs}; pub use interpreter::ExecResult; pub use limits::{ExecutionCounters, ExecutionLimits, LimitExceeded}; +pub use network::NetworkAllowlist; + +#[cfg(feature = "network")] +pub use network::HttpClient; use std::collections::HashMap; use std::path::PathBuf; diff --git a/crates/bashkit/src/network/allowlist.rs b/crates/bashkit/src/network/allowlist.rs new file mode 100644 index 00000000..9e520439 --- /dev/null +++ b/crates/bashkit/src/network/allowlist.rs @@ -0,0 +1,330 @@ +//! URL allowlist for network access control +//! +//! Provides a whitelist-based security model for network access. + +use std::collections::HashSet; +use url::Url; + +/// Network allowlist configuration. +/// +/// URLs must match an entry in the allowlist to be accessed. +/// An empty allowlist means all URLs are blocked. +#[derive(Debug, Clone, Default)] +pub struct NetworkAllowlist { + /// URL patterns that are allowed + /// Format: "scheme://host[:port][/path]" + /// Examples: "https://api.example.com", "https://example.com/api" + patterns: HashSet, + + /// If true, allow all URLs (dangerous - use only for testing) + allow_all: bool, +} + +/// Result of matching a URL against the allowlist +#[derive(Debug, Clone, PartialEq)] +pub enum UrlMatch { + /// URL is allowed + Allowed, + /// URL is blocked (not in allowlist) + Blocked { reason: String }, + /// URL is invalid + Invalid { reason: String }, +} + +impl NetworkAllowlist { + /// Create a new empty allowlist (blocks all URLs) + pub fn new() -> Self { + Self::default() + } + + /// Create an allowlist that allows all URLs. + /// + /// # Warning + /// + /// This is dangerous and should only be used for testing or + /// when the script is fully trusted. + pub fn allow_all() -> Self { + Self { + patterns: HashSet::new(), + allow_all: true, + } + } + + /// Add a URL pattern to the allowlist. + /// + /// # Pattern Format + /// + /// Patterns can be: + /// - Full URLs: "https://api.example.com/v1" + /// - Host only: "https://example.com" + /// - With port: "http://localhost:8080" + /// + /// A pattern matches if the requested URL's scheme, host, and port match, + /// and the requested path starts with the pattern's path (if specified). + pub fn allow(mut self, pattern: impl Into) -> Self { + self.patterns.insert(pattern.into()); + self + } + + /// Add multiple URL patterns to the allowlist. + pub fn allow_many(mut self, patterns: impl IntoIterator>) -> Self { + for pattern in patterns { + self.patterns.insert(pattern.into()); + } + self + } + + /// Check if a URL is allowed. + pub fn check(&self, url: &str) -> UrlMatch { + // Allow all if configured + if self.allow_all { + return UrlMatch::Allowed; + } + + // Empty allowlist blocks everything + if self.patterns.is_empty() { + return UrlMatch::Blocked { + reason: "no URLs are allowed (empty allowlist)".to_string(), + }; + } + + // Parse the URL + let parsed = match Url::parse(url) { + Ok(u) => u, + Err(e) => { + return UrlMatch::Invalid { + reason: format!("invalid URL: {}", e), + } + } + }; + + // Check against each pattern + for pattern in &self.patterns { + if self.matches_pattern(&parsed, pattern) { + return UrlMatch::Allowed; + } + } + + UrlMatch::Blocked { + reason: format!("URL not in allowlist: {}", url), + } + } + + /// Check if a parsed URL matches a pattern. + fn matches_pattern(&self, url: &Url, pattern: &str) -> bool { + // Parse the pattern as a URL + let pattern_url = match Url::parse(pattern) { + Ok(u) => u, + Err(_) => return false, + }; + + // Check scheme + if url.scheme() != pattern_url.scheme() { + return false; + } + + // Check host + match (url.host_str(), pattern_url.host_str()) { + (Some(url_host), Some(pattern_host)) => { + if url_host != pattern_host { + return false; + } + } + _ => return false, + } + + // Check port (use default ports if not specified) + let url_port = url.port_or_known_default(); + let pattern_port = pattern_url.port_or_known_default(); + if url_port != pattern_port { + return false; + } + + // Check path prefix (pattern path must be prefix of URL path) + let pattern_path = pattern_url.path(); + let url_path = url.path(); + + // If pattern path is "/" or empty, match any path + if pattern_path == "/" || pattern_path.is_empty() { + return true; + } + + // URL path must start with pattern path + if !url_path.starts_with(pattern_path) { + return false; + } + + // If pattern path doesn't end with /, ensure we're at a path boundary + if !pattern_path.ends_with('/') && url_path.len() > pattern_path.len() { + let next_char = url_path.chars().nth(pattern_path.len()); + if next_char != Some('/') && next_char != Some('?') && next_char != Some('#') { + return false; + } + } + + true + } + + /// Check if network access is enabled (has any patterns or allow_all) + pub fn is_enabled(&self) -> bool { + self.allow_all || !self.patterns.is_empty() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_allowlist_blocks_all() { + let allowlist = NetworkAllowlist::new(); + assert!(matches!( + allowlist.check("https://example.com"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_allow_all() { + let allowlist = NetworkAllowlist::allow_all(); + assert_eq!( + allowlist.check("https://example.com"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("http://localhost:8080/anything"), + UrlMatch::Allowed + ); + } + + #[test] + fn test_exact_host_match() { + let allowlist = NetworkAllowlist::new().allow("https://api.example.com"); + + assert_eq!( + allowlist.check("https://api.example.com"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/v1/users"), + UrlMatch::Allowed + ); + + // Different scheme + assert!(matches!( + allowlist.check("http://api.example.com"), + UrlMatch::Blocked { .. } + )); + + // Different host + assert!(matches!( + allowlist.check("https://other.example.com"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_path_prefix_match() { + let allowlist = NetworkAllowlist::new().allow("https://api.example.com/v1"); + + // Matches path prefix + assert_eq!( + allowlist.check("https://api.example.com/v1"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/v1/"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://api.example.com/v1/users"), + UrlMatch::Allowed + ); + + // Does not match different path + assert!(matches!( + allowlist.check("https://api.example.com/v2"), + UrlMatch::Blocked { .. } + )); + + // Does not match partial path component + assert!(matches!( + allowlist.check("https://api.example.com/v10"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_port_matching() { + let allowlist = NetworkAllowlist::new().allow("http://localhost:8080"); + + assert_eq!( + allowlist.check("http://localhost:8080/api"), + UrlMatch::Allowed + ); + + // Different port + assert!(matches!( + allowlist.check("http://localhost:3000"), + UrlMatch::Blocked { .. } + )); + + // Default HTTP port + assert!(matches!( + allowlist.check("http://localhost"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_multiple_patterns() { + let allowlist = NetworkAllowlist::new() + .allow("https://api.example.com") + .allow("https://cdn.example.com") + .allow("http://localhost:3000"); + + assert_eq!( + allowlist.check("https://api.example.com/v1"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("https://cdn.example.com/assets/logo.png"), + UrlMatch::Allowed + ); + assert_eq!( + allowlist.check("http://localhost:3000/health"), + UrlMatch::Allowed + ); + + assert!(matches!( + allowlist.check("https://evil.com"), + UrlMatch::Blocked { .. } + )); + } + + #[test] + fn test_invalid_url() { + let allowlist = NetworkAllowlist::new().allow("https://example.com"); + + assert!(matches!( + allowlist.check("not a url"), + UrlMatch::Invalid { .. } + )); + } + + #[test] + fn test_is_enabled() { + let empty = NetworkAllowlist::new(); + assert!(!empty.is_enabled()); + + let with_pattern = NetworkAllowlist::new().allow("https://example.com"); + assert!(with_pattern.is_enabled()); + + let allow_all = NetworkAllowlist::allow_all(); + assert!(allow_all.is_enabled()); + } +} diff --git a/crates/bashkit/src/network/client.rs b/crates/bashkit/src/network/client.rs new file mode 100644 index 00000000..b4fbd856 --- /dev/null +++ b/crates/bashkit/src/network/client.rs @@ -0,0 +1,192 @@ +//! HTTP client for secure network access +//! +//! Provides a sandboxed HTTP client that respects the allowlist. + +use reqwest::Client; +use std::time::Duration; + +use super::allowlist::{NetworkAllowlist, UrlMatch}; +use crate::error::{Error, Result}; + +/// HTTP client with allowlist-based access control. +pub struct HttpClient { + client: Client, + allowlist: NetworkAllowlist, +} + +/// HTTP request method +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Method { + Get, + Post, + Put, + Delete, + Head, + Patch, +} + +impl Method { + fn as_reqwest(self) -> reqwest::Method { + match self { + Method::Get => reqwest::Method::GET, + Method::Post => reqwest::Method::POST, + Method::Put => reqwest::Method::PUT, + Method::Delete => reqwest::Method::DELETE, + Method::Head => reqwest::Method::HEAD, + Method::Patch => reqwest::Method::PATCH, + } + } +} + +/// HTTP response +#[derive(Debug)] +pub struct Response { + /// HTTP status code + pub status: u16, + /// Response headers (key-value pairs) + pub headers: Vec<(String, String)>, + /// Response body + pub body: Vec, +} + +impl Response { + /// Get the body as a UTF-8 string (lossy) + pub fn body_string(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + /// Check if the response was successful (2xx status) + pub fn is_success(&self) -> bool { + (200..300).contains(&self.status) + } +} + +impl HttpClient { + /// Create a new HTTP client with the given allowlist. + pub fn new(allowlist: NetworkAllowlist) -> Self { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("bashkit/0.1.0") + .build() + .expect("failed to build HTTP client"); + + Self { client, allowlist } + } + + /// Create a client with custom timeout. + pub fn with_timeout(allowlist: NetworkAllowlist, timeout: Duration) -> Self { + let client = Client::builder() + .timeout(timeout) + .user_agent("bashkit/0.1.0") + .build() + .expect("failed to build HTTP client"); + + Self { client, allowlist } + } + + /// Make a GET request. + pub async fn get(&self, url: &str) -> Result { + self.request(Method::Get, url, None).await + } + + /// Make a POST request with optional body. + pub async fn post(&self, url: &str, body: Option<&[u8]>) -> Result { + self.request(Method::Post, url, body).await + } + + /// Make a PUT request with optional body. + pub async fn put(&self, url: &str, body: Option<&[u8]>) -> Result { + self.request(Method::Put, url, body).await + } + + /// Make a DELETE request. + pub async fn delete(&self, url: &str) -> Result { + self.request(Method::Delete, url, None).await + } + + /// Make an HTTP request. + pub async fn request( + &self, + method: Method, + url: &str, + body: Option<&[u8]>, + ) -> Result { + // Check allowlist + match self.allowlist.check(url) { + UrlMatch::Allowed => {} + UrlMatch::Blocked { reason } => { + return Err(Error::Network(format!("access denied: {}", reason))); + } + UrlMatch::Invalid { reason } => { + return Err(Error::Network(format!("invalid URL: {}", reason))); + } + } + + // Build request + let mut request = self.client.request(method.as_reqwest(), url); + + if let Some(body_data) = body { + request = request.body(body_data.to_vec()); + } + + // Send request + let response = request + .send() + .await + .map_err(|e| Error::Network(format!("request failed: {}", e)))?; + + // Extract response data + let status = response.status().as_u16(); + let headers: Vec<(String, String)> = response + .headers() + .iter() + .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) + .collect(); + + let body = response + .bytes() + .await + .map_err(|e| Error::Network(format!("failed to read response: {}", e)))? + .to_vec(); + + Ok(Response { + status, + headers, + body, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_blocked_by_empty_allowlist() { + let client = HttpClient::new(NetworkAllowlist::new()); + + let result = client.get("https://example.com").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("access denied")); + } + + #[tokio::test] + async fn test_blocked_by_allowlist() { + let allowlist = NetworkAllowlist::new().allow("https://allowed.com"); + let client = HttpClient::new(allowlist); + + let result = client.get("https://blocked.com").await; + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("access denied")); + } + + // Note: Integration tests that actually make network requests + // should be in a separate test file and marked with #[ignore] + // to avoid network dependencies in unit tests. +} diff --git a/crates/bashkit/src/network/mod.rs b/crates/bashkit/src/network/mod.rs new file mode 100644 index 00000000..d228cf0f --- /dev/null +++ b/crates/bashkit/src/network/mod.rs @@ -0,0 +1,20 @@ +//! Network layer for BashKit +//! +//! Provides secure network access with URL allowlists. +//! +//! # Security Model +//! +//! - Network access is disabled by default +//! - URLs must match an entry in the allowlist +//! - Allowlist entries can match by scheme, host, and path prefix + +mod allowlist; + +#[cfg(feature = "network")] +mod client; + +#[allow(unused_imports)] // UrlMatch is used internally but may not be exported +pub use allowlist::{NetworkAllowlist, UrlMatch}; + +#[cfg(feature = "network")] +pub use client::HttpClient;