diff --git a/Cargo.toml b/Cargo.toml index 8858d96c..3a12c09f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,8 @@ rustdoc-args = ["--cfg", "docsrs"] default = [ "simple_http", "simple_tcp" ] # A bare-minimum HTTP transport. simple_http = [ "base64" ] +# A transport that uses `minreq` as the HTTP client. +minreq_http = [ "base64", "minreq" ] # Basic transport over a raw TcpListener simple_tcp = [] # Basic transport over a raw UnixStream @@ -32,7 +34,8 @@ serde = { version = "1", features = ["derive"] } serde_json = { version = "1", features = [ "raw_value" ] } base64 = { version = "0.13.0", optional = true } +minreq = { version = "2.7.0", features = ["json-using-serde"], optional = true } socks = { version = "0.3.4", optional = true} [workspace] -members = ["fuzz"] +members = ["fuzz", "integration_test"] diff --git a/integration_test/Cargo.toml b/integration_test/Cargo.toml new file mode 100644 index 00000000..ab7d3575 --- /dev/null +++ b/integration_test/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "integration_test" +version = "0.1.0" +authors = ["Steven Roose , Tobin C. Harding "] +edition = "2018" + +[dependencies] +jsonrpc = { path = "..", features = ["minreq_http"] } +lazy_static = "1.4.0" +log = "0.4" +backtrace = "0.3.50" +serde_json = { version = "1.0", features = ["raw_value"] } diff --git a/integration_test/run.sh b/integration_test/run.sh new file mode 100755 index 00000000..c894902f --- /dev/null +++ b/integration_test/run.sh @@ -0,0 +1,52 @@ +#!/bin/sh + +BITCOIND_PATH="${BITCOIND_PATH:-bitcoind}" +TESTDIR=/tmp/rust_bitcoincore_rpc_test + +rm -rf ${TESTDIR} +mkdir -p ${TESTDIR}/1 ${TESTDIR}/2 + +# To kill any remaining open bitcoind. +killall -9 bitcoind + +${BITCOIND_PATH} -regtest \ + -datadir=${TESTDIR}/1 \ + -port=12348 \ + -server=0 \ + -printtoconsole=0 & +PID1=$! + +# Make sure it's listening on its p2p port. +sleep 3 + +BLOCKFILTERARG="" +if ${BITCOIND_PATH} -version | grep -q "v\(2\|0\.19\|0.2\)"; then + BLOCKFILTERARG="-blockfilterindex=1" +fi + +FALLBACKFEEARG="" +if ${BITCOIND_PATH} -version | grep -q "v\(2\|0\.2\)"; then + FALLBACKFEEARG="-fallbackfee=0.00001000" +fi + +${BITCOIND_PATH} -regtest $BLOCKFILTERARG $FALLBACKFEEARG \ + -datadir=${TESTDIR}/2 \ + -connect=127.0.0.1:12348 \ + -rpcport=12349 \ + -server=1 \ + -txindex=1 \ + -printtoconsole=0 & +PID2=$! + +# Let it connect to the other node. +sleep 5 + +RPC_URL=http://localhost:12349 \ + RPC_COOKIE=${TESTDIR}/2/regtest/.cookie \ + cargo run + +RESULT=$? + +kill -9 $PID1 $PID2 + +exit $RESULT diff --git a/integration_test/src/main.rs b/integration_test/src/main.rs new file mode 100644 index 00000000..43e7181f --- /dev/null +++ b/integration_test/src/main.rs @@ -0,0 +1,149 @@ +//! # rust-bitcoincore-rpc integration test +//! +//! The test methods are named to mention the methods tested. +//! Individual test methods don't use any methods not tested before or +//! mentioned in the test method name. +//! +//! The goal of this test is not to test the correctness of the server, but +//! to test the serialization of arguments and deserialization of responses. +//! + +#![deny(unused)] +#![allow(deprecated)] + +#[macro_use] +extern crate lazy_static; + +use std::cell::RefCell; +use std::time::Duration; +use std::{fs, mem, panic}; + +use backtrace::Backtrace; + +use jsonrpc::http::minreq_http; +use jsonrpc::{Client, Request}; + +struct StdLogger; + +impl log::Log for StdLogger { + fn enabled(&self, metadata: &log::Metadata) -> bool { + metadata.target().contains("jsonrpc") || metadata.target().contains("bitcoincore_rpc") + } + + fn log(&self, record: &log::Record) { + if self.enabled(record.metadata()) { + println!("[{}][{}]: {}", record.level(), record.metadata().target(), record.args()); + } + } + + fn flush(&self) {} +} + +static LOGGER: StdLogger = StdLogger; + +fn get_rpc_url() -> String { + return std::env::var("RPC_URL").expect("RPC_URL must be set"); +} + +fn get_auth() -> (String, Option) { + if let Ok(cookie) = std::env::var("RPC_COOKIE") { + let contents = + fs::read_to_string(&cookie).expect(&format!("failed to read cookie file: {}", cookie)); + let mut split = contents.split(':'); + let user = split.next().expect("failed to get username from cookie file"); + let pass = split.next().map_or("".to_string(), |s| s.to_string()); + return (user.to_string(), Some(pass)); + } else if let Ok(user) = std::env::var("RPC_USER") { + return (user, std::env::var("RPC_PASS").ok()); + } else { + panic!("Either RPC_COOKIE or RPC_USER + RPC_PASS must be set."); + }; +} + +fn make_client() -> Client { + let (user, pass) = get_auth(); + let tp = minreq_http::Builder::new() + .timeout(Duration::from_secs(1)) + .url(&get_rpc_url()) + .unwrap() + .basic_auth(user, pass) + .build(); + Client::with_transport(tp) +} + +lazy_static! { + static ref CLIENT: Client = make_client(); +} + +thread_local! { + static LAST_PANIC: RefCell> = RefCell::new(None); +} + +/// Here we will collect all the results of the individual tests, preserving ordering. +/// Ideally this would be preset with capacity, but static prevents this. +static mut RESULTS: Vec<(&'static str, bool)> = Vec::new(); + +macro_rules! run_test { + ($method:ident) => { + println!("Running {}...", stringify!($method)); + let result = panic::catch_unwind(|| { + $method(&*CLIENT); + }); + if result.is_err() { + let (msg, bt) = LAST_PANIC.with(|b| b.borrow_mut().take()).unwrap(); + println!("{}", msg); + println!("{:?}", bt); + println!("--"); + } + + unsafe { + RESULTS.push((stringify!($method), result.is_ok())); + } + }; +} + +fn main() { + log::set_logger(&LOGGER).map(|()| log::set_max_level(log::LevelFilter::max())).unwrap(); + + // let default_hook = std::panic::take_hook() + std::panic::set_hook(Box::new(|panic_info| { + let bt = Backtrace::new(); + LAST_PANIC.with(move |b| b.borrow_mut().replace((panic_info.to_string(), bt))); + })); + + run_test!(test_get_network_info); + + // Print results + println!(""); + println!(""); + println!("Summary:"); + let mut error_count = 0; + for (name, success) in mem::replace(unsafe { &mut RESULTS }, Vec::new()).into_iter() { + if !success { + println!(" - {}: FAILED", name); + error_count += 1; + } else { + println!(" - {}: PASSED", name); + } + } + + println!(""); + + if error_count == 0 { + println!("All tests succesful!"); + } else { + println!("{} tests failed", error_count); + std::process::exit(1); + } +} + +fn test_get_network_info(cl: &Client) { + let request = Request { + method: "getnetworkinfo".into(), + params: &[], + id: serde_json::json!(1), + jsonrpc: Some("2.0"), + }; + + let _ = cl.send_request(request).unwrap(); +} diff --git a/src/http/minreq_http.rs b/src/http/minreq_http.rs new file mode 100644 index 00000000..da46fea8 --- /dev/null +++ b/src/http/minreq_http.rs @@ -0,0 +1,217 @@ +//! This module implements the [`crate::client::Transport`] trait using [`minreq`] +//! as the underlying HTTP transport. +//! +//! [minreq]: + +use std::time::Duration; +use std::{error, fmt}; + +use crate::client::Transport; +use crate::{Request, Response}; + +const DEFAULT_URL: &str = "http://localhost"; +const DEFAULT_PORT: u16 = 8332; // the default RPC port for bitcoind. +const DEFAULT_TIMEOUT_SECONDS: u64 = 15; + +/// An HTTP transport that uses [`minreq`] and is useful for running a bitcoind RPC client. +#[derive(Clone, Debug)] +pub struct MinreqHttpTransport { + /// URL of the RPC server. + url: String, + /// timeout only supports second granularity. + timeout: Duration, + /// The value of the `Authorization` HTTP header, i.e., a base64 encoding of 'user:password'. + basic_auth: Option, +} + +impl Default for MinreqHttpTransport { + fn default() -> Self { + MinreqHttpTransport { + url: format!("{}:{}", DEFAULT_URL, DEFAULT_PORT), + timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECONDS), + basic_auth: None, + } + } +} + +impl MinreqHttpTransport { + /// Constructs a new [`MinreqHttpTransport`] with default parameters. + pub fn new() -> Self { + MinreqHttpTransport::default() + } + + /// Returns a builder for [`MinreqHttpTransport`]. + pub fn builder() -> Builder { + Builder::new() + } + + fn request(&self, req: impl serde::Serialize) -> Result + where + R: for<'a> serde::de::Deserialize<'a>, + { + let req = match &self.basic_auth { + Some(auth) => minreq::Request::new(minreq::Method::Post, &self.url) + .with_timeout(self.timeout.as_secs()) + .with_header("Authorization", auth) + .with_json(&req)?, + None => minreq::Request::new(minreq::Method::Post, &self.url) + .with_timeout(self.timeout.as_secs()) + .with_json(&req)?, + }; + + let resp = req.send()?; + let json = resp.json()?; + Ok(json) + } +} + +impl Transport for MinreqHttpTransport { + fn send_request(&self, req: Request) -> Result { + Ok(self.request(req)?) + } + + fn send_batch(&self, reqs: &[Request]) -> Result, crate::Error> { + Ok(self.request(reqs)?) + } + + fn fmt_target(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.url) + } +} + +/// Builder for simple bitcoind [`MinreqHttpTransport`]. +#[derive(Clone, Debug)] +pub struct Builder { + tp: MinreqHttpTransport, +} + +impl Builder { + /// Constructs a new [`Builder`] with default configuration and the URL to use. + pub fn new() -> Builder { + Builder { + tp: MinreqHttpTransport::new(), + } + } + + /// Sets the timeout after which requests will abort if they aren't finished. + pub fn timeout(mut self, timeout: Duration) -> Self { + self.tp.timeout = timeout; + self + } + + /// Sets the URL of the server to the transport. + pub fn url(mut self, url: &str) -> Result { + self.tp.url = url.to_owned(); + Ok(self) + } + + /// Adds authentication information to the transport. + pub fn basic_auth(mut self, user: String, pass: Option) -> Self { + let mut s = user; + s.push(':'); + if let Some(ref pass) = pass { + s.push_str(pass.as_ref()); + } + self.tp.basic_auth = Some(format!("Basic {}", &base64::encode(s.as_bytes()))); + self + } + + /// Adds authentication information to the transport using a cookie string ('user:pass'). + /// + /// Does no checking on the format of the cookie string, just base64 encodes whatever is passed in. + /// + /// # Examples + /// + /// ```no_run + /// # use jsonrpc::minreq_http::MinreqHttpTransport; + /// # use std::fs::{self, File}; + /// # use std::path::Path; + /// # let cookie_file = Path::new("~/.bitcoind/.cookie"); + /// let mut file = File::open(cookie_file).expect("couldn't open cookie file"); + /// let mut cookie = String::new(); + /// fs::read_to_string(&mut cookie).expect("couldn't read cookie file"); + /// let client = MinreqHttpTransport::builder().cookie_auth(cookie); + /// ``` + pub fn cookie_auth>(mut self, cookie: S) -> Self { + self.tp.basic_auth = Some(format!("Basic {}", &base64::encode(cookie.as_ref().as_bytes()))); + self + } + + /// Builds the final [`MinreqHttpTransport`]. + pub fn build(self) -> MinreqHttpTransport { + self.tp + } +} + +impl Default for Builder { + fn default() -> Self { + Builder::new() + } +} + +/// Error that can happen when sending requests. +#[derive(Debug)] +pub enum Error { + /// JSON parsing error. + Json(serde_json::Error), + /// Minreq error. + Minreq(minreq::Error), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> { + match *self { + Error::Json(ref e) => write!(f, "parsing JSON failed: {}", e), + Error::Minreq(ref e) => write!(f, "minreq: {}", e), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + use self::Error::*; + + match *self { + Json(ref e) => Some(e), + Minreq(ref e) => Some(e), + } + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Json(e) + } +} + +impl From for Error { + fn from(e: minreq::Error) -> Self { + Error::Minreq(e) + } +} + +impl From for crate::Error { + fn from(e: Error) -> crate::Error { + match e { + Error::Json(e) => crate::Error::Json(e), + e => crate::Error::Transport(Box::new(e)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Client; + + #[test] + fn construct() { + let tp = Builder::new() + .timeout(Duration::from_millis(100)) + .url("http://localhost:22") + .unwrap() + .basic_auth("user".to_string(), None) + .build(); + let _ = Client::with_transport(tp); + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 00000000..318d8c29 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,17 @@ +//! HTTP transport modules. + +#[cfg(feature = "simple_http")] +pub mod simple_http; + +#[cfg(feature = "minreq_http")] +pub mod minreq_http; + +/// The default TCP port to use for connections. +/// Set to 8332, the default RPC port for bitcoind. +pub const DEFAULT_PORT: u16 = 8332; + +/// The Default SOCKS5 Port to use for proxy connection. +/// Set to 9050, the default RPC port for tor. +// Currently only used by `simple_http` module, here for consistency. +#[cfg(feature = "proxy")] +pub const DEFAULT_PROXY_PORT: u16 = 9050; diff --git a/src/simple_http.rs b/src/http/simple_http.rs similarity index 99% rename from src/simple_http.rs rename to src/http/simple_http.rs index 8314f6d5..c338cceb 100644 --- a/src/simple_http.rs +++ b/src/http/simple_http.rs @@ -15,16 +15,11 @@ use std::time::Duration; use std::{error, fmt, io, net, num}; use crate::client::Transport; +use crate::http::DEFAULT_PORT; +#[cfg(feature = "proxy")] +use crate::http::DEFAULT_PROXY_PORT; use crate::{Request, Response}; -/// The default TCP port to use for connections. -/// Set to 8332, the default RPC port for bitcoind. -pub const DEFAULT_PORT: u16 = 8332; - -/// The Default SOCKS5 Port to use for proxy connection. -/// Set to 9050, the default RPC port for tor. -pub const DEFAULT_PROXY_PORT: u16 = 9050; - /// Absolute maximum content length allowed before cutting off the response. const FINAL_RESP_ALLOC: u64 = 1024 * 1024 * 1024; diff --git a/src/lib.rs b/src/lib.rs index a53e1259..84293fa6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,9 +19,13 @@ pub extern crate base64; pub mod client; pub mod error; +pub mod http; #[cfg(feature = "simple_http")] -pub mod simple_http; +pub use http::simple_http; + +#[cfg(feature = "minreq_http")] +pub use http::minreq_http; #[cfg(feature = "simple_tcp")] pub mod simple_tcp;