earl 0.5.2

AI-safe CLI for AI agents
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
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
use std::collections::HashMap;

use anyhow::{Context, Result, anyhow, bail};
use secrecy::SecretString;
use vaultrs::client::{VaultClient, VaultClientSettingsBuilder};

use crate::secrets::resolver::SecretResolver;
use crate::secrets::resolvers::validate_path_segment;

/// A parsed `vault://mount/path#field` reference.
#[derive(Debug)]
struct VaultReference {
    mount: String,
    path: String,
    field: String,
}

impl VaultReference {
    fn parse(reference: &str) -> Result<Self> {
        let after_scheme = reference
            .strip_prefix("vault://")
            .ok_or_else(|| anyhow!("invalid Vault reference: must start with vault://"))?;

        // Split on '#' to separate path from field
        let (full_path, field) = after_scheme.split_once('#').ok_or_else(|| {
            anyhow!("invalid Vault reference: missing '#field' suffix in {reference}")
        })?;

        if field.is_empty() {
            bail!("invalid Vault reference: field after '#' must not be empty in {reference}");
        }

        // The full_path is mount/path where the first segment is the mount point
        // and the rest is the secret path within that mount.
        let segments: Vec<&str> = full_path.split('/').collect();

        // Reject empty segments from double slashes or trailing slashes —
        // e.g., `vault://secret//path#field` could silently misresolve.
        if segments.iter().any(|s| s.is_empty()) {
            bail!(
                "invalid Vault reference: contains empty path segments \
                 (double slash or trailing slash) in {reference}"
            );
        }

        if segments.len() < 2 {
            bail!("invalid Vault reference: expected vault://mount/path#field, got: {reference}");
        }

        let mount = segments[0].to_string();
        let path = segments[1..].join("/");

        validate_path_segment(&mount, "mount point")?;
        for segment in &segments[1..] {
            validate_path_segment(segment, "secret path segment")?;
        }
        validate_path_segment(field, "field name")?;

        Ok(Self {
            mount,
            path,
            field: field.to_string(),
        })
    }
}

/// Returns `true` when every byte in `s` is valid in an HTTP header value
/// (printable ASCII 0x20–0x7E, or tab 0x09).
fn is_header_safe(s: &str) -> bool {
    s.bytes()
        .all(|b| b == b'\t' || (0x20u8..=0x7E).contains(&b))
}

/// Resolver for HashiCorp Vault secrets using the `vault://` URI scheme.
///
/// Reads secrets from a Vault KV v2 secrets engine. Requires the following
/// environment variables:
///
/// * `VAULT_ADDR` — the Vault server address (e.g. `https://vault.example.com:8200`)
/// * `VAULT_TOKEN` — a valid Vault authentication token
///
/// Optional environment variables:
///
/// * `VAULT_NAMESPACE` — Vault enterprise namespace (e.g. `admin/team-a`)
/// * `VAULT_SKIP_VERIFY` — set to `"1"` or `"true"` to disable TLS verification
///
/// TLS is verified against the system certificate store by default.
/// `VAULT_CACERT` (path to a PEM CA certificate file) and `VAULT_CAPATH`
/// (path to a directory of PEM CA certificates) are read automatically by
/// the underlying `vaultrs` library and can be used to trust a private CA.
/// `VAULT_CLIENT_CERT` and `VAULT_CLIENT_KEY` are also read by `vaultrs`
/// for mTLS client authentication.
///
/// References use the format `vault://mount/path#field`, where:
/// * `mount` is the secrets engine mount point (commonly `"secret"`)
/// * `path` is the secret path within the mount
/// * `field` is the key to extract from the secret's data map
///
/// Example: `vault://secret/myapp#api_key`
pub struct VaultResolver;

impl VaultResolver {
    pub fn new() -> Self {
        Self
    }
}

impl Default for VaultResolver {
    fn default() -> Self {
        Self::new()
    }
}

impl SecretResolver for VaultResolver {
    fn scheme(&self) -> &str {
        "vault"
    }

    fn resolve(&self, reference: &str) -> Result<SecretString> {
        let vault_ref = VaultReference::parse(reference)?;

        // Warn if the secret path starts with "data/" — this is almost always
        // a mistake because `vaultrs::kv2::read` automatically prepends `data/`
        // to the path (KV v2 convention). A path like `data/myapp` would become
        // `secret/data/data/myapp` at the API level.
        if vault_ref.path.starts_with("data/") || vault_ref.path == "data" {
            bail!(
                "Vault secret path '{}' starts with 'data/' — this is likely a mistake. \
                 Earl uses the KV v2 API which automatically adds the 'data/' prefix. \
                 Use the path without 'data/' (e.g., 'myapp' instead of 'data/myapp').",
                vault_ref.path
            );
        }

        let addr = std::env::var("VAULT_ADDR")
            .ok()
            .filter(|v| !v.is_empty())
            .ok_or_else(|| {
                anyhow!(
                    "VAULT_ADDR is not set. Set both VAULT_ADDR and VAULT_TOKEN \
                     environment variables to use vault:// secret references. \
                     For enterprise Vault with namespaces, also set VAULT_NAMESPACE."
                )
            })?;

        let token = std::env::var("VAULT_TOKEN")
            .ok()
            .filter(|v| !v.is_empty())
            .ok_or_else(|| {
                anyhow!(
                    "VAULT_TOKEN is not set. Set both VAULT_ADDR and VAULT_TOKEN \
                     environment variables to use vault:// secret references. \
                     For enterprise Vault with namespaces, also set VAULT_NAMESPACE."
                )
            })?;

        // Validate VAULT_TOKEN contains only HTTP header-safe bytes.
        // vaultrs calls HeaderValue::from_str(&token).unwrap() which panics on
        // control characters, DEL (0x7F), or non-ASCII bytes.
        if !is_header_safe(&token) {
            bail!(
                "VAULT_TOKEN contains characters that are not valid in HTTP headers \
                 (control characters, DEL, or non-ASCII). \
                 Vault tokens must consist only of printable ASCII characters."
            );
        }

        let namespace = std::env::var("VAULT_NAMESPACE")
            .ok()
            .filter(|v| !v.is_empty());

        // Pre-validate the URL to produce a clear error instead of the panic
        // inside vaultrs's address() setter which calls unwrap() on url::Url::parse.
        let parsed_addr = reqwest::Url::parse(&addr).with_context(|| {
            format!(
                "VAULT_ADDR is not a valid URL: {addr}. \
                 Expected format: https://vault.example.com:8200"
            )
        })?;
        if !["http", "https"].contains(&parsed_addr.scheme()) {
            bail!(
                "VAULT_ADDR must use http:// or https:// scheme, got: {}",
                parsed_addr.scheme()
            );
        }

        let mut settings_builder = VaultClientSettingsBuilder::default();
        settings_builder.address(&addr).token(token);

        if let Some(ref ns) = namespace {
            // Validate namespace contains only HTTP header-safe bytes.
            // vaultrs calls HeaderValue::from_str(ns).unwrap() which panics on
            // control characters, DEL (0x7F), or non-ASCII bytes.
            if !is_header_safe(ns) {
                bail!(
                    "VAULT_NAMESPACE contains characters that are not valid in HTTP headers \
                     (control characters, DEL, or non-ASCII). \
                     Must be a valid Vault namespace path (e.g., 'admin/team-a')."
                );
            }
            settings_builder.set_namespace(ns.clone());
        }

        // Always set verify explicitly — vaultrs 0.7.x has an inverted
        // default_verify() implementation that maps VAULT_SKIP_VERIFY=true to
        // verify=true (the opposite of the intended behavior). By always calling
        // settings_builder.verify(), we bypass the buggy default and ensure
        // correct behavior regardless of whether VAULT_SKIP_VERIFY is set.
        let skip_verify = std::env::var("VAULT_SKIP_VERIFY")
            .ok()
            .map(|v| matches!(v.to_lowercase().as_str(), "1" | "true" | "t"))
            .unwrap_or(false);
        settings_builder.verify(!skip_verify);

        // VAULT_CACERT, VAULT_CAPATH, VAULT_CLIENT_CERT, and VAULT_CLIENT_KEY
        // are read automatically by vaultrs's builder defaults (default_ca_certs
        // and default_identity). No explicit configuration is needed here.
        // Use VAULT_SKIP_VERIFY=1 only as a last resort (not in production).

        let settings = settings_builder
            .build()
            .context("failed to build Vault client settings")?;

        let client = VaultClient::new(settings).context("failed to create Vault client")?;

        // We are inside a sync trait method but need to perform an async API call.
        // Use tokio's block_in_place + Handle::current().block_on() to bridge.
        let secret_data: HashMap<String, serde_json::Value> = tokio::task::block_in_place(|| {
            tokio::runtime::Handle::current().block_on(vaultrs::kv2::read(
                &client,
                &vault_ref.mount,
                &vault_ref.path,
            ))
        })
        .with_context(|| {
            let ns_hint = namespace
                .as_deref()
                .map(|ns| format!(" (namespace='{ns}')"))
                .unwrap_or_default();
            format!(
                "failed to read Vault secret at mount='{}', path='{}'{ns_hint}. \
                 Note: Earl uses KV v2 — ensure the mount uses the KV v2 secrets engine.",
                vault_ref.mount, vault_ref.path
            )
        })?;

        let value = secret_data.get(&vault_ref.field).ok_or_else(|| {
            let ns_hint = namespace
                .as_deref()
                .map(|ns| format!(" (namespace='{ns}')"))
                .unwrap_or_default();
            anyhow!(
                "field '{}' not found in Vault secret '{}/{}'{ns_hint}. \
                 Verify the field name matches a top-level key in the secret's data map.",
                vault_ref.field,
                vault_ref.mount,
                vault_ref.path,
            )
        })?;

        // Extract string value — if it's a JSON string, unwrap it;
        // otherwise serialize it back to a JSON string.
        let text = match value.as_str() {
            Some(s) => s.to_string(),
            None => value.to_string(),
        };

        Ok(SecretString::from(text))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn valid_reference_mount_is_first_path_segment() {
        let r = VaultReference::parse("vault://secret/myapp#api_key").unwrap();
        assert_eq!(r.mount, "secret");
    }

    #[test]
    fn valid_reference_path_excludes_mount_segment() {
        let r = VaultReference::parse("vault://secret/myapp#api_key").unwrap();
        assert_eq!(r.path, "myapp");
    }

    #[test]
    fn valid_reference_field_is_fragment_portion() {
        let r = VaultReference::parse("vault://secret/myapp#api_key").unwrap();
        assert_eq!(r.field, "api_key");
    }

    #[test]
    fn nested_path_mount_is_first_segment() {
        let r = VaultReference::parse("vault://secret/data/team/app#password").unwrap();
        assert_eq!(r.mount, "secret");
    }

    #[test]
    fn nested_path_joins_all_segments_after_mount() {
        let r = VaultReference::parse("vault://secret/data/team/app#password").unwrap();
        assert_eq!(r.path, "data/team/app");
    }

    #[test]
    fn nested_path_field_is_preserved() {
        let r = VaultReference::parse("vault://secret/data/team/app#password").unwrap();
        assert_eq!(r.field, "password");
    }

    #[test]
    fn reference_without_hash_field_returns_error() {
        VaultReference::parse("vault://secret/myapp").unwrap_err();
    }

    #[test]
    fn empty_field_name_after_hash_returns_error() {
        VaultReference::parse("vault://secret/myapp#").unwrap_err();
    }

    #[test]
    fn missing_path_after_scheme_returns_error() {
        VaultReference::parse("vault://#field").unwrap_err();
    }

    #[test]
    fn mount_without_path_segment_returns_error() {
        VaultReference::parse("vault://secret#field").unwrap_err();
    }

    #[test]
    fn non_vault_scheme_returns_error() {
        VaultReference::parse("op://vault/item/field").unwrap_err();
    }

    #[test]
    fn empty_uri_body_returns_error() {
        VaultReference::parse("vault://").unwrap_err();
    }

    #[test]
    fn question_mark_in_mount_returns_error() {
        VaultReference::parse("vault://sec?ret/path#field").unwrap_err();
    }

    #[test]
    fn whitespace_in_path_segment_returns_error() {
        VaultReference::parse("vault://secret/my path#field").unwrap_err();
    }

    #[test]
    fn control_char_in_field_name_returns_error() {
        VaultReference::parse("vault://secret/path#fi\x00eld").unwrap_err();
    }

    #[test]
    fn data_prefix_in_path_does_not_cause_parse_error() {
        // Parsing itself should succeed — the data/ prefix warning is in resolve().
        VaultReference::parse("vault://secret/data/myapp#field").unwrap();
    }

    #[test]
    fn data_prefix_in_path_is_preserved_in_parsed_path() {
        let r = VaultReference::parse("vault://secret/data/myapp#field").unwrap();
        assert_eq!(r.path, "data/myapp");
    }

    #[test]
    fn token_rejects_newline() {
        assert!(!is_header_safe("tok\nen"));
    }

    #[test]
    fn token_rejects_del() {
        assert!(!is_header_safe("tok\x7Fen"));
    }

    #[test]
    fn token_rejects_non_ascii() {
        assert!(!is_header_safe("tök"));
    }

    #[test]
    fn legacy_format_vault_token_passes_header_safety_check() {
        // Vault tokens look like: s.XhzOVFgiTw3n3OYJqBiqIGfx
        assert!(is_header_safe("s.XhzOVFgiTw3n3OYJqBiqIGfx"));
    }

    #[test]
    fn hvs_format_vault_token_passes_header_safety_check() {
        // Vault tokens look like: hvs.XXXX
        assert!(is_header_safe("hvs.CAESIBtR0QkDnWL0oFKj9iC8AAAA"));
    }

    #[test]
    fn namespace_rejects_del() {
        assert!(!is_header_safe("admin\x7F/team"));
    }

    #[test]
    fn namespace_rejects_non_ascii() {
        assert!(!is_header_safe("admin/tëam"));
    }

    #[test]
    fn namespace_with_path_separator_passes_header_safety_check() {
        assert!(is_header_safe("admin/team-a"));
    }

    #[test]
    fn simple_namespace_passes_header_safety_check() {
        assert!(is_header_safe("root"));
    }
}