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
38 changes: 38 additions & 0 deletions lib/wreq_ruby/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,35 @@ class Client
# including self-signed or expired ones. Should only be disabled
# for testing purposes.
#
# @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. Must contain at least one
# +-----BEGIN CERTIFICATE-----+ block.
# 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+).
# 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.
# Must contain at least one +-----BEGIN CERTIFICATE-----+ block.
# 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 @@ -245,6 +274,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
98 changes: 96 additions & 2 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,18 @@ pub mod resp;
use std::{net::IpAddr, time::Duration};

use ::serde::Deserialize;
use magnus::{Module, Object, RModule, Ruby, TryConvert, Value, function, method, typed_data::Obj};
use magnus::{
Module, Object, RModule, RString, Ruby, TryConvert, Value, function, method, typed_data::Obj,
value::ReprValue,
};
use wreq::Proxy;

use crate::{
arch::{ProcessLocal, SUPPORTS_INTERFACE, SUPPORTS_TCP_USER_TIMEOUT},
client::{req::execute_request, resp::Response},
cookie::Jar,
emulate::Emulation,
error::wreq_error,
error::{argument_error, option_value_error, wreq_error},
extractor::Extractor,
gvl,
header::{Headers, OrigHeaders, UserAgent},
Expand Down Expand Up @@ -95,6 +98,16 @@ struct Builder {
// ========= TLS options =========
/// Whether to verify TLS certificates.
verify: 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 +165,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 +185,22 @@ impl Builder {
)
.finish()?;

// Convert path-like objects (Pathname, etc.) for CA file options.
if let Some(value) = options.convert_present::<Value>("ca_file")?
&& !value.is_nil()
{
builder.ca_file.set(Some(
convert_path(value).map_err(|e| option_value_error("ca_file", e))?,
));
}
if let Some(value) = options.convert_present::<Value>("additional_ca_file")?
&& !value.is_nil()
{
builder.additional_ca_file.set(Some(
convert_path(value).map_err(|e| option_value_error("additional_ca_file", e))?,
));
}

extract_native_option!(
options,
builder,
Expand All @@ -183,6 +224,16 @@ impl Builder {
}
}

/// Convert a Ruby value to a file-system path `String`.
///
/// Accepts a plain `String` or any object responding to `to_path` (e.g. `Pathname`).
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)
}

// ===== impl Client =====

impl Client {
Expand Down Expand Up @@ -225,6 +276,39 @@ impl Client {
fn build(ruby: &Ruby, mut params: Builder) -> Result<wreq::Client, magnus::Error> {
rt::ensure_current(ruby)?;

// Resolve the CA option (at most one is set, enforced by reject_conflicts).
// Read file contents before releasing the GVL so I/O errors become ArgumentError.
// The bool indicates whether to augment system defaults (true) or replace them (false).
let ca_pem_data: Option<(Vec<u8>, bool)> = if let Some(path) = params.ca_file.take() {
let data = std::fs::read(&path)
.map_err(|e| argument_error(ruby, format!("ca_file: cannot read {path}: {e}")))?;
Some((data, false))
} else if let Some(pem) = params.ca_pem.take() {
Some((pem.into_bytes(), false))
} else if let Some(path) = params.additional_ca_file.take() {
let data = std::fs::read(&path).map_err(|e| {
argument_error(ruby, format!("additional_ca_file: cannot read {path}: {e}"))
})?;
Some((data, true))
} else {
params
.additional_ca_pem
.take()
.map(|pem| (pem.into_bytes(), true))
};

// Validate that PEM data contains at least one certificate.
if let Some((ref pem_bytes, _)) = ca_pem_data
&& !pem_bytes
.windows(27)
.any(|w| w == b"-----BEGIN CERTIFICATE-----")
{
return Err(argument_error(
ruby,
"PEM data does not contain any certificates",
));
}

let result = gvl::nogvl(|| {
let mut builder = wreq::Client::builder();

Expand Down Expand Up @@ -359,6 +443,16 @@ impl Client {
// TLS options.
apply_option!(set_if_some, builder, params.verify, tls_cert_verification);

// Custom CA certificate store.
if let Some((pem_bytes, augment)) = ca_pem_data {
let mut store_builder = wreq::tls::trust::CertStore::builder();
if augment {
store_builder = store_builder.set_default_paths();
}
let store = store_builder.add_stack_pem_certs(&pem_bytes).build()?;
builder = builder.tls_cert_store(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
Loading