Skip to content

Commit dd755d2

Browse files
domenkozarclaude
andcommitted
fix: support spaces and special characters in provider URIs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 99825a5 commit dd755d2

16 files changed

Lines changed: 225 additions & 213 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
- Provider URIs now support spaces and special characters in names
12+
(e.g., `onepassword://Home Lab`). All providers receive automatically
13+
percent-decoded values via a new `ProviderUrl` wrapper type.
14+
1015
## [0.8.2] - 2026-03-19
1116

1217
### Changed

Cargo.lock

Lines changed: 3 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ serde_json = "1.0"
2626
tempfile = "3.0"
2727
http = "1.0"
2828
url = "2.5.4"
29+
percent-encoding = "2.3"
2930
whoami = "2.0"
3031
syn = "2.0"
3132
quote = "1.0"

secretspec/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ miette.workspace = true
3030
serde_json.workspace = true
3131
tempfile.workspace = true
3232
url.workspace = true
33+
percent-encoding.workspace = true
3334
whoami = { workspace = true, optional = true }
3435
linkme.workspace = true
3536
secrecy.workspace = true

secretspec/src/provider/awssm.rs

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,12 @@
3232
//! secretspec check --provider awssm://production@us-east-1
3333
//! ```
3434
35-
use super::Provider;
35+
use super::{Provider, ProviderUrl};
3636
use crate::{Result, SecretSpecError};
3737
use aws_sdk_secretsmanager::Client;
3838
use secrecy::{ExposeSecret, SecretString};
3939
use serde::{Deserialize, Serialize};
4040
use std::collections::HashMap;
41-
use url::Url;
4241

4342
/// Maximum number of secrets per BatchGetSecretValue API call.
4443
const AWS_BATCH_GET_MAX_SECRETS: usize = 20;
@@ -52,10 +51,10 @@ pub struct AwssmConfig {
5251
pub aws_profile: Option<String>,
5352
}
5453

55-
impl TryFrom<&Url> for AwssmConfig {
54+
impl TryFrom<&ProviderUrl> for AwssmConfig {
5655
type Error = SecretSpecError;
5756

58-
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
57+
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
5958
if url.scheme() != "awssm" {
6059
return Err(SecretSpecError::ProviderOperationFailed(format!(
6160
"Invalid scheme '{}' for awssm provider. Expected 'awssm'.",
@@ -69,14 +68,11 @@ impl TryFrom<&Url> for AwssmConfig {
6968
if username.is_empty() {
7069
None
7170
} else {
72-
Some(username.to_string())
71+
Some(username)
7372
}
7473
};
7574

76-
let region = url
77-
.host_str()
78-
.filter(|s| !s.is_empty())
79-
.map(|s| s.to_string());
75+
let region = url.host().filter(|s| !s.is_empty());
8076

8177
Ok(Self {
8278
region,
@@ -85,14 +81,6 @@ impl TryFrom<&Url> for AwssmConfig {
8581
}
8682
}
8783

88-
impl TryFrom<Url> for AwssmConfig {
89-
type Error = SecretSpecError;
90-
91-
fn try_from(url: Url) -> std::result::Result<Self, Self::Error> {
92-
(&url).try_into()
93-
}
94-
}
95-
9684
/// AWS Secrets Manager provider.
9785
///
9886
/// This provider stores and retrieves secrets from AWS Secrets Manager using

secretspec/src/provider/dotenv.rs

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
use super::Provider;
1+
use super::{Provider, ProviderUrl};
22
use crate::{Result, SecretSpecError};
33
use secrecy::{ExposeSecret, SecretString};
44
use serde::{Deserialize, Serialize};
55
use std::collections::HashMap;
66
use std::fs;
77
use std::path::PathBuf;
8-
use url::Url;
98

109
/// Configuration for the dotenv provider.
1110
///
@@ -43,7 +42,7 @@ impl Default for DotEnvConfig {
4342
}
4443
}
4544

46-
impl TryFrom<&Url> for DotEnvConfig {
45+
impl TryFrom<&ProviderUrl> for DotEnvConfig {
4746
type Error = SecretSpecError;
4847

4948
/// Creates a DotEnvConfig from a URL.
@@ -56,45 +55,24 @@ impl TryFrom<&Url> for DotEnvConfig {
5655
/// - `dotenv:///absolute/path` - Absolute path
5756
/// - `dotenv://.env` - Relative path (authority as filename)
5857
/// - `dotenv://` - Uses default `.env` in current directory
59-
///
60-
/// # Examples
61-
///
62-
/// ```ignore
63-
/// use url::Url;
64-
/// use secretspec::provider::dotenv::DotEnvConfig;
65-
///
66-
/// let url = Url::parse("dotenv:///.env.production").unwrap();
67-
/// let config: DotEnvConfig = (&url).try_into().unwrap();
68-
/// assert_eq!(config.path.to_str().unwrap(), "/.env.production");
69-
/// ```
70-
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
58+
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
7159
if url.scheme() != "dotenv" {
7260
return Err(SecretSpecError::ProviderOperationFailed(format!(
7361
"Invalid scheme '{}' for dotenv provider",
7462
url.scheme()
7563
)));
7664
}
7765

78-
// For dotenv URLs:
79-
// - dotenv:///absolute/path -> url.path() = "/absolute/path"
80-
// - dotenv://.env -> url.host_str() = ".env", url.path() = ""
81-
// - dotenv:// -> url.host_str() = None, url.path() = ""
82-
83-
let path = if url.path() != "" && url.path() != "/" {
84-
// Check if this is an absolute path (starts with /) or has a host
85-
if let Some(host) = url.host_str() {
86-
// Case like dotenv://config/.env.local -> host="config", path="/.env.local"
87-
// We want "config/.env.local"
88-
format!("{}{}", host, url.path())
66+
let path_str = url.path();
67+
let path = if path_str != "" && path_str != "/" {
68+
if let Some(host) = url.host() {
69+
format!("{}{}", host, path_str)
8970
} else {
90-
// Absolute path from dotenv:///path
91-
url.path().to_string()
71+
path_str
9272
}
93-
} else if let Some(host) = url.host_str() {
94-
// Relative path from dotenv://filename
95-
host.to_string()
73+
} else if let Some(host) = url.host() {
74+
host
9675
} else {
97-
// Default case dotenv://
9876
".env".to_string()
9977
};
10078

@@ -169,13 +147,8 @@ impl Provider for DotEnvProvider {
169147
let path_str = self.config.path.display().to_string();
170148

171149
if path_str == ".env" {
172-
// Default case - just return "dotenv"
173150
"dotenv".to_string()
174-
} else if path_str.starts_with('/') {
175-
// Absolute path
176-
format!("dotenv:{}", path_str)
177151
} else {
178-
// Relative path
179152
format!("dotenv:{}", path_str)
180153
}
181154
}
@@ -309,28 +282,30 @@ mod tests {
309282

310283
#[test]
311284
fn test_dotenv_url_parsing() {
285+
use url::Url;
286+
312287
// Test with absolute path using three slashes - this is the main syntax we want to support
313-
let url = Url::parse("dotenv:///tmp/test/.env").unwrap();
288+
let url = ProviderUrl::new(Url::parse("dotenv:///tmp/test/.env").unwrap());
314289
let config: DotEnvConfig = (&url).try_into().unwrap();
315290
assert_eq!(config.path.to_str().unwrap(), "/tmp/test/.env");
316291

317292
// Test with relative path using two slashes - authority as filename
318-
let url = Url::parse("dotenv://.env").unwrap();
293+
let url = ProviderUrl::new(Url::parse("dotenv://.env").unwrap());
319294
let config: DotEnvConfig = (&url).try_into().unwrap();
320295
assert_eq!(config.path.to_str().unwrap(), ".env");
321296

322297
// Test with relative path in subdirectory
323-
let url = Url::parse("dotenv://config/.env.local").unwrap();
298+
let url = ProviderUrl::new(Url::parse("dotenv://config/.env.local").unwrap());
324299
let config: DotEnvConfig = (&url).try_into().unwrap();
325300
assert_eq!(config.path.to_str().unwrap(), "config/.env.local");
326301

327302
// Test with default (empty after //)
328-
let url = Url::parse("dotenv://").unwrap();
303+
let url = ProviderUrl::new(Url::parse("dotenv://").unwrap());
329304
let config: DotEnvConfig = (&url).try_into().unwrap();
330305
assert_eq!(config.path.to_str().unwrap(), ".env");
331306

332307
// Test with relative path - host part becomes first part of path
333-
let url = Url::parse("dotenv://foobar/custom/path/.env").unwrap();
308+
let url = ProviderUrl::new(Url::parse("dotenv://foobar/custom/path/.env").unwrap());
334309
let config: DotEnvConfig = (&url).try_into().unwrap();
335310
assert_eq!(config.path.to_str().unwrap(), "foobar/custom/path/.env");
336311
}

secretspec/src/provider/env.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
use super::Provider;
1+
use super::{Provider, ProviderUrl};
22
use crate::{Result, SecretSpecError};
33
use secrecy::SecretString;
44
use serde::{Deserialize, Serialize};
55
use std::env;
6-
use url::Url;
76

87
/// Configuration for the environment variables provider.
98
///
@@ -20,7 +19,7 @@ use url::Url;
2019
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2120
pub struct EnvConfig {}
2221

23-
impl TryFrom<&Url> for EnvConfig {
22+
impl TryFrom<&ProviderUrl> for EnvConfig {
2423
type Error = SecretSpecError;
2524

2625
/// Creates an `EnvConfig` from a URL.
@@ -37,7 +36,7 @@ impl TryFrom<&Url> for EnvConfig {
3736
/// let url = Url::parse("env://").unwrap();
3837
/// let config: EnvConfig = (&url).try_into().unwrap();
3938
/// ```
40-
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
39+
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
4140
if url.scheme() != "env" {
4241
return Err(SecretSpecError::ProviderOperationFailed(format!(
4342
"Invalid scheme '{}' for env provider",

secretspec/src/provider/gcsm.rs

Lines changed: 8 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,12 @@
3030
//! secretspec check --provider gcsm://my-gcp-project
3131
//! ```
3232
33-
use super::Provider;
33+
use super::{Provider, ProviderUrl};
3434
use crate::{Result, SecretSpecError};
3535
use google_cloud_secretmanager_v1::client::SecretManagerService;
3636
use google_cloud_secretmanager_v1::model::{Replication, Secret, SecretPayload, replication};
3737
use secrecy::{ExposeSecret, SecretString};
3838
use serde::{Deserialize, Serialize};
39-
use url::Url;
4039

4140
/// Configuration for the Google Cloud Secret Manager provider.
4241
///
@@ -96,10 +95,10 @@ fn validate_gcp_project_id(project_id: &str) -> std::result::Result<(), SecretSp
9695
Ok(())
9796
}
9897

99-
impl TryFrom<&Url> for GcsmConfig {
98+
impl TryFrom<&ProviderUrl> for GcsmConfig {
10099
type Error = SecretSpecError;
101100

102-
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
101+
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
103102
if url.scheme() != "gcsm" {
104103
return Err(SecretSpecError::ProviderOperationFailed(format!(
105104
"Invalid scheme '{}' for gcsm provider. Expected 'gcsm'.",
@@ -108,15 +107,11 @@ impl TryFrom<&Url> for GcsmConfig {
108107
}
109108

110109
// Extract project ID from host portion: gcsm://project-id
111-
let project_id = url
112-
.host_str()
113-
.filter(|s| !s.is_empty())
114-
.ok_or_else(|| {
115-
SecretSpecError::ProviderOperationFailed(
116-
"GCP project ID is required. Use format: gcsm://project-id".to_string(),
117-
)
118-
})?
119-
.to_string();
110+
let project_id = url.host().filter(|s| !s.is_empty()).ok_or_else(|| {
111+
SecretSpecError::ProviderOperationFailed(
112+
"GCP project ID is required. Use format: gcsm://project-id".to_string(),
113+
)
114+
})?;
120115

121116
// Validate project ID format
122117
validate_gcp_project_id(&project_id)?;
@@ -125,14 +120,6 @@ impl TryFrom<&Url> for GcsmConfig {
125120
}
126121
}
127122

128-
impl TryFrom<Url> for GcsmConfig {
129-
type Error = SecretSpecError;
130-
131-
fn try_from(url: Url) -> std::result::Result<Self, Self::Error> {
132-
(&url).try_into()
133-
}
134-
}
135-
136123
/// Google Cloud Secret Manager provider.
137124
///
138125
/// This provider stores and retrieves secrets from Google Cloud Secret Manager using

secretspec/src/provider/keyring.rs

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
use super::Provider;
1+
use super::{Provider, ProviderUrl};
22
use crate::{Result, SecretSpecError};
33
use keyring::Entry;
44
use secrecy::{ExposeSecret, SecretString};
55
use serde::{Deserialize, Serialize};
6-
use url::Url;
76

87
/// Configuration for the keyring provider.
98
///
@@ -18,23 +17,14 @@ pub struct KeyringConfig {
1817
pub folder_prefix: Option<String>,
1918
}
2019

21-
impl TryFrom<&Url> for KeyringConfig {
20+
impl TryFrom<&ProviderUrl> for KeyringConfig {
2221
type Error = SecretSpecError;
2322

2423
/// Creates a new KeyringConfig from a URL.
2524
///
2625
/// The URL must have the scheme "keyring" (e.g., "keyring://" or
2726
/// "keyring://secretspec/shared/{profile}/{key}").
28-
///
29-
/// # Examples
30-
///
31-
/// ```ignore
32-
/// # use url::Url;
33-
/// # use secretspec::provider::keyring::KeyringConfig;
34-
/// let url = Url::parse("keyring://").unwrap();
35-
/// let config: KeyringConfig = (&url).try_into().unwrap();
36-
/// ```
37-
fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
27+
fn try_from(url: &ProviderUrl) -> std::result::Result<Self, Self::Error> {
3828
if url.scheme() != "keyring" {
3929
return Err(SecretSpecError::ProviderOperationFailed(format!(
4030
"Invalid scheme '{}' for keyring provider",
@@ -44,12 +34,9 @@ impl TryFrom<&Url> for KeyringConfig {
4434

4535
let mut config = Self::default();
4636

47-
if let Some(host) = url.host_str() {
37+
if let Some(host) = url.host() {
4838
let path = url.path();
49-
// Percent-decode so placeholders like {profile} and {key} survive URL parsing
50-
let raw = format!("{}{}", host, path);
51-
let decoded = raw.replace("%7B", "{").replace("%7D", "}");
52-
config.folder_prefix = Some(decoded);
39+
config.folder_prefix = Some(format!("{}{}", host, path));
5340
}
5441

5542
Ok(config)
@@ -121,7 +108,7 @@ impl Provider for KeyringProvider {
121108

122109
fn uri(&self) -> String {
123110
if let Some(ref prefix) = self.config.folder_prefix {
124-
format!("keyring://{}", prefix)
111+
format!("keyring://{}", ProviderUrl::encode(prefix))
125112
} else {
126113
"keyring".to_string()
127114
}

0 commit comments

Comments
 (0)