Skip to main content

secret_rs/
lib.rs

1//!
2#![doc = include_str!("../README.md")]
3//!
4
5use crate::error::SecretError;
6use base64::{Engine, engine::GeneralPurpose, prelude::BASE64_STANDARD};
7pub use error::Result;
8#[cfg(feature = "notify")]
9pub use notify::SecretWatcher;
10use serde::{Deserialize, Serialize, de::Visitor, ser::SerializeMap};
11use serde_json::Value;
12use std::{
13    ffi::OsStr,
14    fmt::{Debug, Display},
15    fs,
16    path::{Path, PathBuf},
17};
18use zeroize::ZeroizeOnDrop;
19
20mod error;
21#[cfg(feature = "notify")]
22mod notify;
23
24struct Base64(GeneralPurpose);
25
26trait Decoder {
27    fn decode(&self, input: &str) -> Result<Vec<u8>>;
28}
29
30impl Decoder for Base64 {
31    fn decode(&self, input: &str) -> Result<Vec<u8>> {
32        self.0
33            .decode(input)
34            .map_err(|err| SecretError::Decode(Encoding::Base64.to_string(), err.to_string()))
35    }
36}
37
38/// Define which type of encoding the library supports when it needs to read the actual secret value.
39#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq)]
40#[cfg_attr(feature = "json-schema", derive(::schemars::JsonSchema))]
41pub enum Encoding {
42    #[serde(rename = "base64")]
43    Base64,
44}
45
46impl Display for Encoding {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        write!(
49            f,
50            "{}",
51            match self {
52                Encoding::Base64 => "base64",
53            }
54        )
55    }
56}
57
58fn decode(content: &str, encoding: Option<Encoding>) -> Result<String> {
59    if let Some(encoding) = encoding {
60        let decoder: Box<dyn Decoder> = match encoding {
61            Encoding::Base64 => Box::new(Base64(BASE64_STANDARD)),
62        };
63        decoder
64            .decode(content)
65            .map_err(|err| SecretError::Decode(encoding.to_string(), err.to_string()))
66            .and_then(|content| {
67                String::from_utf8(content).map_err(|err| SecretError::InvalidUtf8(err.to_string()))
68            })
69    } else {
70        Ok(content.to_string())
71    }
72}
73
74enum SupportedExtensions {
75    Json,
76    Ini,
77    None,
78}
79
80enum JsonIndex<'a> {
81    String(&'a str),
82    Index(u32),
83}
84
85fn traverse_json_and_get<'a, I>(
86    json: &'a Value,
87    mut path: I,
88    output: &'a mut Option<&'a Value>,
89) -> Result<&'a mut Option<&'a Value>, String>
90where
91    I: Iterator<Item = JsonIndex<'a>>,
92{
93    if let Some(index) = path.next() {
94        match (index, json) {
95            (JsonIndex::Index(idx), Value::Array(arr)) => traverse_json_and_get(
96                arr.get(idx as usize).ok_or(format!(
97                    "while traversing array no item at index '{idx}' was found"
98                ))?,
99                path,
100                output,
101            ),
102            (JsonIndex::String(str), Value::Object(obj)) => traverse_json_and_get(
103                obj.get(str).ok_or(format!(
104                    "while traversing object no field named '{str}' was found"
105                ))?,
106                path,
107                output,
108            ),
109            (index, json) => Err(format!(
110                "found index {} to traverse invalid structure {}",
111                match index {
112                    JsonIndex::String(str) => format!("String({str})"),
113                    JsonIndex::Index(idx) => format!("Integer({idx})"),
114                },
115                match json {
116                    Value::Null => "null",
117                    Value::Bool(_) => "bool",
118                    Value::Number(_) => "number",
119                    Value::String(_) => "string",
120                    Value::Array(_) => "array",
121                    Value::Object(_) => "object",
122                }
123            )),
124        }
125    } else {
126        *output = Some(json);
127        Ok(output)
128    }
129}
130
131impl From<Option<&OsStr>> for SupportedExtensions {
132    fn from(value: Option<&OsStr>) -> Self {
133        if let Some(extension) = value {
134            if extension == "json" {
135                SupportedExtensions::Json
136            } else if extension == "ini" {
137                SupportedExtensions::Ini
138            } else {
139                SupportedExtensions::None
140            }
141        } else {
142            SupportedExtensions::None
143        }
144    }
145}
146
147fn get_key_from_file(extension: Option<&OsStr>, content: &str, key: &str) -> Result<String> {
148    use ini::Ini;
149
150    match extension.into() {
151        SupportedExtensions::Json => {
152            let json_content = serde_json::from_str::<Value>(content)
153                .map_err(|err| SecretError::Json(err.to_string()))?;
154
155            let key_path = key.split('.').map(|index| {
156                let index = index.trim().strip_prefix('[').unwrap_or(index);
157                let index = index.strip_suffix(']').unwrap_or(index).trim();
158                let index = index.strip_prefix('\'').unwrap_or(index);
159                let index = index.strip_suffix('\'').unwrap_or(index).trim();
160                index
161                    .parse::<u32>()
162                    .map(JsonIndex::Index)
163                    .unwrap_or(JsonIndex::String(index))
164            });
165            traverse_json_and_get(&json_content, key_path, &mut None)
166                .map_err(|err| SecretError::JsonTraverse(err.to_string()))?
167                .and_then(|v| match v {
168                    Value::String(s) => Some(s.clone()),
169                    _ => None,
170                })
171                .ok_or(SecretError::JsonKey(key.to_string()))
172        }
173        _ => {
174            let ini_content =
175                Ini::load_from_str(content).map_err(|err| SecretError::Ini(err.to_string()))?;
176            ini_content
177                .get_from(None::<String>, key)
178                .map(String::from)
179                .ok_or(SecretError::JsonKey(key.to_string()))
180        }
181    }
182}
183
184/// A secret container that abstracts how it is written within the configuration file.
185#[derive(Clone, PartialEq, ZeroizeOnDrop)]
186pub enum Secret {
187    /// Represents a plaintext secret. It is read as-is from the configuration file.
188    ///
189    /// ```
190    /// use serde::Deserialize;
191    /// use serde_json::json;
192    /// use secret_rs::Secret;
193    ///
194    /// #[derive(Deserialize, Debug, PartialEq)]
195    /// struct SimpleConfig {
196    ///     plain: Secret
197    /// }
198    ///
199    /// let parsed_config = serde_json::from_value::<SimpleConfig>(json!({
200    ///     "plain": "my-secret-value"
201    /// })).unwrap();
202    ///
203    /// let expected_config = SimpleConfig {
204    ///     plain: Secret::Plain { content: String::from("my-secret-value") }
205    /// };
206    ///
207    /// assert_eq!(parsed_config, expected_config)
208    /// ```
209    Plain {
210        #[zeroize]
211        content: String,
212    },
213    /// Represents a secret contained within the selected environment variable, which is specified in the `key` field.
214    /// Encoding can be signalled using the `encoding` key with value `"base64"`.
215    ///
216    /// ```
217    /// use serde::Deserialize;
218    /// use serde_json::json;
219    /// use secret_rs::Secret;
220    ///
221    /// #[derive(Deserialize, Debug, PartialEq)]
222    /// struct EnvConfig {
223    ///     env: Secret
224    /// }
225    ///
226    /// unsafe {std::env::set_var("MY_SECRET_INFO", String::from("my-secret-value"));}
227    ///
228    /// let parsed_config = serde_json::from_value::<EnvConfig>(json!({
229    ///     "env": {
230    ///         "type": "env",
231    ///         "key": "MY_SECRET_INFO"
232    ///     }
233    /// })).unwrap();
234    ///
235    /// let expected_config = EnvConfig {
236    ///     env: Secret::Env {
237    ///         key: String::from("MY_SECRET_INFO"),
238    ///         content: String::from("my-secret-value"),
239    ///         encoding: None
240    ///     }
241    /// };
242    ///
243    /// assert_eq!(parsed_config, expected_config);
244    /// ```
245    Env {
246        #[zeroize(skip)]
247        key: String,
248        #[zeroize]
249        content: String,
250        #[zeroize(skip)]
251        encoding: Option<Encoding>,
252    },
253    /// Represents a secret contained in a file, whose filepath can be found in the `path` field. A file may contain
254    /// either a single value, which is loaded completely into the secret content, or a set of key-value pairs
255    /// (following [.ini](https://en.wikipedia.org/wiki/INI_file) format), where only the one specified in the `key` field is loaded.
256    /// Encoding can be signalled using the `encoding` key with value `"base64"`.
257    ///
258    /// #### Secret loaded from the whole file
259    ///
260    /// ```
261    /// use std::path::PathBuf;
262    /// use assert_fs::fixture::{FileWriteStr, PathChild};
263    /// use serde::Deserialize;
264    /// use serde_json::json;
265    /// use secret_rs::Secret;
266    ///
267    /// #[derive(Deserialize, Debug, PartialEq)]
268    /// struct FileConfig {
269    ///     file: Secret
270    /// }
271    ///
272    /// let temp = assert_fs::TempDir::new().unwrap();
273    /// let file = temp.child("private-key.pem");
274    /// let path = file.to_string_lossy();
275    ///
276    /// file.write_str("PEM-secret\n").unwrap();
277    ///
278    /// let parsed_config = serde_json::from_value::<FileConfig>(json!({
279    ///     "file": {
280    ///         "type": "file",
281    ///         "path": path
282    ///     }
283    /// })).unwrap();
284    ///
285    /// let expected_config = FileConfig {
286    ///     file: Secret::File {
287    ///         path: PathBuf::from(path.to_string()),
288    ///         key: None,
289    ///         content: "PEM-secret\n".into(),
290    ///         encoding: None
291    ///     }
292    /// };
293    ///
294    /// assert_eq!(parsed_config, expected_config);
295    /// ```
296    ///
297    /// #### Secret loaded from a key of a secrets file
298    ///
299    /// ```
300    /// use std::path::PathBuf;
301    /// use assert_fs::fixture::{FileWriteStr, PathChild};
302    /// use serde::Deserialize;
303    /// use serde_json::json;
304    /// use secret_rs::Secret;
305    ///
306    /// #[derive(Deserialize, Debug, PartialEq)]
307    /// struct FileConfig {
308    ///     file: Secret
309    /// }
310    ///
311    /// let temp = assert_fs::TempDir::new().unwrap();
312    /// let file = temp.child("secrets.ini");
313    /// let path = file.to_string_lossy();
314    ///
315    /// file.write_str("MY_SECRET=hello_there\n").unwrap();
316    ///
317    /// let parsed_config = serde_json::from_value::<FileConfig>(json!({
318    ///     "file": {
319    ///         "type": "file",
320    ///         "key": "MY_SECRET",
321    ///         "path": path
322    ///     }
323    /// })).unwrap();
324    ///
325    /// let expected_config = FileConfig {
326    ///     file: Secret::File {
327    ///         path: PathBuf::from(path.to_string()),
328    ///         key: Some(String::from("MY_SECRET")),
329    ///         content: String::from("hello_there"),
330    ///         encoding: None
331    ///     }
332    /// };
333    ///
334    /// assert_eq!(parsed_config, expected_config);
335    /// ```
336    ///
337    /// Secrets can also be stored in JSON files.
338    ///
339    /// ```
340    /// use std::path::PathBuf;
341    /// use assert_fs::fixture::{FileWriteStr, PathChild};
342    /// use serde::Deserialize;
343    /// use serde_json::json;
344    /// use secret_rs::Secret;
345    ///
346    /// #[derive(Deserialize, Debug, PartialEq)]
347    /// struct FileConfig {
348    ///     file: Secret
349    /// }
350    ///
351    /// let temp = assert_fs::TempDir::new().unwrap();
352    /// let file = temp.child("secrets.json");
353    /// let path = file.to_string_lossy();
354    ///
355    /// file.write_str(r#"{"key1":[{"MY_SECRET":"hello_there"}]}"#).unwrap();
356    ///
357    /// let parsed_config = serde_json::from_value::<FileConfig>(json!({
358    ///     "file": {
359    ///         "type": "file",
360    ///         "key": "key1.[0].MY_SECRET",
361    ///         "path": path
362    ///     }
363    /// })).unwrap();
364    ///
365    /// let expected_config = FileConfig {
366    ///     file: Secret::File {
367    ///         path: PathBuf::from(path.to_string()),
368    ///         key: Some(String::from("key1.[0].MY_SECRET")),
369    ///         content: String::from("hello_there"),
370    ///         encoding: None
371    ///     }
372    /// };
373    ///
374    /// assert_eq!(parsed_config, expected_config);
375    /// ```
376    File {
377        #[zeroize(skip)]
378        path: PathBuf,
379        #[zeroize(skip)]
380        key: Option<String>,
381        #[zeroize]
382        content: String,
383        #[zeroize(skip)]
384        encoding: Option<Encoding>,
385    },
386}
387
388impl Secret {
389    /// Extract the actual secret content from the underlying data structure.
390    pub fn read(&self) -> &str {
391        match self {
392            Secret::Plain { content }
393            | Secret::Env { content, .. }
394            | Secret::File { content, .. } => content.as_str(),
395        }
396    }
397}
398
399impl AsRef<str> for Secret {
400    fn as_ref(&self) -> &str {
401        self.read()
402    }
403}
404
405impl Default for Secret {
406    fn default() -> Self {
407        Self::Plain {
408            content: Default::default(),
409        }
410    }
411}
412
413impl From<String> for Secret {
414    fn from(value: String) -> Self {
415        Secret::Plain { content: value }
416    }
417}
418
419impl From<&str> for Secret {
420    fn from(value: &str) -> Self {
421        Self::Plain {
422            content: value.into(),
423        }
424    }
425}
426
427impl Debug for Secret {
428    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
429        match self {
430            Self::Plain { .. } => f.debug_tuple("Plain").field(&"[REDACTED]").finish(),
431            Self::Env { key, .. } => f.debug_tuple("Env").field(key).finish(),
432            Self::File { path, key, .. } => f
433                .debug_struct("File")
434                .field("path", path)
435                .field("key", key)
436                .finish(),
437        }
438    }
439}
440
441impl Display for Secret {
442    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
443        match self {
444            Secret::Plain { .. } => write!(f, "[REDACTED]"),
445            Secret::Env { key, .. } => {
446                write!(f, r#"type: "env", key: "{key}"#)
447            }
448            Secret::File { path, key, .. } => {
449                write!(f, r#"type: "file", path: {path:?}, key: "{key:?}"#)
450            }
451        }
452    }
453}
454
455#[cfg(feature = "json-schema")]
456impl ::schemars::JsonSchema for Secret {
457    fn schema_name() -> std::borrow::Cow<'static, str> {
458        "Secret".into()
459    }
460
461    fn json_schema(generator: &mut ::schemars::SchemaGenerator) -> ::schemars::Schema {
462        use ::schemars::json_schema;
463
464        json_schema!({
465            "examples": [
466                "my-secret",
467                {
468                    "type":"env",
469                    "key":"CUSTOM_ENV_VAR"
470                },
471                {
472                    "type":"env",
473                    "key":"CUSTOM_ENV_VAR",
474                    "encoding": "base64"
475                },
476                {
477                    "type":"file",
478                    "path":"/path/to/file"
479                }
480            ],
481            "anyOf": [
482                {
483                    "type": "string"
484                },
485                {
486                    "type": "object",
487                    "required": ["type", "key"],
488                    "properties": {
489                        "type": {
490                            "const": "env"
491                        },
492                        "key": {
493                            "type": "string"
494                        },
495                        "encoding": Encoding::json_schema(generator),
496                    }
497                },
498                {
499                    "type": "object",
500                    "required": ["type", "path"],
501                    "properties": {
502                        "type": {
503                            "const": "file"
504                        },
505                        "key": {
506                            "type": "string"
507                        },
508                        "path": {
509                            "type": "string",
510                        },
511                        "encoding": Encoding::json_schema(generator),
512                    }
513                }
514            ]
515        })
516    }
517}
518
519impl Serialize for Secret {
520    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
521    where
522        S: serde::Serializer,
523    {
524        match self {
525            Secret::Plain { content } => serializer.serialize_str(content),
526            Secret::Env { key, encoding, .. } => {
527                let mut map = serializer.serialize_map(None)?;
528                map.serialize_entry("type", "env")?;
529                map.serialize_entry("key", key)?;
530                if let Some(encoding) = encoding {
531                    map.serialize_entry("encoding", encoding)?;
532                }
533                map.end()
534            }
535            Secret::File {
536                path,
537                key,
538                encoding,
539                ..
540            } => {
541                let mut map = serializer.serialize_map(None)?;
542                map.serialize_entry("type", "file")?;
543                map.serialize_entry("path", path)?;
544                if let Some(key) = key {
545                    map.serialize_entry("key", key)?;
546                }
547                if let Some(encoding) = encoding {
548                    map.serialize_entry("encoding", encoding)?;
549                }
550                map.end()
551            }
552        }
553    }
554}
555
556struct SecretVisitor;
557
558fn get_content_from_file(
559    path: &Path,
560    key: Option<&str>,
561    encoding: Option<Encoding>,
562) -> Result<String> {
563    let content = fs::read_to_string(path)
564        .map_err(|err| SecretError::FileRead(path.to_path_buf(), err.to_string()))?;
565
566    let content = match key.as_ref() {
567        Some(key) => get_key_from_file(path.extension(), &content, key)?,
568        None => content,
569    };
570
571    decode(&content, encoding)
572}
573
574impl<'de> Visitor<'de> for SecretVisitor {
575    type Value = Secret;
576
577    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
578        formatter.write_str("enum Secret")
579    }
580
581    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
582    where
583        E: serde::de::Error,
584    {
585        Ok(Secret::Plain {
586            content: v.to_string(),
587        })
588    }
589
590    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
591    where
592        A: serde::de::MapAccess<'de>,
593    {
594        enum SecretGuard {
595            Env,
596            File,
597        }
598
599        let mut type_name = None;
600        let mut key_name = None;
601        let mut path_name = None;
602        let mut encoding_name = None;
603        while let Ok(Some(key)) = map.next_key::<String>() {
604            match key.as_str() {
605                "type" => {
606                    let type_value = map.next_value::<String>()?;
607                    type_name = match type_value.as_str() {
608                        "env" => Some(SecretGuard::Env),
609                        "file" => Some(SecretGuard::File),
610                        _ => {
611                            return Err(<A::Error as serde::de::Error>::custom(
612                                "unsupported value for key 'type'",
613                            ));
614                        }
615                    };
616                }
617                "key" => {
618                    let key_value = map.next_value::<String>()?;
619                    key_name = Some(key_value);
620                }
621                "path" => {
622                    let path_value = map.next_value::<PathBuf>()?;
623                    path_name = Some(path_value);
624                }
625                "encoding" => {
626                    let encoding_value = map.next_value::<Encoding>()?;
627                    encoding_name = Some(encoding_value);
628                }
629                _ => {}
630            }
631        }
632
633        match (type_name, key_name, path_name, encoding_name) {
634            (Some(SecretGuard::Env), Some(key_name), _, encoding) => Ok(Secret::Env {
635                content: std::env::var(key_name.clone())
636                    .map_err(|err| {
637                        <A::Error as serde::de::Error>::custom(format!(
638                            "cannot read environment variable '{key_name}': {err}"
639                        ))
640                    })
641                    .and_then(|content| {
642                        decode(&content, encoding)
643                            .map_err(|err| <A::Error as serde::de::Error>::custom(err.to_string()))
644                    })?,
645                key: key_name,
646                encoding,
647            }),
648            (Some(SecretGuard::File), key, Some(path), encoding) => Ok(Secret::File {
649                content: get_content_from_file(&path, key.as_deref(), encoding)
650                    .map_err(|err| <A::Error as serde::de::Error>::custom(err.to_string()))?,
651                path,
652                key,
653                encoding,
654            }),
655            _ => Err(<A::Error as serde::de::Error>::custom(
656                "unsupported enum variant",
657            )),
658        }
659    }
660}
661
662impl<'de> Deserialize<'de> for Secret {
663    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
664    where
665        D: serde::Deserializer<'de>,
666    {
667        deserializer.deserialize_any(SecretVisitor)
668    }
669}
670
671#[cfg(test)]
672mod tests {
673    use super::{Encoding, Secret};
674    use assert_fs::fixture::{FileWriteStr, PathChild};
675    use base64::prelude::*;
676    use rstest::rstest;
677    use std::path::PathBuf;
678
679    #[rstest]
680    #[case(r#""my secret""#, Secret::from("my secret"))]
681    #[case(r#"{"type":"env","key":"CUSTOM_ENV_VAR"}"#, Secret::Env { key: "CUSTOM_ENV_VAR".into(), content: "value".into(), encoding: None })]
682    fn serde_json_tests(#[case] input: &str, #[case] expected: Secret) {
683        match &expected {
684            Secret::Env { key, content, .. } => unsafe {
685                std::env::set_var(key, content);
686            },
687            Secret::Plain { .. } => {}
688            _ => unimplemented!(),
689        };
690
691        let input: Secret = serde_json::from_str(input).expect("input to be deserialized");
692        assert_eq!(input, expected);
693    }
694
695    #[rstest]
696    #[case("secret", "SECRET=hello\n", "SECRET")]
697    #[case("secret.ini", "SECRET=hello\n", "SECRET")]
698    #[case("secret.whatever", "SECRET=hello\n", "SECRET")]
699    #[case("secret1.json", r#"{"SECRET":"hello"}"#, "SECRET")]
700    #[case(
701        "secret2.json",
702        r#"{"ANOTHER_SECRET":{"SECRET":["hello"]}}"#,
703        "ANOTHER_SECRET.SECRET.0"
704    )]
705    fn secrets_in_file_with_keys(
706        #[case] filename: &str,
707        #[case] file_content: &str,
708        #[case] key: &str,
709    ) {
710        let temp = assert_fs::TempDir::new().unwrap();
711        let file = temp.child(filename);
712        let path = file.to_string_lossy();
713        file.write_str(file_content).unwrap();
714
715        // on linux, paths do not need escaping
716        // on windows, `\` must be escaped then we use the Value::String
717        // constructor to ensure properly formed JSON
718        let input = format!(
719            r#"{{"type":"file","path":{},"key":"{key}"}}"#,
720            serde_json::Value::String(path.to_string())
721        );
722
723        let full_secret: Secret = serde_json::from_str(&input).expect("input to be deserialized");
724        assert_eq!(
725            full_secret,
726            Secret::File {
727                path: PathBuf::from(path.to_string()),
728                key: Some(key.to_string()),
729                content: "hello".into(),
730                encoding: None
731            }
732        );
733    }
734
735    #[rstest]
736    #[case("secret")]
737    #[case("secret.ini")]
738    #[case("secret.whatever")]
739    fn file_secret_tests(#[case] filename: &str) {
740        let temp = assert_fs::TempDir::new().unwrap();
741        let file = temp.child(filename);
742        let path = file.to_string_lossy();
743
744        // on linux, paths do not need escaping
745        // on windows, `\` must be escaped then we use the Value::String
746        // constructor to ensure properly formed JSON
747        let input = format!(
748            r#"{{"type":"file","path":{}}}"#,
749            serde_json::Value::String(path.to_string())
750        );
751
752        file.write_str("SECRET=hello\n").unwrap();
753
754        let full_secret: Secret = serde_json::from_str(&input).expect("input to be deserialized");
755        assert_eq!(
756            full_secret,
757            Secret::File {
758                path: PathBuf::from(path.to_string()),
759                key: None,
760                content: "SECRET=hello\n".into(),
761                encoding: None
762            }
763        );
764    }
765
766    #[test]
767    fn partial_file_secret_tests() {
768        let temp = assert_fs::TempDir::new().unwrap();
769        let file = temp.child("secret");
770
771        let path = file.to_string_lossy();
772        // on linux, paths do not need escaping
773        // on windows, `\` must be escaped then we use the Value::String
774        // constructor to ensure properly formed JSON
775        let input = format!(
776            r#"{{"type":"file","path":{},"key":"SECRET"}}"#,
777            serde_json::Value::String(path.to_string())
778        );
779
780        file.write_str("SECRET=hello\n").unwrap();
781
782        let partial_secret: Secret =
783            serde_json::from_str(&input).expect("input to be deserialized");
784        assert_eq!(
785            partial_secret,
786            Secret::File {
787                path: PathBuf::from(path.to_string()),
788                key: Some("SECRET".into()),
789                content: "hello".into(),
790                encoding: None
791            }
792        );
793    }
794
795    #[rstest]
796    #[case(Encoding::Base64)]
797    fn partial_file_secret_tests_with_decoding(#[case] encoding: Encoding) {
798        let temp = assert_fs::TempDir::new().unwrap();
799        let file = temp.child("secret");
800
801        let path = file.to_string_lossy();
802        // on linux, paths do not need escaping
803        // on windows, `\` must be escaped then we use the Value::String
804        // constructor to ensure properly formed JSON
805        let input = format!(
806            r#"{{"type":"file","path":{},"key":"SECRET","encoding":"{encoding}"}}"#,
807            serde_json::Value::String(path.to_string())
808        );
809
810        let decoded = "hello";
811        let encoded = match encoding {
812            Encoding::Base64 => BASE64_STANDARD.encode(decoded),
813        };
814
815        let file_content = format!(
816            r#"SECRET="{encoded}"
817"#
818        );
819        file.write_str(&file_content).unwrap();
820
821        let partial_secret: Secret =
822            serde_json::from_str(&input).expect("input to be deserialized");
823        assert_eq!(
824            partial_secret,
825            Secret::File {
826                path: PathBuf::from(path.to_string()),
827                key: Some("SECRET".into()),
828                content: "hello".into(),
829                encoding: encoding.into()
830            }
831        );
832    }
833
834    #[rstest]
835    #[case(Secret::from("my secret"), "[REDACTED]")]
836    fn display_tests(#[case] secret: Secret, #[case] expected: &str) {
837        assert_eq!(format!("{secret}"), expected);
838    }
839
840    #[cfg(feature = "json-schema")]
841    #[rstest]
842    fn ensure_valid_json_schema() {
843        use schemars::{SchemaGenerator, generate::SchemaSettings};
844
845        let schema_gen = SchemaGenerator::new(SchemaSettings::draft07());
846        let schema = schema_gen.into_root_schema_for::<Secret>();
847
848        let validator = jsonschema::validator_for(schema.as_value()).expect("a valid json schema");
849        let examples = schema
850            .as_object()
851            .and_then(|m| m.get("examples"))
852            .and_then(|e| e.as_array())
853            .expect("json schema must have examples");
854
855        assert!(!examples.is_empty());
856
857        examples
858            .iter()
859            .for_each(|e| assert!(validator.validate(e).is_ok()))
860    }
861}