Skip to content

Commit 6008eb8

Browse files
committed
refactor(rust): make reading a secret an explicit act, and drop the unused half
`Revealed` and its serialisation in clear are gone. They existed so a caller could write a configuration out and read it back, and no such caller exists: nothing in the repository enables the `serde` feature, the FFI layer passes typed fields across a C ABI, and the configuration comes from environment variables. The only round-trip was the test written for it. What is left is complete. `Debug` and `Serialize` redact; `Deserialize` refuses the redaction marker, so reading back a dump fails where it can be understood rather than as an unexplained rejection later. `Deref` is gone too, which made the type match its own documentation: `&*password` handed the value out with no call to anything. Emptiness, the only thing the call sites wanted, has its own method. The accessor is `expose_secret`, named in full so that a call site reads as the deliberate act it is. `rustls` guards a private key the same way, verified in rustls-pki-types 1.15.1: no `Deref`, no `AsRef`, a named `secret_*_der()` accessor, and a `Debug` that elides. The comments this branch adds were revised as a whole rather than only where the code changed: 200 lines audited, the four longest blocks cut. $ cargo test -p armonik-transport --all-features 69 passing
1 parent 99b374e commit 6008eb8

5 files changed

Lines changed: 40 additions & 114 deletions

File tree

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

Lines changed: 8 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,14 @@ use crate::secret::Secret;
1212
pub enum ProxySource {
1313
/// Connect directly, ignoring any proxy configured in the environment.
1414
///
15-
/// The default, so that adding proxy support changes nothing for a client that never asked for it.
15+
/// The default: a client that asks for nothing connects directly.
1616
#[default]
1717
Disabled,
1818
/// Read the proxy from the environment, on `hyper_util`'s rules: `ALL_PROXY`, `HTTPS_PROXY`,
1919
/// `HTTP_PROXY` and `NO_PROXY`, in either case, with `NO_PROXY` matched as curl matches it.
2020
///
21-
/// Read when `connect` builds the channel, not when this is built and not again afterwards: a
22-
/// variable changed in between is the one that counts, and a channel that reconnects keeps the
23-
/// values it started with. Every other option is read in [`ClientConfigArgs::from_env`].
21+
/// Read once, when `connect` builds the channel, so one that reconnects keeps the values it
22+
/// started with. Every other option is read in [`ClientConfigArgs::from_env`].
2423
System,
2524
/// Use this specific proxy.
2625
Explicit(Uri),
@@ -44,11 +43,8 @@ pub struct ProxyConfig {
4443
impl ProxyConfig {
4544
/// Use this specific proxy.
4645
///
47-
/// Credentials written into the URL are taken out of it and kept as the proxy's credentials, so
48-
/// they are honoured without the URI carrying them anywhere it is rendered.
49-
///
50-
/// The type is `#[non_exhaustive]`, so another crate cannot build it with a struct expression;
51-
/// these constructors are the way in.
46+
/// Credentials written into the URL are taken out of it and kept here, so the URI carries none
47+
/// wherever it is rendered. The type is `#[non_exhaustive]`, so this is the way in.
5248
pub fn explicit(uri: Uri) -> Self {
5349
let (uri, credentials) = crate::proxy::split_credentials(uri);
5450
let (username, password) = credentials.unwrap_or_default();
@@ -83,7 +79,7 @@ impl ProxyConfig {
8379
if self.username.is_empty() && self.password.is_empty() {
8480
None
8581
} else {
86-
Some((&self.username, self.password.expose()))
82+
Some((&self.username, self.password.expose_secret()))
8783
}
8884
}
8985
}
@@ -230,8 +226,8 @@ pub struct ClientConfigArgs {
230226
/// Password for proxy authentication.
231227
///
232228
/// Empty falls back to the password the `proxy` URL carried, independently of the username, so
233-
/// setting this one alone still uses that URL's username. Redacted by `Debug` and by an ordinary
234-
/// serialisation; see [`Secret`].
229+
/// setting this one alone still uses that URL's username. Redacted wherever it is written; see
230+
/// [`Secret`].
235231
#[cfg_attr(feature = "serde", serde(default))]
236232
pub proxy_password: Secret,
237233
}
@@ -1238,22 +1234,6 @@ mod tests {
12381234
assert!(!json.contains("s3cr3t"), "password written out: {json}");
12391235
}
12401236

1241-
#[cfg(feature = "serde")]
1242-
#[test]
1243-
fn a_secret_is_written_in_clear_only_where_the_call_site_asks() {
1244-
let args = ClientConfigArgs {
1245-
proxy_password: "s3cr3t".into(),
1246-
..args()
1247-
};
1248-
1249-
let revealed = serde_json::to_string(&args.proxy_password.revealed()).expect("serialise");
1250-
assert_eq!(revealed, "\"s3cr3t\"");
1251-
// The value is untouched by having been revealed once.
1252-
assert!(!serde_json::to_string(&args)
1253-
.expect("serialise")
1254-
.contains("s3cr3t"));
1255-
}
1256-
12571237
#[test]
12581238
fn proxy_without_a_host_is_rejected_and_names_its_own_option() {
12591239
// Reporting these through the endpoint's URI error would send whoever reads it looking at the

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ mod utils;
1313
pub use config::{ClientConfig, ClientConfigArgs, ConfigError, ProxyConfig, ProxySource};
1414
pub use connect::{connect, https_connector, ConnectionError};
1515
pub use proxy::ProxyError;
16-
pub use secret::{Revealed, Secret};
16+
pub use secret::Secret;
1717
// Snafu's context selectors, so a caller in another crate can build the error with the location
1818
// captured at its own call site. Hidden: this is how the error is built, not API to design against.
1919
#[doc(hidden)]

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

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
//! Reaching the endpoint through an HTTP proxy.
22
//!
33
//! A `CONNECT` tunnel rather than an absolute-form request, so TLS stays end to end with the real
4-
//! server and the proxy only forwards opaque bytes. Terminating TLS at the proxy would defeat the
5-
//! point of this transport, which exists to control the TLS stack.
6-
//!
7-
//! [`ProxyConnector`] therefore sits below the TLS connector and hands back the same stream type a
8-
//! direct connection would.
4+
//! server: [`ProxyConnector`] sits below the TLS connector and hands back the stream a direct
5+
//! connection would.
96
107
use std::future::Future;
118
use std::pin::Pin;
@@ -44,16 +41,11 @@ const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(30);
4441
pub struct ProxyConnector<S> {
4542
inner: S,
4643
proxy: ProxyConfig,
47-
/// The environment's proxy rules, and only those: `Some` for [`ProxySource::System`] alone.
48-
///
49-
/// `hyper_util`'s rather than ours, because it already implements the `*_PROXY` convention,
50-
/// `NO_PROXY` on curl's rules, and taking credentials out of a proxy URL. An explicitly named proxy
51-
/// does not go through it: a matcher answers `None` for a target with no host or a scheme other
52-
/// than `http`/`https`, which would silently skip the proxy the caller asked for. Only the tunnel
53-
/// below is ours as well, because theirs rejects a `CONNECT` response split across reads.
44+
/// The environment's proxy rules: `Some` for [`ProxySource::System`] alone, since a matcher
45+
/// answers `None` for a scheme other than `http`/`https` and would skip an explicit proxy.
5446
///
55-
/// Shared rather than cloned: a `tower` connector is cloned per connection, and `Matcher` is
56-
/// neither `Clone` nor cheap to rebuild.
47+
/// Behind an `Arc` because a `tower` connector is cloned per connection and `Matcher` is not
48+
/// `Clone`.
5749
matcher: Option<std::sync::Arc<Matcher>>,
5850
}
5951

Lines changed: 24 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,15 @@
11
//! A configuration value that must not be printed.
22
33
use std::fmt;
4-
use std::ops::Deref;
54

6-
/// The text a redacted secret renders as.
5+
/// What a secret renders as instead of its value.
76
const REDACTED: &str = "[redacted]";
87

98
/// A string that redacts itself when printed or serialised.
109
///
11-
/// Redacted by construction rather than by a hand-written `Debug` on each holder: a struct grows
12-
/// fields, and a `Debug` listing them by hand goes stale the first time someone forgets one.
13-
///
14-
/// Serialising redacts as well, which is what output that might be logged needs. [`Secret::revealed`]
15-
/// opts out for one serialisation, and only for that one.
10+
/// The value is reachable only through [`Secret::expose_secret`], named so that reading it is a visible act.
11+
/// There is deliberately no `Deref` or `AsRef`, which would let it out silently; `rustls` guards a
12+
/// private key the same way, with `secret_der` as the only way in.
1613
#[derive(Clone, Default, PartialEq, Eq, Hash)]
1714
pub struct Secret(String);
1815

@@ -23,32 +20,16 @@ impl Secret {
2320
}
2421

2522
/// The value itself, for the code that has to use it.
26-
pub fn expose(&self) -> &str {
23+
///
24+
/// Named in full so that a call site reads as the deliberate act it is, following the convention
25+
/// the `secrecy` crate set.
26+
pub fn expose_secret(&self) -> &str {
2727
&self.0
2828
}
2929

30-
/// Borrow this secret for a single serialisation in clear.
31-
///
32-
/// The borrow is what makes it safe: revealing is a property of one call site, not of the value,
33-
/// so it cannot be carried along by a clone or outlive the expression that asked for it.
34-
///
35-
/// For output that is itself protected and has to be read back. `Debug` still redacts, because a
36-
/// log is never that output.
37-
pub fn revealed(&self) -> Revealed<'_> {
38-
Revealed(self)
39-
}
40-
}
41-
42-
/// A [`Secret`] borrowed for one serialisation in clear. See [`Secret::revealed`].
43-
///
44-
/// Deliberately not `Copy` or `Clone`: serialising takes it by reference, so nothing needs to
45-
/// duplicate it, and a handle that cannot be passed around keeps revealing where it was asked for.
46-
pub struct Revealed<'a>(&'a Secret);
47-
48-
// Redacted here too: this type exists to widen serialisation, not printing.
49-
impl fmt::Debug for Revealed<'_> {
50-
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51-
self.0.fmt(f)
30+
/// Whether no secret was given, which a caller may ask without reading one.
31+
pub fn is_empty(&self) -> bool {
32+
self.0.is_empty()
5233
}
5334
}
5435

@@ -62,14 +43,6 @@ impl fmt::Debug for Secret {
6243
}
6344
}
6445

65-
impl Deref for Secret {
66-
type Target = str;
67-
68-
fn deref(&self) -> &Self::Target {
69-
&self.0
70-
}
71-
}
72-
7346
impl From<String> for Secret {
7447
fn from(value: String) -> Self {
7548
Self::new(value)
@@ -84,6 +57,7 @@ impl From<&str> for Secret {
8457

8558
#[cfg(feature = "serde")]
8659
impl serde::Serialize for Secret {
60+
/// Redacts, so that a configuration dumped for diagnosis carries no credential.
8761
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
8862
if self.0.is_empty() {
8963
serializer.serialize_str("")
@@ -93,24 +67,15 @@ impl serde::Serialize for Secret {
9367
}
9468
}
9569

96-
#[cfg(feature = "serde")]
97-
impl serde::Serialize for Revealed<'_> {
98-
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
99-
serializer.serialize_str(&self.0 .0)
100-
}
101-
}
102-
10370
#[cfg(feature = "serde")]
10471
impl<'de> serde::Deserialize<'de> for Secret {
105-
/// Refuses the redaction marker, so that reading back a redacted dump fails where it can be
106-
/// understood rather than later, as an unexplained rejection by whatever the secret authenticates
107-
/// against.
72+
/// Refuses the redaction marker, so that reading back a dump fails where it can be understood
73+
/// rather than later, as an unexplained rejection by whatever the secret authenticates against.
10874
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
10975
let value = String::deserialize(deserializer)?;
11076
if value == REDACTED {
11177
return Err(serde::de::Error::custom(format!(
112-
"`{REDACTED}` is what a secret serialises to unless `Secret::revealed` was used; \
113-
this input cannot be a secret"
78+
"`{REDACTED}` is what a secret serialises to, so this input cannot be one"
11479
)));
11580
}
11681
Ok(Self::new(value))
@@ -124,8 +89,6 @@ mod tests {
12489
#[test]
12590
fn debug_never_shows_the_value() {
12691
assert_eq!(format!("{:?}", Secret::new("hunter2")), REDACTED);
127-
// Including through the wrapper that widens serialisation.
128-
assert_eq!(format!("{:?}", Secret::new("hunter2").revealed()), REDACTED);
12992
}
13093

13194
#[test]
@@ -135,27 +98,18 @@ mod tests {
13598
}
13699

137100
#[test]
138-
fn the_value_is_available_to_the_code_that_needs_it() {
139-
assert_eq!(Secret::new("hunter2").expose(), "hunter2");
140-
assert_eq!(&*Secret::new("hunter2"), "hunter2");
101+
fn the_value_is_available_only_to_the_code_that_asks_for_it() {
102+
assert_eq!(Secret::new("hunter2").expose_secret(), "hunter2");
103+
// Emptiness is answerable without reading the value.
104+
assert!(Secret::default().is_empty());
105+
assert!(!Secret::new("hunter2").is_empty());
141106
}
142107

143108
#[cfg(feature = "serde")]
144109
#[test]
145-
fn serialisation_redacts_unless_the_call_site_asks_otherwise() {
146-
let secret = Secret::new("hunter2");
147-
148-
assert_eq!(
149-
serde_json::to_string(&secret).expect("serialise"),
150-
format!("\"{REDACTED}\"")
151-
);
152-
assert_eq!(
153-
serde_json::to_string(&secret.revealed()).expect("serialise"),
154-
"\"hunter2\""
155-
);
156-
// Revealing one call site leaves the secret itself untouched, which is the whole point.
110+
fn serialisation_redacts() {
157111
assert_eq!(
158-
serde_json::to_string(&secret).expect("serialise"),
112+
serde_json::to_string(&Secret::new("hunter2")).expect("serialise"),
159113
format!("\"{REDACTED}\"")
160114
);
161115
}
@@ -167,8 +121,8 @@ mod tests {
167121
.expect_err("the marker must not be taken for a secret");
168122

169123
assert!(
170-
error.to_string().contains("revealed"),
171-
"the message should say what to do: {error}"
124+
error.to_string().contains("cannot be one"),
125+
"the message should say why: {error}"
172126
);
173127
}
174128
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use armonik_transport::ConfigSnafu;
99
#[cfg(feature = "_gen-client")]
1010
pub use armonik_transport::{
1111
ClientConfig, ClientConfigArgs, ConfigError, ConnectionError, ProxyConfig, ProxyError,
12-
ProxySource, ReadEnvError, Revealed, Secret,
12+
ProxySource, ReadEnvError, Secret,
1313
};
1414

1515
#[cfg(feature = "worker")]

0 commit comments

Comments
 (0)