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}