Skip to main content

osp_cli/
ports.rs

1use crate::core::row::Row;
2use anyhow::{Result, anyhow};
3use serde_json::Value;
4
5pub trait LdapDirectory {
6    fn user(
7        &self,
8        uid: &str,
9        filter: Option<&str>,
10        attributes: Option<&[String]>,
11    ) -> Result<Vec<Row>>;
12
13    fn netgroup(
14        &self,
15        name: &str,
16        filter: Option<&str>,
17        attributes: Option<&[String]>,
18    ) -> Result<Vec<Row>>;
19}
20
21pub fn parse_attributes(raw: Option<&str>) -> Result<Option<Vec<String>>> {
22    let Some(raw) = raw else {
23        return Ok(None);
24    };
25    let attrs = raw
26        .split(',')
27        .map(str::trim)
28        .filter(|s| !s.is_empty())
29        .map(ToOwned::to_owned)
30        .collect::<Vec<String>>();
31    if attrs.is_empty() {
32        return Err(anyhow!("--attributes must include at least one key"));
33    }
34    Ok(Some(attrs))
35}
36
37pub fn apply_filter_and_projection(
38    rows: Vec<Row>,
39    filter: Option<&str>,
40    attributes: Option<&[String]>,
41) -> Vec<Row> {
42    let filtered = match filter {
43        Some(spec) => rows
44            .into_iter()
45            .filter(|row| row_matches_filter(row, spec))
46            .collect::<Vec<Row>>(),
47        None => rows,
48    };
49
50    match attributes {
51        Some(attrs) => filtered
52            .into_iter()
53            .map(|row| project_attributes(&row, attrs))
54            .collect::<Vec<Row>>(),
55        None => filtered,
56    }
57}
58
59fn row_matches_filter(row: &Row, spec: &str) -> bool {
60    let spec = spec.trim();
61    if spec.is_empty() {
62        return true;
63    }
64
65    if let Some((key, value)) = spec.split_once('=') {
66        return field_equals(row, key.trim(), value.trim());
67    }
68
69    let query = spec.to_ascii_lowercase();
70    let serial = Value::Object(row.clone()).to_string().to_ascii_lowercase();
71    serial.contains(&query)
72}
73
74fn field_equals(row: &Row, key: &str, expected: &str) -> bool {
75    let Some(value) = row.get(key) else {
76        return false;
77    };
78    value_matches(value, expected)
79}
80
81fn value_matches(value: &Value, expected: &str) -> bool {
82    match value {
83        Value::Array(items) => items.iter().any(|item| value_matches(item, expected)),
84        Value::String(s) => string_matches(s, expected),
85        other => string_matches(&other.to_string(), expected),
86    }
87}
88
89fn string_matches(actual: &str, expected: &str) -> bool {
90    if expected.contains('*') {
91        return wildcard_match(expected, actual);
92    }
93    actual.eq_ignore_ascii_case(expected)
94}
95
96fn project_attributes(row: &Row, attrs: &[String]) -> Row {
97    let mut selected = Row::new();
98    for key in attrs {
99        if let Some(value) = row.get(key) {
100            selected.insert(key.clone(), value.clone());
101        }
102    }
103    selected
104}
105
106fn wildcard_match(pattern: &str, value: &str) -> bool {
107    let escaped = regex::escape(pattern).replace("\\*", ".*");
108    let re = regex::Regex::new(&format!("^{escaped}$"));
109    match re {
110        Ok(re) => re.is_match(value),
111        Err(_) => false,
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use serde_json::json;
118
119    use super::{Row, apply_filter_and_projection, parse_attributes};
120
121    fn user_row() -> Row {
122        json!({
123            "uid": "oistes",
124            "cn": "Øistein Søvik",
125            "netgroups": ["ucore", "usit"]
126        })
127        .as_object()
128        .cloned()
129        .expect("fixture must be object")
130    }
131
132    #[test]
133    fn filter_supports_key_value_match() {
134        let rows = vec![user_row()];
135        let result = apply_filter_and_projection(rows, Some("uid=oistes"), None);
136        assert_eq!(result.len(), 1);
137    }
138
139    #[test]
140    fn projection_keeps_selected_keys_only() {
141        let rows = vec![user_row()];
142        let attrs = vec!["uid".to_string()];
143        let result = apply_filter_and_projection(rows, None, Some(&attrs));
144        assert_eq!(result[0].len(), 1);
145        assert!(result[0].contains_key("uid"));
146    }
147
148    #[test]
149    fn parse_attributes_trims_and_rejects_empty_lists() {
150        let attrs = parse_attributes(Some(" uid , cn ,, mail "))
151            .expect("attribute list should parse")
152            .expect("attribute list should be present");
153        assert_eq!(attrs, vec!["uid", "cn", "mail"]);
154
155        assert!(
156            parse_attributes(None)
157                .expect("missing list is allowed")
158                .is_none()
159        );
160
161        let err = parse_attributes(Some(" , ,, ")).expect_err("empty attribute list should fail");
162        assert!(
163            err.to_string()
164                .contains("--attributes must include at least one key")
165        );
166    }
167
168    #[test]
169    fn filter_supports_case_insensitive_substring_and_wildcard_matching() {
170        let rows = vec![user_row()];
171
172        let substring = apply_filter_and_projection(rows.clone(), Some("søvik"), None);
173        assert_eq!(substring.len(), 1);
174
175        let wildcard = apply_filter_and_projection(rows, Some("uid=*tes"), None);
176        assert_eq!(wildcard.len(), 1);
177    }
178
179    #[test]
180    fn filter_matches_arrays_and_missing_fields_fail_cleanly() {
181        let rows = vec![user_row()];
182
183        let array_match = apply_filter_and_projection(rows.clone(), Some("netgroups=usit"), None);
184        assert_eq!(array_match.len(), 1);
185
186        let missing = apply_filter_and_projection(rows, Some("mail=oistes@example.org"), None);
187        assert!(missing.is_empty());
188    }
189
190    #[test]
191    fn projection_runs_after_filtering() {
192        let rows = vec![user_row()];
193        let attrs = vec!["uid".to_string()];
194        let result = apply_filter_and_projection(rows, Some("uid=oistes"), Some(&attrs));
195        assert_eq!(result.len(), 1);
196        assert_eq!(result[0].len(), 1);
197        assert_eq!(
198            result[0].get("uid").and_then(|value| value.as_str()),
199            Some("oistes")
200        );
201    }
202}