1#![doc = include_str!("../README.md")]
3use 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#[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#[derive(Clone, PartialEq, ZeroizeOnDrop)]
186pub enum Secret {
187 Plain {
210 #[zeroize]
211 content: String,
212 },
213 Env {
246 #[zeroize(skip)]
247 key: String,
248 #[zeroize]
249 content: String,
250 #[zeroize(skip)]
251 encoding: Option<Encoding>,
252 },
253 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 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 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 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 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 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}