Skip to main content

akv_cli/
parsing.rs

1// Copyright 2025 Heath Stewart.
2// Licensed under the MIT License. See LICENSE.txt in the project root for license information.
3
4//! Parsing utility functions.
5
6use crate::{Error, ErrorKind, Result, ResultExt};
7use futures::future::BoxFuture;
8use std::{borrow::Cow, io, str::FromStr};
9use time::{format_description::well_known, OffsetDateTime};
10use url::Url;
11
12/// Parse an optional [`OffsetDateTime`] argument from `clap`.
13pub fn parse_date_time_opt(value: &str) -> Result<OffsetDateTime> {
14    OffsetDateTime::parse(value, &well_known::Rfc3339)
15        .with_context(ErrorKind::InvalidData, "failed to parse date-time")
16}
17
18/// Parse a `key=value` argument from `clap`.
19pub fn parse_key_value<T>(value: &str) -> Result<(String, T)>
20where
21    T: FromStr,
22    Error: From<<T as FromStr>::Err>,
23{
24    let idx = value
25        .find("=")
26        .ok_or_else(|| format!("no '=' found in '{value}'"))?;
27    Ok((value[..idx].to_string(), value[idx + 1..].parse()?))
28}
29
30/// Parse an `key=(value)` argument from `clap` where `value` is optional.
31pub fn parse_key_value_opt<T>(value: &str) -> Result<(String, Option<T>)>
32where
33    T: FromStr,
34    Error: From<<T as FromStr>::Err>,
35{
36    if let Some(idx) = value.find("=") {
37        return Ok((value[..idx].to_string(), Some(value[idx + 1..].parse()?)));
38    }
39
40    Ok((value.to_string(), None))
41}
42
43/// Replaces variables between `{{ }}` with text returned from function.
44///
45/// The function `f` receives a variable named (trimmed of leading and trailing whitespace)
46/// and must return an `Ok(String)` or an `Err`, which will terminate the replacement. Any text written to
47/// the [`io::Write`] `w` will be left as-is.
48///
49/// Overlapping templates e.g., `{{ {{foo}} }}` with attempt to replace the text
50/// between the first occurrences of both `{{` and `}}`, so the function would receive `{{foo`
51/// and, even if the function returned `Ok("".to_string())`, would still contain the final `}}` in the text.
52///
53/// # Examples
54///
55/// ```
56/// use akv_cli::parsing::replace_expressions;
57/// # use futures::{FutureExt, StreamExt};
58///
59/// # async fn test_replace_expressions() {
60/// let s = "Hello, {{ var }}!";
61/// let mut buf = Vec::new();
62///
63/// replace_expressions(s, &mut buf, |v| {
64///     assert_eq!(v, "var");
65///     async { Ok(String::from("world")) }.boxed()
66/// }).await.unwrap();
67///
68/// assert_eq!(String::from_utf8(buf).unwrap(), "Hello, world!");
69/// # }
70/// ```
71pub async fn replace_expressions<W, F>(mut template: &str, w: &mut W, f: F) -> Result<()>
72where
73    W: io::Write,
74    F: Fn(&str) -> BoxFuture<'_, Result<String>>,
75{
76    const START: &str = "{{";
77    const START_LEN: usize = START.len();
78    const END: &str = "}}";
79    const END_LEN: usize = END.len();
80
81    while let Some(mut start) = template.find(START) {
82        // Start only after the first "{{".
83        let Some(mut end) = template[start + START_LEN..].find(END) else {
84            return Err(Error::with_message(
85                ErrorKind::InvalidData,
86                "missing closing '}}'",
87            ));
88        };
89        end += start + START_LEN;
90
91        w.write_all(&template.as_bytes()[..start])?;
92        start += START_LEN;
93
94        let id = template[start..end].trim();
95        let secret = f(id).await?;
96
97        w.write_all(secret.as_bytes())?;
98        end += END_LEN;
99
100        template = &template[end..];
101    }
102
103    w.write_all(template.as_bytes())?;
104    Ok(())
105}
106
107/// Replaces variables in the form $VAR_NAME with text returned from a function.
108pub fn replace_vars<F>(input: &str, f: F) -> Result<Cow<'_, str>>
109where
110    F: Fn(&str) -> Result<String>,
111{
112    let mut cur = input;
113    let mut output = String::new();
114
115    while let Some(start) = cur.find('$') {
116        output += &cur[..start];
117        cur = &cur[start + 1..];
118
119        let mut end = cur.len();
120        for (i, c) in cur.char_indices() {
121            if !c.is_ascii_alphanumeric() && c != '_' {
122                end = i;
123                break;
124            }
125        }
126
127        let name = &cur[..end];
128        if !name.is_empty() {
129            output += &f(name)?;
130        }
131        cur = &cur[end..];
132    }
133
134    if output.is_empty() {
135        Ok(Cow::Borrowed(input))
136    } else {
137        output += cur;
138        Ok(Cow::Owned(output))
139    }
140}
141
142/// Represents secret, key, or certificate resources.
143#[derive(Clone, Debug)]
144pub struct Resource {
145    /// The containing vault URL e.g., "https://my-vault.vault.azure.net".
146    pub vault_url: String,
147
148    /// The name of the secret, key, or certificate resource.
149    pub name: String,
150
151    /// The optional version of the secret, key, or certificate resource.
152    pub version: Option<String>,
153}
154
155impl TryFrom<Url> for Resource {
156    type Error = crate::Error;
157
158    #[inline]
159    fn try_from(url: Url) -> std::result::Result<Self, Self::Error> {
160        Self::try_from(&url)
161    }
162}
163
164impl TryFrom<&Url> for Resource {
165    type Error = crate::Error;
166
167    fn try_from(url: &Url) -> std::result::Result<Self, Self::Error> {
168        Ok(azure_security_keyvault_secrets::ResourceId::try_from(url)
169            .map(From::from)
170            .or_else(|_| azure_security_keyvault_keys::ResourceId::try_from(url).map(From::from))
171            .or_else(|_| {
172                azure_security_keyvault_certificates::ResourceId::try_from(url).map(From::from)
173            })?)
174    }
175}
176
177impl From<azure_security_keyvault_secrets::ResourceId> for Resource {
178    fn from(value: azure_security_keyvault_secrets::ResourceId) -> Self {
179        Self {
180            vault_url: value.vault_url,
181            name: value.name,
182            version: value.version,
183        }
184    }
185}
186
187impl From<azure_security_keyvault_keys::ResourceId> for Resource {
188    fn from(value: azure_security_keyvault_keys::ResourceId) -> Self {
189        Self {
190            vault_url: value.vault_url,
191            name: value.name,
192            version: value.version,
193        }
194    }
195}
196
197impl From<azure_security_keyvault_certificates::ResourceId> for Resource {
198    fn from(value: azure_security_keyvault_certificates::ResourceId) -> Self {
199        Self {
200            vault_url: value.vault_url,
201            name: value.name,
202            version: value.version,
203        }
204    }
205}
206
207impl FromStr for Resource {
208    type Err = crate::Error;
209
210    fn from_str(s: &str) -> Result<Self> {
211        let url: Url = s.parse()?;
212        url.try_into()
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219    use futures::FutureExt as _;
220
221    #[test]
222    fn resource_from_secret_url() {
223        let url: Url = "https://my-vault.vault.azure.net/secrets/my-secret"
224            .parse()
225            .unwrap();
226        let resource = Resource::try_from(url).expect("valid secret URL");
227        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
228        assert_eq!(resource.name, "my-secret");
229        assert!(resource.version.is_none());
230    }
231
232    #[test]
233    fn resource_from_secret_url_with_version() {
234        let url: Url =
235            "https://my-vault.vault.azure.net/secrets/my-secret/746984e474594896aad9aff48aca0849"
236                .parse()
237                .unwrap();
238        let resource = Resource::try_from(url).expect("valid secret URL with version");
239        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
240        assert_eq!(resource.name, "my-secret");
241        assert_eq!(
242            resource.version.as_deref(),
243            Some("746984e474594896aad9aff48aca0849")
244        );
245    }
246
247    #[test]
248    fn resource_from_key_url() {
249        let url: Url = "https://my-vault.vault.azure.net/keys/my-key"
250            .parse()
251            .unwrap();
252        let resource = Resource::try_from(url).expect("valid key URL");
253        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
254        assert_eq!(resource.name, "my-key");
255        assert!(resource.version.is_none());
256    }
257
258    #[test]
259    fn resource_from_key_url_with_version() {
260        let url: Url = "https://my-vault.vault.azure.net/keys/my-key/1234567890abcdef"
261            .parse()
262            .unwrap();
263        let resource = Resource::try_from(url).expect("valid key URL with version");
264        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
265        assert_eq!(resource.name, "my-key");
266        assert_eq!(resource.version.as_deref(), Some("1234567890abcdef"));
267    }
268
269    #[test]
270    fn resource_from_certificate_url() {
271        let url: Url = "https://my-vault.vault.azure.net/certificates/my-cert"
272            .parse()
273            .unwrap();
274        let resource = Resource::try_from(url).expect("valid certificate URL");
275        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
276        assert_eq!(resource.name, "my-cert");
277        assert!(resource.version.is_none());
278    }
279
280    #[test]
281    fn resource_from_certificate_url_with_version() {
282        let url: Url = "https://my-vault.vault.azure.net/certificates/my-cert/abcdef1234567890"
283            .parse()
284            .unwrap();
285        let resource = Resource::try_from(url).expect("valid certificate URL with version");
286        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
287        assert_eq!(resource.name, "my-cert");
288        assert_eq!(resource.version.as_deref(), Some("abcdef1234567890"));
289    }
290
291    #[test]
292    fn resource_from_str() {
293        let resource: Resource = "https://my-vault.vault.azure.net/secrets/my-secret"
294            .parse()
295            .expect("valid secret URL string");
296        assert_eq!(resource.vault_url, "https://my-vault.vault.azure.net");
297        assert_eq!(resource.name, "my-secret");
298        assert!(resource.version.is_none());
299    }
300
301    #[test]
302    fn resource_from_invalid_url() {
303        let url: Url = "https://my-vault.vault.azure.net".parse().unwrap();
304        Resource::try_from(url).expect_err("vault URL without resource path should fail");
305    }
306
307    #[test]
308    fn test_parse_key_value() {
309        let kv = parse_key_value::<String>("key=value");
310        assert!(matches!(kv, Ok(kv) if kv.0 == "key" && kv.1 == "value"));
311
312        let kv = parse_key_value::<String>("key=value=other");
313        assert!(matches!(kv, Ok(kv) if kv.0 == "key" && kv.1 == "value=other"));
314
315        parse_key_value::<String>("key").expect_err("requires '='");
316
317        let k = parse_key_value::<i32>("key=1");
318        assert!(matches!(k, Ok(k) if k.0 == "key" && k.1 == 1));
319
320        parse_key_value::<i32>("key=value").expect_err("should not parse 'value' as i32");
321    }
322
323    #[test]
324    fn test_parse_key_value_opt() {
325        let kv = parse_key_value_opt::<String>("key=value");
326        assert!(matches!(kv, Ok(kv) if kv.0 == "key" && kv.1 == Some("value".into())));
327
328        let kv = parse_key_value_opt::<String>("key=value=other");
329        assert!(matches!(kv, Ok(kv) if kv.0 == "key" && kv.1 == Some("value=other".into())));
330
331        let k = parse_key_value_opt::<i32>("key");
332        assert!(matches!(k, Ok(k) if k.0 == "key" && k.1.is_none()));
333
334        parse_key_value_opt::<i32>("key=value").expect_err("should not parse 'value' as i32");
335    }
336
337    #[tokio::test]
338    async fn test_replace_expressions() {
339        let s = "Hello, {{ var }}!";
340        let mut buf = Vec::new();
341
342        replace_expressions(s, &mut buf, |v| {
343            assert_eq!(v, "var");
344            async { Ok(String::from("world")) }.boxed()
345        })
346        .await
347        .unwrap();
348        assert_eq!(String::from_utf8(buf).unwrap(), "Hello, world!");
349    }
350
351    #[tokio::test]
352    async fn replace_expressions_overlap() {
353        let s = "Hello, {{ {{var}} }}!";
354        let mut buf = Vec::new();
355
356        replace_expressions(s, &mut buf, |v| {
357            assert_eq!(v, "{{var");
358            async { Ok(String::from("world")) }.boxed()
359        })
360        .await
361        .unwrap();
362        assert_eq!(String::from_utf8(buf).unwrap(), "Hello, world }}!");
363    }
364
365    #[tokio::test]
366    async fn replace_expressions_missing_end() {
367        let s = "Hello, {{ var!";
368        let mut buf = Vec::new();
369
370        replace_expressions(s, &mut buf, |_| async { Ok(String::from("world")) }.boxed())
371            .await
372            .expect_err("missing end");
373    }
374
375    #[tokio::test]
376    async fn replace_expressions_missing_empty() {
377        let s = "";
378        let mut buf = Vec::new();
379
380        replace_expressions(s, &mut buf, |_| async { Ok(String::from("world")) }.boxed())
381            .await
382            .unwrap();
383        assert_eq!(String::from_utf8(buf).unwrap(), "");
384    }
385
386    #[tokio::test]
387    async fn replace_expressions_missing_no_template() {
388        let s = "Hello, world!";
389        let mut buf = Vec::new();
390
391        replace_expressions(s, &mut buf, |_| {
392            async { Ok(String::from("Ferris")) }.boxed()
393        })
394        .await
395        .unwrap();
396        assert_eq!(String::from_utf8(buf).unwrap(), "Hello, world!");
397    }
398
399    #[test]
400    fn replace_vars_borrowed() {
401        let s = "echo NONE";
402        let out = replace_vars(s, |name| {
403            assert_eq!(name, "VAR");
404            Ok(String::from("VALUE"))
405        })
406        .expect("replaces $VAR with VALUE");
407        assert!(matches!(out, Cow::Borrowed(out) if out == "echo NONE"));
408    }
409
410    #[test]
411    fn replace_vars_owned() {
412        let s = "echo $VAR";
413        let out = replace_vars(s, |name| {
414            assert_eq!(name, "VAR");
415            Ok(String::from("VALUE"))
416        })
417        .expect("replaces $VAR with VALUE");
418        assert!(matches!(out, Cow::Owned(out) if out == "echo VALUE"));
419    }
420
421    #[test]
422    fn replace_only_vars() {
423        let s = "$VAR";
424        let out = replace_vars(s, |name| {
425            assert_eq!(name, "VAR");
426            Ok(String::from("VALUE"))
427        })
428        .expect("replaces $VAR with VALUE");
429        assert!(matches!(out, Cow::Owned(out) if out == "VALUE"));
430    }
431
432    #[test]
433    fn replace_vars_errs() {
434        let s = "echo $VAR";
435        replace_vars(s, |name| {
436            assert_eq!(name, "VAR");
437            Err(Error::with_message(ErrorKind::Other, "test"))
438        })
439        .expect_err("expected error");
440    }
441
442    #[tokio::test]
443    async fn replace_expression_with_var() {
444        let s = "Hello, {{ $VAR }}!";
445        let mut buf = Vec::new();
446
447        replace_expressions(s, &mut buf, |expr| {
448            async move {
449                assert_eq!(expr, "$VAR");
450                replace_vars(expr, |var| {
451                    assert_eq!(var, "VAR");
452                    Ok(String::from("world"))
453                })
454                .map(Into::into)
455            }
456            .boxed()
457        })
458        .await
459        .expect("replaces $VAR with 'world'");
460        assert_eq!(String::from_utf8(buf).unwrap(), "Hello, world!");
461    }
462}