Skip to main content

dinio_sdk/ops/
sinkhole.rs

1//! Client identities and sinkhole rules addressed the way people write them: the type
2//! follows from the value where that is unambiguous, and rules are found by pattern
3//! instead of uuid.
4
5use std::net::IpAddr;
6
7use uuid::Uuid;
8
9use crate::api::ClientIdentityFilter;
10use crate::client::Client;
11use crate::error::{Error, ErrorCode, Result};
12use crate::models::{
13    ClientIdentity, ClientIdentityType, SinkholeList, SinkholeRole, SinkholeRoleType,
14};
15
16/// Which rules of a list [`select_sinkhole_roles`] picks.
17#[derive(Debug, Clone)]
18pub enum SinkholeRoleSelector {
19    /// Every rule of the list.
20    All,
21    /// The rule of each pattern, compared the way Core stores patterns; every pattern must
22    /// exist.
23    Patterns(Vec<String>),
24    /// One rule by its uuid.
25    Id(Uuid),
26}
27
28/// The client identity type that `identify` implies, if it implies one.
29///
30/// An IP address can only be [`ClientIdentityType::Ip`]: Relay files anything that parses
31/// as an IP under IPs. Anything else could be a DoT hostname or a DoH path, so the caller
32/// has to say which.
33///
34/// # Examples
35///
36/// ```
37/// use dinio_sdk::models::ClientIdentityType;
38/// use dinio_sdk::ops::infer_client_identity_type;
39///
40/// assert_eq!(infer_client_identity_type("203.0.113.10"), Some(ClientIdentityType::Ip));
41/// assert_eq!(infer_client_identity_type("2001:db8::1"), Some(ClientIdentityType::Ip));
42/// assert_eq!(infer_client_identity_type("office.example.com"), None);
43/// ```
44pub fn infer_client_identity_type(identify: &str) -> Option<ClientIdentityType> {
45    identify
46        .trim()
47        .parse::<IpAddr>()
48        .ok()
49        .map(|_| ClientIdentityType::Ip)
50}
51
52/// The rule type a pattern implies: [`Wildcard`](SinkholeRoleType::Wildcard) for `*.` +
53/// name, [`Exact`](SinkholeRoleType::Exact) otherwise. A regex is never inferred; it has to
54/// be asked for.
55///
56/// # Examples
57///
58/// ```
59/// use dinio_sdk::models::SinkholeRoleType;
60/// use dinio_sdk::ops::infer_sinkhole_role_type;
61///
62/// assert_eq!(infer_sinkhole_role_type("*.ads.example"), SinkholeRoleType::Wildcard);
63/// assert_eq!(infer_sinkhole_role_type("ads.example.com"), SinkholeRoleType::Exact);
64/// ```
65pub fn infer_sinkhole_role_type(pattern: &str) -> SinkholeRoleType {
66    if pattern.trim().starts_with("*.") {
67        SinkholeRoleType::Wildcard
68    } else {
69        SinkholeRoleType::Exact
70    }
71}
72
73/// Picks rules of `list` without changing anything. Each rule is returned once, even if
74/// its pattern is given more than once.
75///
76/// A pattern matches a regex rule only if it is exactly the same text, and a name rule if
77/// it is the same name (case and a trailing dot do not matter).
78///
79/// # Errors
80///
81/// * [`Error::InvalidInput`] if `list` was read without its rules, or a pattern matches
82///   more than one rule (e.g. the same text as `Exact` and as `Regex`); the message lists
83///   their uuids.
84/// * [`Error::NotFound`] if a pattern or the uuid matches no rule. Nothing is returned
85///   then, so a typo cannot lead to removing only part of the selection.
86///
87/// # Examples
88///
89/// ```no_run
90/// use dinio_sdk::ops::{self, SinkholeRoleSelector};
91///
92/// # async fn example(client: &dinio_sdk::Client, list_id: dinio_sdk::Uuid) -> dinio_sdk::Result<()> {
93/// let list = client.sinkhole_lists().get(list_id, true).await?;
94/// let selector = SinkholeRoleSelector::Patterns(vec!["Ads.Example.com.".into()]);
95/// for role in ops::select_sinkhole_roles(&list, &selector)? {
96///     client.sinkhole_roles().delete(role.uuid).await?;
97/// }
98/// # Ok(())
99/// # }
100/// ```
101pub fn select_sinkhole_roles(
102    list: &SinkholeList,
103    selector: &SinkholeRoleSelector,
104) -> Result<Vec<SinkholeRole>> {
105    let roles = list.roles.as_deref().ok_or_else(|| {
106        Error::InvalidInput(format!(
107            "sinkhole list {} was read without its rules; read it with with_roles = true",
108            list.uuid
109        ))
110    })?;
111    match selector {
112        SinkholeRoleSelector::All => Ok(roles.to_vec()),
113        SinkholeRoleSelector::Id(id) => roles
114            .iter()
115            .find(|r| r.uuid == *id)
116            .map(|r| vec![r.clone()])
117            .ok_or_else(|| {
118                Error::NotFound(format!("sinkhole list {} has no rule {id}", list.uuid))
119            }),
120        SinkholeRoleSelector::Patterns(patterns) => {
121            let mut selected: Vec<SinkholeRole> = Vec::with_capacity(patterns.len());
122            for pattern in patterns {
123                let matches: Vec<&SinkholeRole> = roles
124                    .iter()
125                    .filter(|r| pattern_matches(r, pattern))
126                    .collect();
127                let role = match matches.as_slice() {
128                    [one] => *one,
129                    [] => {
130                        return Err(Error::NotFound(format!(
131                            "sinkhole list {} has no rule {pattern:?}",
132                            list.uuid
133                        )));
134                    }
135                    many => {
136                        let ids: Vec<String> = many
137                            .iter()
138                            .map(|r| format!("{} {}", r.sinkhole_type, r.uuid))
139                            .collect();
140                        return Err(Error::InvalidInput(format!(
141                            "{pattern:?} matches several rules ({}); pick one by uuid",
142                            ids.join(", ")
143                        )));
144                    }
145                };
146                if !selected.iter().any(|s| s.uuid == role.uuid) {
147                    selected.push(role.clone());
148                }
149            }
150            Ok(selected)
151        }
152    }
153}
154
155/// The list of `lists` that `reference` names: its uuid, or its name (trimmed, case does
156/// not matter). `Ok(None)` if none matches.
157///
158/// # Errors
159///
160/// [`Error::InvalidInput`] if the name matches several lists (names are not unique); the
161/// message lists their uuids.
162///
163/// # Examples
164///
165/// ```
166/// use dinio_sdk::models::SinkholeList;
167/// use dinio_sdk::ops::match_sinkhole_list;
168///
169/// # fn list(n: u128, name: Option<&str>) -> SinkholeList {
170/// #     serde_json::from_value(serde_json::json!({
171/// #         "uuid": dinio_sdk::Uuid::from_u128(n), "name": name, "is_shared": false,
172/// #         "type": "Blacklist", "created_at": 0, "updated_at": 0
173/// #     })).unwrap()
174/// # }
175/// let lists = [list(1, Some("ads")), list(2, Some("Tracking")), list(3, Some("tracking"))];
176/// assert_eq!(match_sinkhole_list(&lists, "ADS").unwrap().unwrap().uuid.as_u128(), 1);
177/// assert!(match_sinkhole_list(&lists, "tracking").is_err());
178/// assert!(match_sinkhole_list(&lists, "other").unwrap().is_none());
179/// ```
180pub fn match_sinkhole_list<'a>(
181    lists: &'a [SinkholeList],
182    reference: &str,
183) -> Result<Option<&'a SinkholeList>> {
184    let reference = reference.trim();
185    if let Ok(uuid) = reference.parse::<Uuid>() {
186        return Ok(lists.iter().find(|l| l.uuid == uuid));
187    }
188    let wanted = reference.to_lowercase();
189    let matches: Vec<&SinkholeList> = lists
190        .iter()
191        .filter(|l| {
192            l.name
193                .as_deref()
194                .is_some_and(|n| n.trim().to_lowercase() == wanted)
195        })
196        .collect();
197    match matches.as_slice() {
198        [] => Ok(None),
199        [one] => Ok(Some(*one)),
200        many => {
201            let ids: Vec<String> = many.iter().map(|l| l.uuid.to_string()).collect();
202            Err(Error::InvalidInput(format!(
203                "several sinkhole lists are named {reference:?} ({}); pick one by uuid",
204                ids.join(", ")
205            )))
206        }
207    }
208}
209
210/// A sinkhole list by uuid or name: first among the caller's own lists, then, with
211/// `include_shared`, among lists other users share. A uuid that matches neither is read
212/// directly (an Admin reaches other users' lists that way). Rules are not included.
213///
214/// # Errors
215///
216/// * [`Error::NotFound`] if no list has that name.
217/// * [`Error::InvalidInput`] if the name is ambiguous (see [`match_sinkhole_list`]).
218/// * Any error of [`SinkholeLists::list`](crate::api::SinkholeLists::list) or
219///   [`SinkholeLists::get`](crate::api::SinkholeLists::get).
220///
221/// # Examples
222///
223/// ```no_run
224/// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
225/// let list = dinio_sdk::ops::find_sinkhole_list(client, "ads", false).await?;
226/// let with_rules = client.sinkhole_lists().get(list.uuid, true).await?;
227/// # Ok(())
228/// # }
229/// ```
230pub async fn find_sinkhole_list(
231    client: &Client,
232    reference: &str,
233    include_shared: bool,
234) -> Result<SinkholeList> {
235    let own = client.sinkhole_lists().list(false).await?;
236    if let Some(list) = match_sinkhole_list(&own, reference)? {
237        return Ok(list.clone());
238    }
239    if include_shared {
240        let shared = client.sinkhole_lists().list(true).await?;
241        if let Some(list) = match_sinkhole_list(&shared, reference)? {
242            return Ok(list.clone());
243        }
244    }
245    if let Ok(uuid) = reference.trim().parse::<Uuid>() {
246        return client.sinkhole_lists().get(uuid, false).await;
247    }
248    Err(Error::NotFound(format!(
249        "no sinkhole list named {:?}{}",
250        reference.trim(),
251        if include_shared {
252            " among your own and shared lists"
253        } else {
254            " among your lists"
255        }
256    )))
257}
258
259/// The identity of `identities` that `reference` names: its uuid, its `identify` value,
260/// or its name (trimmed, case does not matter), in that order. `Ok(None)` if none
261/// matches.
262///
263/// # Errors
264///
265/// [`Error::InvalidInput`] if the name matches several identities (names are not unique);
266/// the message lists their `identify` values.
267///
268/// # Examples
269///
270/// ```
271/// use dinio_sdk::models::ClientIdentity;
272/// use dinio_sdk::ops::match_client_identity;
273///
274/// # fn identity(n: u128, identify: &str, name: Option<&str>) -> ClientIdentity {
275/// #     serde_json::from_value(serde_json::json!({
276/// #         "uuid": dinio_sdk::Uuid::from_u128(n), "id_type": "Path", "identify": identify,
277/// #         "name": name, "user_id": dinio_sdk::Uuid::nil(), "enabled": true,
278/// #         "suspended": false, "owner_suspended": false, "created_at": 0, "updated_at": 0
279/// #     })).unwrap()
280/// # }
281/// let all = [
282///     identity(1, "k3x7q2mzr5tvb4wa", Some("Phone")),
283///     identity(2, "p2d6hn4cw7yzq3sl", Some("laptop")),
284///     identity(3, "a5b6c7d2e3f4g5h6", Some("laptop")),
285/// ];
286/// let phone = match_client_identity(&all, "phone").unwrap().unwrap();
287/// assert_eq!(phone.identify, "k3x7q2mzr5tvb4wa");
288/// assert!(match_client_identity(&all, "p2d6hn4cw7yzq3sl").unwrap().is_some());
289/// assert!(match_client_identity(&all, "laptop").is_err());
290/// assert!(match_client_identity(&all, "tablet").unwrap().is_none());
291/// ```
292pub fn match_client_identity<'a>(
293    identities: &'a [ClientIdentity],
294    reference: &str,
295) -> Result<Option<&'a ClientIdentity>> {
296    let reference = reference.trim();
297    if let Ok(uuid) = reference.parse::<Uuid>() {
298        return Ok(identities.iter().find(|c| c.uuid == uuid));
299    }
300    if let Some(found) = identities.iter().find(|c| c.identify == reference) {
301        return Ok(Some(found));
302    }
303    let wanted = reference.to_lowercase();
304    let matches: Vec<&ClientIdentity> = identities
305        .iter()
306        .filter(|c| {
307            c.name
308                .as_deref()
309                .is_some_and(|n| n.trim().to_lowercase() == wanted)
310        })
311        .collect();
312    match matches.as_slice() {
313        [] => Ok(None),
314        [one] => Ok(Some(*one)),
315        many => {
316            let values: Vec<&str> = many.iter().map(|c| c.identify.as_str()).collect();
317            Err(Error::InvalidInput(format!(
318                "several client identities are named {reference:?} ({}); pick one by its \
319                 identify value",
320                values.join(", ")
321            )))
322        }
323    }
324}
325
326/// A client identity by uuid, `identify` value or name, among the caller's own (see
327/// [`match_client_identity`]). A reference that matches none of them is read directly,
328/// so an Admin reaches other users' identities by uuid or `identify`.
329///
330/// # Errors
331///
332/// * [`Error::NotFound`] if nothing matches.
333/// * [`Error::InvalidInput`] if the name is ambiguous.
334/// * Any error of [`ClientIdentities::list`](crate::api::ClientIdentities::list) or
335///   [`ClientIdentities::get`](crate::api::ClientIdentities::get).
336///
337/// # Examples
338///
339/// ```no_run
340/// # async fn example(client: &dinio_sdk::Client) -> dinio_sdk::Result<()> {
341/// let phone = dinio_sdk::ops::find_client_identity(client, "phone").await?;
342/// println!("DoH path part: {}", phone.identify);
343/// # Ok(())
344/// # }
345/// ```
346pub async fn find_client_identity(client: &Client, reference: &str) -> Result<ClientIdentity> {
347    let identities = client.client_identities();
348    let own = identities.list(&ClientIdentityFilter::default()).await?;
349    if let Some(found) = match_client_identity(&own, reference)? {
350        return Ok(found.clone());
351    }
352    match identities.get(reference.trim()).await {
353        Err(Error::Api(err)) if err.code == ErrorCode::NotFound => Err(Error::NotFound(format!(
354            "no client identity with uuid, identify value or name {:?}",
355            reference.trim()
356        ))),
357        other => other,
358    }
359}
360
361fn pattern_matches(role: &SinkholeRole, pattern: &str) -> bool {
362    match role.kind() {
363        Some(SinkholeRoleType::Regex) => role.pattern == pattern,
364        _ => role.pattern == normalize_name(pattern),
365    }
366}
367
368/// Core's `normalize_name` (`../core/src/dns/name.ts`): trimmed, lowercase, without one
369/// trailing dot (the root `.` stays).
370fn normalize_name(name: &str) -> String {
371    let lower = name.trim().to_lowercase();
372    match lower.strip_suffix('.') {
373        Some(stripped) if !stripped.is_empty() => stripped.to_owned(),
374        _ => lower,
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381
382    fn role(n: u128, kind: SinkholeRoleType, pattern: &str) -> SinkholeRole {
383        SinkholeRole {
384            uuid: Uuid::from_u128(n),
385            list_id: Uuid::nil(),
386            sinkhole_type: kind.as_wire().into(),
387            pattern: pattern.into(),
388            created_at: 0,
389            updated_at: 0,
390        }
391    }
392
393    fn list(roles: Option<Vec<SinkholeRole>>) -> SinkholeList {
394        SinkholeList {
395            uuid: Uuid::nil(),
396            name: None,
397            owner_user_id: None,
398            is_shared: false,
399            list_type: "Blacklist".into(),
400            created_at: 0,
401            updated_at: 0,
402            roles,
403        }
404    }
405
406    fn patterns(p: &[&str]) -> SinkholeRoleSelector {
407        SinkholeRoleSelector::Patterns(p.iter().map(|s| s.to_string()).collect())
408    }
409
410    #[test]
411    fn names_match_like_core_stores_them() {
412        let l = list(Some(vec![
413            role(1, SinkholeRoleType::Exact, "ads.example.com"),
414            role(2, SinkholeRoleType::Wildcard, "*.track.example"),
415        ]));
416        let found =
417            select_sinkhole_roles(&l, &patterns(&["ADS.example.com.", "*.Track.example"])).unwrap();
418        assert_eq!(found.len(), 2);
419        let again =
420            select_sinkhole_roles(&l, &patterns(&["ads.example.com", "ads.example.com."])).unwrap();
421        assert_eq!(again.len(), 1);
422    }
423
424    #[test]
425    fn regex_matches_only_verbatim() {
426        let l = list(Some(vec![role(1, SinkholeRoleType::Regex, "^Ads\\.")]));
427        assert!(select_sinkhole_roles(&l, &patterns(&["^Ads\\."])).is_ok());
428        assert!(matches!(
429            select_sinkhole_roles(&l, &patterns(&["^ads\\."])),
430            Err(Error::NotFound(_))
431        ));
432    }
433
434    #[test]
435    fn ambiguous_pattern_is_refused() {
436        let l = list(Some(vec![
437            role(1, SinkholeRoleType::Exact, "ads.example.com"),
438            role(2, SinkholeRoleType::Regex, "ads.example.com"),
439        ]));
440        assert!(matches!(
441            select_sinkhole_roles(&l, &patterns(&["ads.example.com"])),
442            Err(Error::InvalidInput(_))
443        ));
444        let by_id = select_sinkhole_roles(&l, &SinkholeRoleSelector::Id(Uuid::from_u128(2)));
445        assert_eq!(by_id.unwrap()[0].pattern, "ads.example.com");
446    }
447
448    #[test]
449    fn missing_pattern_fails_whole_selection() {
450        let l = list(Some(vec![role(1, SinkholeRoleType::Exact, "a.example")]));
451        assert!(matches!(
452            select_sinkhole_roles(&l, &patterns(&["a.example", "b.example"])),
453            Err(Error::NotFound(_))
454        ));
455    }
456
457    #[test]
458    fn list_without_roles_loaded_is_rejected() {
459        assert!(matches!(
460            select_sinkhole_roles(&list(None), &SinkholeRoleSelector::All),
461            Err(Error::InvalidInput(_))
462        ));
463    }
464
465    #[test]
466    fn normalize() {
467        assert_eq!(normalize_name(" Ads.Example.COM. "), "ads.example.com");
468        assert_eq!(normalize_name("."), ".");
469    }
470}