Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions lib/wreq_ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,38 @@ class Client
# {Wreq::TlsInfo} object. Disabled by default because retaining
# certificate data uses additional memory.
#
# @param ca_file [String, #to_path, nil] Path to a PEM-encoded CA bundle
# that **replaces** the default system trust store. Only certificates
# signed by CAs in this file will be trusted. Accepts any object
# responding to +to_path+ (e.g. +Pathname+). The file is read during
# client construction; a missing or unreadable file raises immediately.
# Mutually exclusive with +ca_pem+, +additional_ca_file+, and
# +additional_ca_pem+.
#
# @param ca_pem [String, nil] Raw PEM-encoded certificate content that
# **replaces** the default system trust store. Useful when certificate
# material comes from a secret store or environment variable rather
# than a file on disk. Invalid PEM that the native store rejects raises
# {Wreq::TlsError} during client construction.
# Mutually exclusive with +ca_file+, +additional_ca_file+, and
# +additional_ca_pem+.
#
# @param additional_ca_file [String, #to_path, nil] Path to a PEM-encoded
# CA bundle loaded **alongside** the default system trust store.
# Public roots remain available; the supplied certificates are added
# on top. Accepts any object responding to +to_path+ (e.g. +Pathname+).
# The file is read during client construction; a missing or unreadable
# file raises immediately. Mutually exclusive with +ca_file+, +ca_pem+,
# and +additional_ca_pem+.
#
# @param additional_ca_pem [String, nil] Raw PEM-encoded certificate
# content loaded **alongside** the default system trust store. Public
# roots remain available; the supplied certificates are added on top.
# Invalid PEM that the native store rejects raises
# {Wreq::TlsError} during client construction.
# Mutually exclusive with +ca_file+, +ca_pem+, and
# +additional_ca_file+.
#
# @param no_proxy [Boolean, nil] Disable use of any configured proxy
# for this client, even if proxy settings are detected from the
# environment.
Expand Down Expand Up @@ -254,6 +286,15 @@ class Client
# verify: false, # WARNING: Do not use in production!
# timeout: 5
# )
# @example Client with custom CA (replace system roots)
# client = Wreq::Client.new(
# ca_file: "/etc/ssl/private/internal-ca.pem"
# )
#
# @example Client with additional CA (augment system roots)
# client = Wreq::Client.new(
# additional_ca_pem: File.binread("/etc/ssl/certs/extra-ca.pem")
# )
def self.new(**options)
end

Expand Down
45 changes: 44 additions & 1 deletion src/client.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod body;
mod ca;
mod param;
mod query;
mod req;
Expand All @@ -15,12 +16,13 @@ use crate::{
client::{req::execute_request, resp::Response},
cookie::Jar,
emulate::Emulation,
error::wreq_error,
error::{option_value_error, wreq_error},
extractor::Extractor,
gvl,
header::{Headers, OrigHeaders, UserAgent},
http::Method,
options::{NativeOption, Options},
utils::convert_path,
};

/// A builder for `Client`.
Expand Down Expand Up @@ -96,6 +98,16 @@ struct Builder {
verify: Option<bool>,
/// Whether to retain peer certificate data on responses.
tls_info: Option<bool>,
/// Path to a PEM CA bundle that replaces the default trust store.
#[serde(default)]
ca_file: NativeOption<String>,
/// Raw PEM certificate content that replaces the default trust store.
ca_pem: Option<String>,
/// Path to a PEM CA bundle added alongside the default trust store.
#[serde(default)]
additional_ca_file: NativeOption<String>,
/// Raw PEM certificate content added alongside the default trust store.
additional_ca_pem: Option<String>,

// ========= Network options =========
/// Whether to disable the proxy for the client.
Expand Down Expand Up @@ -152,6 +164,18 @@ impl Builder {
(stringify!(proxy), options.is_non_nil(stringify!(proxy))),
(stringify!(no_proxy), builder.no_proxy == Some(true)),
])
.reject_conflicts([
(stringify!(ca_file), options.is_non_nil(stringify!(ca_file))),
(stringify!(ca_pem), builder.ca_pem.is_some()),
(
stringify!(additional_ca_file),
options.is_non_nil(stringify!(additional_ca_file)),
),
(
stringify!(additional_ca_pem),
builder.additional_ca_pem.is_some(),
),
])
.require_when_present(
stringify!(max_redirects),
builder.max_redirects.is_some(),
Expand All @@ -160,6 +184,14 @@ impl Builder {
)
.finish()?;

extract_native_option!(
options, builder, ca_file,
Value =>? convert_path
);
extract_native_option!(
options, builder, additional_ca_file,
Value =>? convert_path
);
extract_native_option!(
options,
builder,
Expand Down Expand Up @@ -221,6 +253,8 @@ impl Client {
.take()
.map(|jar| jar.clone_store(ruby))
.transpose()?;

let mut ca = ca::resolve(ruby, &mut params)?;
let result = gvl::nogvl(|| {
let mut builder = wreq::Client::builder();

Expand Down Expand Up @@ -351,6 +385,15 @@ impl Client {
apply_option!(set_if_some, builder, params.verify, tls_cert_verification);
apply_option!(set_if_some, builder, params.tls_info, tls_info);

// Custom CA certificate store.
apply_option!(
set_if_some_try_map,
builder,
ca,
tls_cert_store,
ca::into_cert_store
);

// Network options.
apply_option!(set_if_some, builder, params.proxy, proxy);
apply_option!(set_if_true, builder, params.no_proxy, no_proxy, false);
Expand Down
70 changes: 70 additions & 0 deletions src/client/ca.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! Custom CA trust-store resolution for [`super::Client`] construction.

use magnus::Ruby;

use crate::error::argument_error;

use super::Builder;

/// PEM material used to build a custom [`wreq::tls::trust::CertStore`].
pub(super) struct CaPem {
/// PEM-encoded certificate bytes (one cert or a bundle).
pub(super) pem: Vec<u8>,
/// When `true`, keep system roots and add these certs.
/// When `false`, replace the default trust store.
pub(super) augment: bool,
}

/// Resolve the mutually exclusive CA client options into PEM bytes.
///
/// Reads `*_file` options here (with the GVL held) so missing/unreadable
/// files raise `ArgumentError` before native client construction.
pub(super) fn resolve(ruby: &Ruby, params: &mut Builder) -> Result<Option<CaPem>, magnus::Error> {
let mut pem: Option<Vec<u8>> = None;
let mut augment_flag: Option<bool> = None;

// ---- path options: ca_file / additional_ca_file ----
if let Some((path, additional)) = params
.ca_file
.take()
.map(|path| (path, false))
.or_else(|| params.additional_ca_file.take().map(|path| (path, true)))
{
let name = if additional {
"additional_ca_file"
} else {
"ca_file"
};
pem =
Some(std::fs::read(&path).map_err(|_| {
argument_error(ruby, format!("{name}: cannot read certificate file"))
})?);
augment_flag = Some(additional);
}

// ---- string options: ca_pem / additional_ca_pem ----
if let Some((value, additional)) = params
.ca_pem
.take()
.map(|value| (value, false))
.or_else(|| params.additional_ca_pem.take().map(|value| (value, true)))
{
pem = Some(value.into_bytes());
augment_flag = Some(additional);
}

if let (Some(pem), Some(augment)) = (pem, augment_flag) {
return Ok(Some(CaPem { pem, augment }));
}

Ok(None)
}

/// Attach a custom certificate store to the native client builder.
pub(super) fn into_cert_store(ca: CaPem) -> wreq::Result<wreq::tls::trust::CertStore> {
let mut store_builder = wreq::tls::trust::CertStore::builder();
if ca.augment {
store_builder = store_builder.set_default_paths();
}
store_builder.add_stack_pem_certs(&ca.pem).build()
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod options;
mod rt;
mod serde;
mod tls;
mod utils;

use magnus::{Error, Module, Ruby, Value};

Expand Down
13 changes: 13 additions & 0 deletions src/macros.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@ macro_rules! apply_option {
$builder = $builder.$method();
}
};
(set_if_some_try_map, $builder:expr, $option:expr, $method:ident, $transform:expr) => {
if let Some(value) = $option.take() {
$builder = $builder.$method($transform(value)?);
}
};
}

/// Convert a Ruby-native option whose field name is also its keyword name.
Expand All @@ -51,6 +56,14 @@ macro_rules! extract_native_option {
.$field
.set($options.convert::<$source>(stringify!($field))?.map($map));
}};
($options:expr, $target:expr, $field:ident, $source:ty =>? $map:expr) => {{
$target.$field.set(
$options
.convert::<$source>(stringify!($field))?
.map(|v| ($map)(v).map_err(|e| option_value_error(stringify!($field), e)))
.transpose()?,
);
}};
}

macro_rules! define_ruby_enum {
Expand Down
13 changes: 13 additions & 0 deletions src/utils.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
//! Shared helpers

use magnus::{RString, TryConvert, Value, value::ReprValue};

/// Convert a Ruby value to a file-system path `String`.
///
/// Accepts a plain `String` or any object responding to `to_path` (e.g. `Pathname`).
pub(crate) fn convert_path(value: Value) -> Result<String, magnus::Error> {
if let Ok(path) = value.funcall::<_, _, RString>("to_path", ()) {
return path.to_string();
}
String::try_convert(value)
}
Loading