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