1use serde_json::{Map, Value};
8
9use crate::Kind;
10use crate::msg::{ConnectionField, FieldKind};
11
12pub const CORE_KEYS: &[&str] = &["threads"];
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
17pub struct Checked {
18 pub errors: Vec<String>,
19 pub warnings: Vec<String>,
20}
21
22pub fn check(
25 kind: Kind,
26 name: &str,
27 fields: &[ConnectionField],
28 connection: &Map<String, Value>,
29 unresolved: &[String],
30) -> Checked {
31 let mut out = Checked::default();
32 for (key, v) in connection {
33 let Some(f) = fields.iter().find(|f| &f.name == key) else {
34 if CORE_KEYS.contains(&key.as_str()) {
36 continue;
37 }
38 let hint = closest(key, fields.iter().map(|f| f.name.as_str()))
39 .map(|c| format!("; did you mean `{c}`?"))
40 .unwrap_or_default();
41 out.warnings
42 .push(format!("unknown key `{key}` for {kind} `{name}`, ignored{hint}"));
43 continue;
44 };
45 if v.is_null() || unresolved.iter().any(|u| u == key) {
46 continue;
47 }
48 if v.as_str().is_some_and(crate::options::is_template) {
50 continue;
51 }
52 if let Some(e) = check_value(f, v) {
53 out.errors.push(format!("`{key}` {e}"));
54 }
55 }
56 for f in fields.iter().filter(|f| f.required && f.default.is_none()) {
57 let set =
58 connection.get(&f.name).is_some_and(|v| !v.is_null()) || unresolved.iter().any(|u| u == &f.name);
59 if !set {
60 out.errors.push(format!("`{}` is required", f.name));
61 }
62 }
63 out
64}
65
66fn check_value(f: &ConnectionField, v: &Value) -> Option<String> {
67 let ok = match f.kind {
68 None => true,
69 Some(FieldKind::String) => v.is_string() || v.is_number() || v.is_boolean(),
70 Some(FieldKind::Integer) => {
71 v.is_i64() || v.is_u64() || v.as_str().is_some_and(|s| s.trim().parse::<i64>().is_ok())
72 }
73 Some(FieldKind::Boolean) => v.is_boolean(),
74 Some(FieldKind::Duration) => crate::delivery::parse_duration(v).is_ok(),
75 Some(FieldKind::Map) => v.is_object(),
76 Some(FieldKind::Path) => match v.as_str() {
77 Some(p) => {
78 let path = expand_home(p);
79 if !path.exists() {
80 return Some(format!("names a file that doesn't exist ({})", path.display()));
81 }
82 true
83 }
84 None => false,
85 },
86 };
87 if !ok {
88 return Some(match f.kind {
89 Some(FieldKind::Integer) => "must be a whole number".into(),
90 Some(FieldKind::Boolean) => "must be true or false".into(),
91 Some(FieldKind::Duration) => "must be a duration such as `30s` or `2m`, or seconds".into(),
92 Some(FieldKind::Map) => "must be a block of settings".into(),
93 Some(FieldKind::Path) => "must be a file path".into(),
94 _ => "must be a string".into(),
95 });
96 }
97 if !f.choices.is_empty() {
98 let s = match v {
99 Value::String(s) => s.clone(),
100 other => other.to_string(),
101 };
102 if !f.choices.contains(&s) {
103 let shown: Vec<String> = f.choices.iter().map(|c| format!("`{c}`")).collect();
104 return Some(format!("must be one of {}", shown.join(", ")));
105 }
106 }
107 None
108}
109
110fn expand_home(p: &str) -> std::path::PathBuf {
112 match p.strip_prefix("~/").or_else(|| p.strip_prefix("~\\")) {
113 Some(rest) => std::env::home_dir().unwrap_or_default().join(rest),
114 None => std::path::PathBuf::from(p),
115 }
116}
117
118pub fn closest<'a>(key: &str, names: impl Iterator<Item = &'a str>) -> Option<&'a str> {
120 names
121 .map(|n| (distance(key, n), n))
122 .filter(|(d, n)| *d <= 2.max(n.len() / 4))
123 .min_by_key(|(d, _)| *d)
124 .map(|(_, n)| n)
125}
126
127fn distance(a: &str, b: &str) -> usize {
129 let b: Vec<char> = b.chars().collect();
130 let mut row: Vec<usize> = (0..=b.len()).collect();
131 for (i, ca) in a.chars().enumerate() {
132 let mut prev = row[0];
133 row[0] = i + 1;
134 for (j, cb) in b.iter().enumerate() {
135 let cur = row[j + 1];
136 row[j + 1] = (prev + usize::from(ca != *cb))
137 .min(row[j] + 1)
138 .min(row[j + 1] + 1);
139 prev = cur;
140 }
141 }
142 row[b.len()]
143}
144
145pub fn redact(
148 messages: Vec<String>,
149 fields: &[ConnectionField],
150 connection: &Map<String, Value>,
151) -> Vec<String> {
152 let secrets: Vec<&str> = fields
153 .iter()
154 .filter(|f| f.secret)
155 .filter_map(|f| connection.get(&f.name).and_then(Value::as_str))
156 .filter(|s| s.len() >= 3)
157 .collect();
158 messages
159 .into_iter()
160 .map(|m| secrets.iter().fold(m, |m, s| m.replace(s, "*****")))
161 .collect()
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use serde_json::json;
168
169 fn fields() -> Vec<ConnectionField> {
170 vec![
171 ConnectionField::new("host", "").required(),
172 ConnectionField::new("port", "")
173 .kind(FieldKind::Integer)
174 .default(5432),
175 ConnectionField::new("password", "").secret(),
176 ConnectionField::new("sslmode", "").choices(&["disable", "require"]),
177 ConnectionField::new("key_path", "").kind(FieldKind::Path),
178 ConnectionField::new("timeout", "").kind(FieldKind::Duration),
179 ConnectionField::new("passive", "").kind(FieldKind::Boolean),
180 ]
181 }
182
183 fn run(c: Value, unresolved: &[&str]) -> Checked {
184 let u: Vec<String> = unresolved.iter().map(|s| s.to_string()).collect();
185 check(Kind::Source, "pg", &fields(), c.as_object().unwrap(), &u)
186 }
187
188 #[test]
189 fn required_kind_choices_and_unknown_keys() {
190 let c = run(
191 json!({"port": "abc", "sslmode": "verify", "timeout": "soon", "passive": "yes", "hots": "x"}),
192 &[],
193 );
194 assert_eq!(
195 c.errors,
196 [
197 "`port` must be a whole number",
198 "`sslmode` must be one of `disable`, `require`",
199 "`timeout` must be a duration such as `30s` or `2m`, or seconds",
200 "`passive` must be true or false",
201 "`host` is required",
202 ]
203 );
204 assert_eq!(
205 c.warnings,
206 ["unknown key `hots` for source `pg`, ignored; did you mean `host`?"]
207 );
208 let ok = run(
209 json!({"host": "h", "port": "5432", "timeout": "30s", "passive": true}),
210 &[],
211 );
212 assert_eq!(ok, Checked::default());
213 }
214
215 #[test]
216 fn a_path_must_exist_and_unresolved_values_count_as_set() {
217 let c = run(json!({"host": "h", "key_path": "/no/such/key"}), &[]);
218 assert!(
219 c.errors[0].starts_with("`key_path` names a file that doesn't exist"),
220 "{c:?}"
221 );
222 let c = run(json!({"host": null, "port": null}), &["host", "port"]);
223 assert_eq!(c, Checked::default());
224 }
225
226 #[test]
227 fn secrets_are_redacted() {
228 let c = json!({"password": "hunter22"});
229 let m = redact(
230 vec!["bad password hunter22".into()],
231 &fields(),
232 c.as_object().unwrap(),
233 );
234 assert_eq!(m, ["bad password *****"]);
235 }
236}