Skip to content

Commit 6eebfb7

Browse files
committed
refactor(rust): hand the transport the certificates rather than paths to them
Opening a file is integration with a deployment, the same as reading an environment variable, which the previous change already moved out. `ClientConfigArgs::cert_pem`, `key_pem` and `ca_cert` now carry the PEM itself; their names already said so. `from_config_args` calls no `std::fs`, and `ConfigError::Io` goes with it. `armonik` opens the files that `GrpcClient__CertPem` and its two neighbours name, so nothing changes for a deployment. `key_pem` becomes a `Secret`, as the proxy password is: a private key now sits in a struct that derives `Debug` and `Serialize`. The compiler found the first consequence on its own, which is the point of the type: the tracing span recorded `cert_pem` and `key_pem`, harmless while they were paths. It records whether each was supplied, and nothing else. The two tests that asserted a missing file follow the behaviour to `armonik`, against `read_pem_file`, which takes the variable name so they can borrow one of their own rather than a `GrpcClient__*` the mock-backed tests depend on. In their place the transport pins what a caller who still passes a path now gets: a PEM error, for all three options, rather than this crate quietly opening whatever it points at. cargo test -p armonik-transport --all-features: 89 passed, 0 failed. cargo test -p armonik --all-features --lib client::env: 8 passed, 0 failed. The workspace, minus the `client::` tests that want the ArmoniK mock: 0 failed. clippy --workspace --all-features --all-targets -Dwarnings, cargo fmt --all --check, RUSTDOCFLAGS=-Dwarnings cargo doc --workspace --all-features: clean.
1 parent c6dea61 commit 6eebfb7

2 files changed

Lines changed: 102 additions & 72 deletions

File tree

packages/rust/armonik-transport/src/config.rs

Lines changed: 51 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -197,13 +197,13 @@ impl Clone for ClientConfig {
197197
pub struct ClientConfigArgs {
198198
/// Endpoint for sending requests
199199
pub endpoint: String,
200-
/// Path to the certificate file in pem format
200+
/// The client certificate itself, in PEM. Not a path: opening files is the caller's business.
201201
#[cfg_attr(feature = "serde", serde(default))]
202202
pub cert_pem: String,
203-
/// Path to the key file in pem format
203+
/// The client key itself, in PEM. Redacted wherever it is written; see [`Secret`].
204204
#[cfg_attr(feature = "serde", serde(default))]
205-
pub key_pem: String,
206-
/// Path to the Certificate Authority file in pem format
205+
pub key_pem: Secret,
206+
/// The Certificate Authority itself, in PEM.
207207
#[cfg_attr(feature = "serde", serde(default))]
208208
pub ca_cert: String,
209209
/// Allow unsafe connections to the endpoint (without SSL), defaults to false
@@ -277,9 +277,11 @@ impl ClientConfig {
277277
let _span = tracing::debug_span!(
278278
"ClientConfig",
279279
args.endpoint,
280-
args.cert_pem,
281-
args.key_pem,
282-
args.ca_cert,
280+
// The material itself now, not a path, so only its presence is recorded: a private key
281+
// must never reach a log, and a certificate would bury the span in PEM.
282+
cert_pem_set = !args.cert_pem.is_empty(),
283+
key_pem_set = !args.key_pem.is_empty(),
284+
ca_cert_set = !args.ca_cert.is_empty(),
283285
args.allow_unsafe_connection,
284286
args.override_target_name,
285287
args.connect_timeout,
@@ -304,9 +306,9 @@ impl ClientConfig {
304306

305307
let ClientConfigArgs {
306308
endpoint,
307-
cert_pem: cert_path,
308-
key_pem: key_path,
309-
ca_cert: cacert_path,
309+
cert_pem,
310+
key_pem,
311+
ca_cert,
310312
allow_unsafe_connection,
311313
override_target_name,
312314
connect_timeout,
@@ -327,25 +329,27 @@ impl ClientConfig {
327329
reuse_ports,
328330
} = args;
329331

330-
// Read CAcert file
331-
let cacert = if !cacert_path.is_empty() {
332-
let cacert_pem = std::fs::read_to_string(cacert_path.clone())
333-
.context(IoSnafu { path: cacert_path })?;
334-
Some(CertificateDer::from_pem_slice(cacert_pem.as_bytes()).context(TlsSnafu {})?)
335-
} else {
332+
let cacert = if ca_cert.is_empty() {
336333
None
334+
} else {
335+
Some(CertificateDer::from_pem_slice(ca_cert.as_bytes()).context(TlsSnafu {})?)
337336
};
338337

339-
// Read client cert and key files
340-
let identity = match (cert_path.as_str(), key_path.as_str()) {
341-
("", "") => None,
342-
("", _) | (_, "") => return IncompatibleOptionsSnafu{msg: format!("`cert_pem={cert_path}` and `key_pem={key_path}` must be either both empty or both set")}.fail(),
343-
(cert_path, key_path) => {
344-
let cert_pem =
345-
std::fs::read_to_string(cert_path).context(IoSnafu { path: cert_path })?;
346-
let key_pem = std::fs::read(key_path).context(IoSnafu { path: key_path })?;
347-
let cert = CertificateDer::from_pem_slice(cert_pem.as_bytes()).context(TlsSnafu {})?;
348-
let key = PrivateKeyDer::from_pem_slice(key_pem.as_slice()).context(TlsSnafu{})?;
338+
let identity = match (cert_pem.is_empty(), key_pem.is_empty()) {
339+
(true, true) => None,
340+
(true, false) | (false, true) => {
341+
return IncompatibleOptionsSnafu {
342+
msg: String::from(
343+
"`cert_pem` and `key_pem` must be either both empty or both set",
344+
),
345+
}
346+
.fail()
347+
}
348+
(false, false) => {
349+
let cert =
350+
CertificateDer::from_pem_slice(cert_pem.as_bytes()).context(TlsSnafu {})?;
351+
let key = PrivateKeyDer::from_pem_slice(key_pem.expose_secret().as_bytes())
352+
.context(TlsSnafu {})?;
349353

350354
Some((cert, key))
351355
}
@@ -674,15 +678,6 @@ pub enum ConfigError {
674678
#[snafu(implicit)]
675679
location: snafu::Location,
676680
},
677-
#[snafu(display("Could not read file `{path}` [{location}]"))]
678-
#[non_exhaustive]
679-
Io {
680-
#[snafu(source(from(std::io::Error, Box::new)))]
681-
source: Box<std::io::Error>,
682-
path: String,
683-
#[snafu(implicit)]
684-
location: snafu::Location,
685-
},
686681
#[snafu(display("{msg} [{location}]"))]
687682
#[non_exhaustive]
688683
IncompatibleOptions {
@@ -933,11 +928,11 @@ mod tests {
933928
#[test]
934929
fn half_an_identity_is_rejected_and_names_both_variables() {
935930
// Half an identity is silent on a plain-TLS endpoint and only surfaces as a rejected handshake
936-
// on an mTLS one. Neither path is read from disk before the check, so this needs no fixture.
937-
for (cert, key) in [("cert.pem", ""), ("", "key.pem")] {
931+
// on an mTLS one, so it is caught before either half is parsed.
932+
for (cert, key) in [("a certificate", ""), ("", "a key")] {
938933
let error = ClientConfig::from_config_args(ClientConfigArgs {
939934
cert_pem: String::from(cert),
940-
key_pem: String::from(key),
935+
key_pem: key.into(),
941936
..args()
942937
})
943938
.expect_err("half an identity must be rejected");
@@ -959,38 +954,25 @@ mod tests {
959954
}
960955

961956
#[test]
962-
fn a_certificate_path_that_does_not_exist_is_reported_with_the_path() {
963-
// These options are paths, not contents. A typo in one has to name the file rather than surface
964-
// later as a TLS failure.
965-
let error = ClientConfig::from_config_args(ClientConfigArgs {
966-
cert_pem: String::from("no/such/cert.pem"),
967-
key_pem: String::from("no/such/key.pem"),
968-
..args()
969-
})
970-
.expect_err("a missing file must be reported");
971-
972-
assert!(matches!(error, ConfigError::Io { .. }), "{error:?}");
973-
assert!(
974-
chain(&error).contains("no/such/cert.pem"),
975-
"{}",
976-
chain(&error)
977-
);
978-
}
979-
980-
#[test]
981-
fn a_missing_ca_certificate_is_reported_with_the_path() {
982-
let error = ClientConfig::from_config_args(ClientConfigArgs {
983-
ca_cert: String::from("no/such/ca.pem"),
984-
..args()
985-
})
986-
.expect_err("a missing file must be reported");
957+
fn a_path_where_the_material_is_expected_fails_as_pem() {
958+
// These options carry the certificate itself. Someone still passing a path gets a PEM error
959+
// naming what was wrong, rather than this crate quietly opening whatever it points at.
960+
for args in [
961+
ClientConfigArgs {
962+
cert_pem: String::from("/etc/ssl/certs/client.pem"),
963+
key_pem: "/etc/ssl/private/client.key".into(),
964+
..args()
965+
},
966+
ClientConfigArgs {
967+
ca_cert: String::from("/etc/ssl/certs/ca.pem"),
968+
..args()
969+
},
970+
] {
971+
let error =
972+
ClientConfig::from_config_args(args).expect_err("a path is not a certificate");
987973

988-
assert!(matches!(error, ConfigError::Io { .. }), "{error:?}");
989-
assert!(
990-
chain(&error).contains("no/such/ca.pem"),
991-
"{}",
992-
chain(&error)
993-
);
974+
assert!(matches!(error, ConfigError::Tls { .. }), "{error:?}");
975+
}
994976
}
995977

996978
// --- override target ---

packages/rust/armonik/src/client/env.rs

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,11 @@ impl FromEnv for ClientConfigArgs {
2222
let ctx = ReadSnafu {};
2323
Ok(Self {
2424
endpoint: read_env("GrpcClient__Endpoint").context(ctx)?,
25-
cert_pem: read_env("GrpcClient__CertPem").context(ctx)?,
26-
key_pem: read_env("GrpcClient__KeyPem").context(ctx)?,
27-
ca_cert: read_env("GrpcClient__CaCert").context(ctx)?,
25+
// These name files. Opening them is this crate's business, not the transport's, which is
26+
// handed the material.
27+
cert_pem: read_pem_file("GrpcClient__CertPem")?,
28+
key_pem: read_pem_file("GrpcClient__KeyPem")?.into(),
29+
ca_cert: read_pem_file("GrpcClient__CaCert")?,
2830
allow_unsafe_connection: read_env_bool("GrpcClient__AllowUnsafeConnection")
2931
.context(ctx)?,
3032
override_target_name: read_env("GrpcClient__OverrideTargetName").context(ctx)?,
@@ -57,6 +59,15 @@ impl FromEnv for ClientConfig {
5759
}
5860
}
5961

62+
/// Read the file the variable `name` points at, or nothing when it points nowhere.
63+
fn read_pem_file(name: &str) -> Result<String, EnvConfigError> {
64+
let path = read_env(name).context(ReadSnafu {})?;
65+
if path.is_empty() {
66+
return Ok(String::new());
67+
}
68+
std::fs::read_to_string(&path).context(FileSnafu { name, path })
69+
}
70+
6071
pub(crate) fn read_env(name: &str) -> Result<String, ReadEnvError> {
6172
match std::env::var(name) {
6273
Ok(value) => Ok(value),
@@ -107,6 +118,16 @@ pub enum EnvConfigError {
107118
#[snafu(implicit)]
108119
location: snafu::Location,
109120
},
121+
#[snafu(display("`{name}={path}` could not be read [{location}]"))]
122+
#[non_exhaustive]
123+
File {
124+
#[snafu(source(from(std::io::Error, Box::new)))]
125+
source: Box<std::io::Error>,
126+
name: String,
127+
path: String,
128+
#[snafu(implicit)]
129+
location: snafu::Location,
130+
},
110131
#[snafu(display(
111132
"The environment does not describe a valid client configuration [{location}]"
112133
))]
@@ -286,6 +307,33 @@ mod tests {
286307
assert_eq!(absent.expect("unset"), "", "absent reads as empty");
287308
}
288309

310+
#[test]
311+
#[serial_test::serial]
312+
fn a_certificate_path_that_leads_nowhere_names_the_variable_and_the_path() {
313+
// The transport is handed the material, so a typo in a path has to be caught here or it
314+
// surfaces much later as a rejected handshake. `read_pem_file` takes the variable name, so
315+
// this borrows one of its own rather than a `GrpcClient__*` the client tests depend on.
316+
let error = with_var("ARMONIK_TEST_PEM", Some("no/such/cert.pem"), || {
317+
read_pem_file("ARMONIK_TEST_PEM")
318+
})
319+
.expect_err("a missing file must be reported");
320+
321+
let rendered = format!("{error}");
322+
assert!(rendered.contains("ARMONIK_TEST_PEM"), "{rendered}");
323+
assert!(rendered.contains("no/such/cert.pem"), "{rendered}");
324+
}
325+
326+
#[test]
327+
#[serial_test::serial]
328+
fn an_unset_certificate_variable_is_no_certificate_rather_than_an_error() {
329+
let loaded = with_var("ARMONIK_TEST_PEM", None, || {
330+
read_pem_file("ARMONIK_TEST_PEM")
331+
})
332+
.expect("an unset variable names no file");
333+
334+
assert_eq!(loaded, "");
335+
}
336+
289337
#[test]
290338
#[serial_test::serial]
291339
fn a_variable_reaches_the_field_that_carries_it() {

0 commit comments

Comments
 (0)