1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
use std::convert::TryFrom;
use std::sync::Arc;
use percent_encoding::percent_encode;
use url::Url;
use super::PROVIDER_REGISTRY;
use super::Provider;
use super::ProviderCredentials;
use super::ProviderUrl;
use super::URI_ENCODE_SET;
use super::preflight::PreflightGuard;
use super::registry::registration_for_scheme;
use super::registry::split_spec;
use super::spec_names_known_provider;
use super::url::WINDOWS_PATH_ENCODE_SET;
use super::url::is_windows_abs_path;
use crate::MonosecretError;
use crate::Result;
impl TryFrom<String> for Box<dyn Provider> {
type Error = MonosecretError;
/// Creates a provider instance from a URI string.
///
/// This function handles various URI formats and normalizes them before parsing.
/// It supports both full URIs and shorthand notations.
///
/// # URI Formats
///
/// - **Full URI**: `scheme://authority/path` (e.g., `onepassword://Production`)
///
/// # Special Cases
///
/// - **1password**: Will error suggesting to use `onepassword` instead
/// - **Bare provider names**: Automatically converted to `provider://`
///
/// # Examples
///
/// ```ignore
/// use std::convert::TryFrom;
///
/// // Simple provider name
/// let provider = Box::<dyn Provider>::try_from("keyring".to_string())?;
///
/// // Full URI with configuration
/// let provider = Box::<dyn Provider>::try_from("onepassword://Production".to_string())?;
///
/// // Dotenv with path
/// let provider = Box::<dyn Provider>::try_from("dotenv:.env.production".to_string())?;
/// ```
fn try_from(s: String) -> Result<Self> {
Self::try_from(&s as &str)
}
}
impl TryFrom<&str> for Box<dyn Provider> {
type Error = MonosecretError;
fn try_from(s: &str) -> Result<Self> {
provider_from_spec(s, ProviderCredentials::new())
}
}
/// Builds a boxed provider from a spec string (a bare name, `scheme:...`
/// shorthand, or full URI), handing it the supplied credentials. The shared
/// body of the string `TryFrom` impls: construction funnels here so URL
/// normalization and credential injection have exactly one home.
pub(crate) fn provider_from_spec(
s: &str,
credentials: ProviderCredentials,
) -> Result<Box<dyn Provider>> {
let url = provider_url_from_spec(s)?;
provider_from_url(&url, credentials)
}
/// Builds an external provider with a host-owned credential broker installed
/// before its endpoint can start. Runtime credential negotiation is exclusive
/// to external providers; a built-in scheme is rejected here rather than
/// silently ignoring the broker.
pub(crate) fn external_provider_from_spec(
s: &str,
broker: Arc<dyn super::external::ProviderCredentialBroker>,
) -> Result<Box<dyn Provider>> {
let url = provider_url_from_spec(s)?;
reject_uri_credential(&url)?;
let scheme = url.scheme();
if registration_for_scheme(scheme).is_some() {
return Err(MonosecretError::ProviderOperationFailed(format!(
"provider '{scheme}' does not use runtime credential negotiation"
)));
}
let endpoint = super::external::discover(scheme)?
.ok_or_else(|| MonosecretError::ProviderNotFound(scheme.to_string()))?;
let mut provider = super::external::ExternalProvider::from_url(endpoint, &url);
provider.with_credential_broker(broker);
Ok(Box::new(provider))
}
/// Parses and normalizes a provider spec without constructing or contacting
/// the provider. Shared with external-provider login, which must install its
/// credential broker before the endpoint is launched.
pub(crate) fn provider_url_from_spec(s: &str) -> Result<ProviderUrl> {
// Parse the scheme from the input string
let (scheme, rest) = split_spec(s);
// Reject the `1password` misspelling (with its corrective error) and
// check the scheme against the registry, through the same gate alias
// resolution uses.
if !spec_names_known_provider(s)? {
// Check if it's a known provider name to give a better error
if PROVIDER_REGISTRY
.iter()
.any(|reg| reg.metadata.info.name == scheme)
{
return Err(MonosecretError::ProviderOperationFailed(format!(
"Provider '{scheme}' exists but URI parsing failed"
)));
}
return Err(MonosecretError::ProviderNotFound(scheme.to_string()));
}
// Build a proper URL with the correct scheme.
//
// Windows absolute paths (e.g. `dotenv://C:\path\.env`) need special care:
// the drive-letter colon looks like a `host:port` separator and parsing
// fails with "invalid port number". Custom schemes carry the encoded path
// in an opaque host so it round-trips through `ProviderUrl::host()`; the
// special `file` scheme uses its standard `file:///C:/...` form instead. A
// Unix absolute path already parses cleanly as `scheme:///abs/path`.
let path_candidate = rest.trim_start_matches('/');
let url_string = if is_windows_abs_path(path_candidate) {
if scheme == "file" {
// `file` is a special URL scheme, so an encoded drive path cannot
// live in its host as it does for custom schemes. Use the standard
// authority-less file URL and normalize Windows separators.
let path = path_candidate.replace('\\', "/");
format!(
"file:///{}",
percent_encode(path.as_bytes(), URI_ENCODE_SET)
)
} else {
format!(
"{}://{}",
scheme,
percent_encode(path_candidate.as_bytes(), WINDOWS_PATH_ENCODE_SET)
)
}
} else {
let url_string = match rest {
// Just scheme name (e.g., "keyring")
"" | ":" => format!("{scheme}://"),
// Standard URI format already has // (e.g., "onepassword://vault")
s if s.starts_with("//") => format!("{scheme}:{s}"),
// Path only format (e.g., "dotenv:/path/to/.env")
s if s.starts_with('/') => format!("{scheme}://{s}"),
// Everything else - assume it's a host or path component
s => format!("{scheme}://{s}"),
};
// Percent-encode characters that are invalid in URIs but might appear in
// provider config values (e.g., spaces in 1Password vault names like "Home Lab")
let scheme_end = url_string.find("://").unwrap() + 3;
let (prefix, rest) = url_string.split_at(scheme_end);
format!(
"{}{}",
prefix,
percent_encode(rest.as_bytes(), URI_ENCODE_SET)
)
};
let proper_url = Url::parse(&url_string).map_err(|e| {
// Redacted: a spec that fails to parse can still carry a credential in
// its userinfo, and this message is printed. The rejection in
// `reject_uri_credential` only runs once parsing succeeds.
MonosecretError::ProviderOperationFailed(format!(
"Invalid provider specification '{}': {}",
crate::audit::redact_uri_strict(s),
e
))
})?;
Ok(ProviderUrl::new(proper_url))
}
impl TryFrom<&Url> for Box<dyn Provider> {
type Error = MonosecretError;
fn try_from(url: &Url) -> Result<Self> {
provider_from_url(&ProviderUrl::new(url.clone()), ProviderCredentials::new())
}
}
/// Refuses a provider URI that carries a credential in its password position.
///
/// A URI is the wrong place for a secret: it is committed to `monosecret.toml`,
/// echoed into shell history, and printed by CI. Redacting it at the terminal
/// does not unpublish it from any of those. Provider credentials exist for this
/// (`credentials = { … }` on the alias, `monosecret config provider login`, or
/// the provider's environment fallback), so the password position is rejected
/// outright rather than read, ignored, or scrubbed.
///
/// Only the password position is universal. Every provider that reads the
/// username reads a non-secret from it (a Vault namespace, an AWS profile, a
/// Bitwarden organization, a 1Password account), so a scheme whose username
/// carries a credential rejects it itself.
///
/// Since Monosecret 0.19.
pub(crate) fn reject_uri_credential(url: &ProviderUrl) -> Result<()> {
if url.password().is_none() {
return Ok(());
}
let scheme = url.scheme();
let registration = registration_for_scheme(scheme);
// Name the credentials this provider actually accepts, straight from its
// registration, so the remedy is concrete rather than a pointer to the
// general mechanism. A provider that accepts none never had a use for the
// password either, so say that instead of suggesting a credential.
let remedy = match registration {
Some(reg) if !reg.metadata.credential_names.is_empty() => {
let names = reg
.metadata
.credential_names
.iter()
.map(|name| format!("`{name}`"))
.collect::<Vec<_>>()
.join(", ");
format!(
"Supply it as the {names} provider credential instead \
(`monosecret config provider login <alias>`, or `credentials = \
{{ ... }}` on the alias), or use the provider's environment \
variable. See https://monosecret.dev/providers/{}/",
reg.metadata.info.name
)
}
Some(reg) => {
format!(
"The {} provider takes no credentials, so remove the userinfo from \
the URI. See https://monosecret.dev/providers/{}/",
reg.metadata.info.name, reg.metadata.info.name
)
}
None => "See https://monosecret.dev/reference/provider-credentials/".to_string(),
};
Err(MonosecretError::ProviderOperationFailed(format!(
"provider URI '{}' carries a password. Monosecret does not accept \
credentials in URIs: a URI reaches committed manifests, shell history, \
and CI logs, so a credential written there is already disclosed. {remedy}",
crate::audit::redact_uri_strict(url.as_url().as_str()),
)))
}
pub(crate) fn provider_from_url(
url: &ProviderUrl,
credentials: ProviderCredentials,
) -> Result<Box<dyn Provider>> {
provider_from_url_with_discovery(url, credentials, super::external::discover)
}
pub(crate) fn provider_from_url_with_discovery(
url: &ProviderUrl,
credentials: ProviderCredentials,
discover: impl FnOnce(&str) -> Result<Option<super::external::ProviderEndpoint>>,
) -> Result<Box<dyn Provider>> {
reject_uri_credential(url)?;
let scheme = url.scheme();
if let Some(registration) = registration_for_scheme(scheme) {
let pwp = (registration.factory)(url, credentials)?;
if pwp.preflight.is_some() {
Ok(Box::new(PreflightGuard::new(pwp)))
} else {
Ok(pwp.provider)
}
} else if let Some(endpoint) = discover(scheme)? {
let mut provider = super::external::ExternalProvider::from_url(endpoint, url);
provider.with_credentials(credentials);
Ok(Box::new(provider))
} else {
Err(MonosecretError::ProviderNotFound(scheme.to_string()))
}
}