1use crate::core::row::Row;
13use anyhow::{Result, anyhow};
14use serde_json::Value;
15
16pub trait LdapDirectory {
18 fn user(
20 &self,
21 uid: &str,
22 filter: Option<&str>,
23 attributes: Option<&[String]>,
24 ) -> Result<Vec<Row>>;
25
26 fn netgroup(
28 &self,
29 name: &str,
30 filter: Option<&str>,
31 attributes: Option<&[String]>,
32 ) -> Result<Vec<Row>>;
33}
34
35pub fn parse_attributes(raw: Option<&str>) -> Result<Option<Vec<String>>> {
52 let Some(raw) = raw else {
53 return Ok(None);
54 };
55 let attrs = raw
56 .split(',')
57 .map(str::trim)
58 .filter(|s| !s.is_empty())
59 .map(ToOwned::to_owned)
60 .collect::<Vec<String>>();
61 if attrs.is_empty() {
62 return Err(anyhow!("--attributes must include at least one key"));
63 }
64 Ok(Some(attrs))
65}
66
67pub fn apply_filter_and_projection(
92 rows: Vec<Row>,
93 filter: Option<&str>,
94 attributes: Option<&[String]>,
95) -> Vec<Row> {
96 let filtered = match filter {
97 Some(spec) => rows
98 .into_iter()
99 .filter(|row| row_matches_filter(row, spec))
100 .collect::<Vec<Row>>(),
101 None => rows,
102 };
103
104 match attributes {
105 Some(attrs) => filtered
106 .into_iter()
107 .map(|row| project_attributes(&row, attrs))
108 .collect::<Vec<Row>>(),
109 None => filtered,
110 }
111}
112
113fn row_matches_filter(row: &Row, spec: &str) -> bool {
114 let spec = spec.trim();
115 if spec.is_empty() {
116 return true;
117 }
118
119 if let Some((key, value)) = spec.split_once('=') {
120 return field_equals(row, key.trim(), value.trim());
121 }
122
123 let query = spec.to_ascii_lowercase();
124 let serial = Value::Object(row.clone()).to_string().to_ascii_lowercase();
125 serial.contains(&query)
126}
127
128fn field_equals(row: &Row, key: &str, expected: &str) -> bool {
129 let Some(value) = row.get(key) else {
130 return false;
131 };
132 value_matches(value, expected)
133}
134
135fn value_matches(value: &Value, expected: &str) -> bool {
136 match value {
137 Value::Array(items) => items.iter().any(|item| value_matches(item, expected)),
138 Value::String(s) => string_matches(s, expected),
139 other => string_matches(&other.to_string(), expected),
140 }
141}
142
143fn string_matches(actual: &str, expected: &str) -> bool {
144 if expected.contains('*') {
145 return wildcard_match(expected, actual);
146 }
147 actual.eq_ignore_ascii_case(expected)
148}
149
150fn project_attributes(row: &Row, attrs: &[String]) -> Row {
151 let mut selected = Row::new();
152 for key in attrs {
153 if let Some(value) = row.get(key) {
154 selected.insert(key.clone(), value.clone());
155 }
156 }
157 selected
158}
159
160fn wildcard_match(pattern: &str, value: &str) -> bool {
161 let escaped = regex::escape(pattern).replace("\\*", ".*");
162 let re = regex::Regex::new(&format!("^{escaped}$"));
163 match re {
164 Ok(re) => re.is_match(value),
165 Err(_) => false,
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use serde_json::json;
172
173 use super::{Row, apply_filter_and_projection, parse_attributes};
174
175 fn user_row() -> Row {
176 json!({
177 "uid": "oistes",
178 "cn": "Øistein Søvik",
179 "netgroups": ["ucore", "usit"]
180 })
181 .as_object()
182 .cloned()
183 .expect("fixture must be object")
184 }
185
186 #[test]
187 fn filter_supports_key_value_match() {
188 let rows = vec![user_row()];
189 let result = apply_filter_and_projection(rows, Some("uid=oistes"), None);
190 assert_eq!(result.len(), 1);
191 }
192
193 #[test]
194 fn projection_keeps_selected_keys_only() {
195 let rows = vec![user_row()];
196 let attrs = vec!["uid".to_string()];
197 let result = apply_filter_and_projection(rows, None, Some(&attrs));
198 assert_eq!(result[0].len(), 1);
199 assert!(result[0].contains_key("uid"));
200 }
201
202 #[test]
203 fn parse_attributes_trims_and_rejects_empty_lists() {
204 let attrs = parse_attributes(Some(" uid , cn ,, mail "))
205 .expect("attribute list should parse")
206 .expect("attribute list should be present");
207 assert_eq!(attrs, vec!["uid", "cn", "mail"]);
208
209 assert!(
210 parse_attributes(None)
211 .expect("missing list is allowed")
212 .is_none()
213 );
214
215 let err = parse_attributes(Some(" , ,, ")).expect_err("empty attribute list should fail");
216 assert!(
217 err.to_string()
218 .contains("--attributes must include at least one key")
219 );
220 }
221
222 #[test]
223 fn filter_supports_case_insensitive_substring_and_wildcard_matching() {
224 let rows = vec![user_row()];
225
226 let substring = apply_filter_and_projection(rows.clone(), Some("søvik"), None);
227 assert_eq!(substring.len(), 1);
228
229 let wildcard = apply_filter_and_projection(rows, Some("uid=*tes"), None);
230 assert_eq!(wildcard.len(), 1);
231 }
232
233 #[test]
234 fn filter_matches_arrays_and_missing_fields_fail_cleanly() {
235 let rows = vec![user_row()];
236
237 let array_match = apply_filter_and_projection(rows.clone(), Some("netgroups=usit"), None);
238 assert_eq!(array_match.len(), 1);
239
240 let missing = apply_filter_and_projection(rows, Some("mail=oistes@example.org"), None);
241 assert!(missing.is_empty());
242 }
243
244 #[test]
245 fn projection_runs_after_filtering() {
246 let rows = vec![user_row()];
247 let attrs = vec!["uid".to_string()];
248 let result = apply_filter_and_projection(rows, Some("uid=oistes"), Some(&attrs));
249 assert_eq!(result.len(), 1);
250 assert_eq!(result[0].len(), 1);
251 assert_eq!(
252 result[0].get("uid").and_then(|value| value.as_str()),
253 Some("oistes")
254 );
255 }
256}