Skip to main content

osp_cli/
ports.rs

1//! Small service-layer ports and helpers.
2//!
3//! This module exists to define the narrow interfaces the embeddable service
4//! layer needs from the outside world.
5//!
6//! Contract:
7//!
8//! - ports should stay small and easy to mock
9//! - transport- or host-specific concerns belong in adapters, not in these
10//!   traits
11
12use crate::core::row::Row;
13use anyhow::{Result, anyhow};
14use serde_json::Value;
15
16/// Minimal LDAP lookup port used by the service layer.
17pub trait LdapDirectory {
18    /// Looks up one or more user rows.
19    fn user(
20        &self,
21        uid: &str,
22        filter: Option<&str>,
23        attributes: Option<&[String]>,
24    ) -> Result<Vec<Row>>;
25
26    /// Looks up one or more netgroup rows.
27    fn netgroup(
28        &self,
29        name: &str,
30        filter: Option<&str>,
31        attributes: Option<&[String]>,
32    ) -> Result<Vec<Row>>;
33}
34
35/// Parses the lightweight comma-separated attribute override syntax.
36///
37/// `None` means the caller did not request projection, while empty or
38/// whitespace-only lists are rejected so the service layer never has to guess.
39///
40/// # Examples
41///
42/// ```
43/// use osp_cli::ports::parse_attributes;
44///
45/// assert_eq!(
46///     parse_attributes(Some("uid, cn ,mail")).unwrap(),
47///     Some(vec!["uid".to_string(), "cn".to_string(), "mail".to_string()])
48/// );
49/// assert_eq!(parse_attributes(None).unwrap(), None);
50/// ```
51pub 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
67/// Applies the lightweight LDAP filter/projection semantics used by the demo
68/// service layer.
69///
70/// Filtering happens first, followed by attribute projection when an explicit
71/// attribute list is provided.
72///
73/// # Examples
74///
75/// ```
76/// use osp_cli::ports::apply_filter_and_projection;
77/// use osp_cli::row;
78///
79/// let rows = vec![
80///     row! { "uid" => "alice", "mail" => "alice@example.com" },
81///     row! { "uid" => "bob", "mail" => "bob@example.com" },
82/// ];
83/// let attrs = vec!["mail".to_string()];
84///
85/// let projected = apply_filter_and_projection(rows, Some("uid=alice"), Some(&attrs));
86///
87/// assert_eq!(projected.len(), 1);
88/// assert_eq!(projected[0].get("mail").unwrap(), "alice@example.com");
89/// assert!(!projected[0].contains_key("uid"));
90/// ```
91pub 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}