Skip to main content

dinio_sdk/models/
sinkhole.rs

1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::error::{Error, Result};
8
9/// A list of sinkhole rules (names to block or to allow). It applies to the client
10/// identities linked to it ([`SinkholeLink`]).
11///
12/// Relay allows every name by default. A name matching a `Blacklist` of the client is
13/// blocked; a name matching a `Whitelist` of the client is allowed even if a blacklist
14/// matches too (an exception, not "allow only these").
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SinkholeList {
17    /// List id.
18    pub uuid: Uuid,
19    /// Optional label, 1–100 characters. Not unique: two lists may share a name.
20    #[serde(default, skip_serializing_if = "Option::is_none")]
21    pub name: Option<String>,
22    /// Owning user. `None` for admin-managed lists (and lists created with a zone key
23    /// before Core stopped allowing that).
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub owner_user_id: Option<Uuid>,
26    /// Other users may link their client identities to it (but not read or change its
27    /// rules).
28    pub is_shared: bool,
29    /// `Blacklist` or `Whitelist` (kept as a string for forward compatibility); see
30    /// [`SinkholeList::kind`].
31    #[serde(rename = "type")]
32    pub list_type: String,
33    /// Unix seconds.
34    pub created_at: i64,
35    /// Unix seconds.
36    pub updated_at: i64,
37    /// The rules; only when read with
38    /// [`SinkholeLists::get`](crate::api::SinkholeLists::get) and `with_roles = true`.
39    #[serde(
40        rename = "sinkholeRoles",
41        default,
42        skip_serializing_if = "Option::is_none"
43    )]
44    pub roles: Option<Vec<SinkholeRole>>,
45}
46
47impl SinkholeList {
48    /// `list_type` in its typed form, if this SDK version knows it.
49    pub fn kind(&self) -> Option<SinkholeListType> {
50        self.list_type.parse().ok()
51    }
52}
53
54/// Kind of sinkhole list (`SinkholeList.type`, `../core/src/api/enum_labels.ts`).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub enum SinkholeListType {
57    /// A matching name is blocked.
58    Blacklist,
59    /// A matching name is allowed even if a blacklist of the same client matches.
60    Whitelist,
61}
62
63impl SinkholeListType {
64    /// The label Core uses (`Blacklist`, `Whitelist`).
65    pub fn as_wire(self) -> &'static str {
66        match self {
67            Self::Blacklist => "Blacklist",
68            Self::Whitelist => "Whitelist",
69        }
70    }
71}
72
73impl fmt::Display for SinkholeListType {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(self.as_wire())
76    }
77}
78
79impl FromStr for SinkholeListType {
80    type Err = Error;
81
82    /// Case-insensitive wire label (`blacklist`, `Whitelist`).
83    fn from_str(text: &str) -> Result<Self> {
84        match text.trim().to_ascii_lowercase().as_str() {
85            "blacklist" => Ok(Self::Blacklist),
86            "whitelist" => Ok(Self::Whitelist),
87            _ => Err(Error::InvalidInput(format!(
88                "unknown sinkhole list type {text:?} (Blacklist or Whitelist)"
89            ))),
90        }
91    }
92}
93
94/// `POST /sinkhole-lists` body. A list starts without rules; add them with
95/// [`SinkholeRoles::create`](crate::api::SinkholeRoles::create).
96#[derive(Debug, Clone, Serialize)]
97pub struct NewSinkholeList {
98    /// Blacklist or whitelist.
99    #[serde(rename = "type")]
100    pub list_type: SinkholeListType,
101    /// Optional label, 1–100 characters (Core trims it).
102    #[serde(skip_serializing_if = "Option::is_none")]
103    pub name: Option<String>,
104    /// Whether other users may link to it; Core uses `false` when `None`.
105    #[serde(skip_serializing_if = "Option::is_none")]
106    pub is_shared: Option<bool>,
107    /// The caller's own client identities to link the new list to, in the same
108    /// transaction (at most 100). Empty: no links.
109    #[serde(skip_serializing_if = "Vec::is_empty")]
110    pub link_to: Vec<Uuid>,
111}
112
113/// What [`SinkholeLists::create`](crate::api::SinkholeLists::create) returns: the list and
114/// the links made from [`NewSinkholeList::link_to`].
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CreatedSinkholeList {
117    /// The new list (without rules).
118    #[serde(flatten)]
119    pub list: SinkholeList,
120    /// One link per identity in `link_to`.
121    #[serde(default)]
122    pub links: Vec<SinkholeLink>,
123}
124
125/// `PATCH /sinkhole-lists/:uuid` body; `None` fields stay unchanged, at least one must be
126/// set.
127#[derive(Debug, Clone, Default, Serialize)]
128pub struct SinkholeListPatch {
129    /// Share or unshare. Unsharing keeps the links other users already have.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub is_shared: Option<bool>,
132    /// `Some(Some(name))` renames the list, `Some(None)` removes its name.
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub name: Option<Option<String>>,
135}
136
137impl SinkholeListPatch {
138    /// Whether the patch changes nothing.
139    pub fn is_empty(&self) -> bool {
140        self.is_shared.is_none() && self.name.is_none()
141    }
142}
143
144/// One rule of a sinkhole list (Core calls rules "roles").
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct SinkholeRole {
147    /// Rule id.
148    pub uuid: Uuid,
149    /// The list the rule belongs to.
150    pub list_id: Uuid,
151    /// `Exact`, `Wildcard` or `Regex` (kept as a string for forward compatibility); see
152    /// [`SinkholeRole::kind`].
153    pub sinkhole_type: String,
154    /// As stored: names in lowercase, without the trailing dot; a regex unchanged.
155    pub pattern: String,
156    /// Unix seconds.
157    pub created_at: i64,
158    /// Unix seconds.
159    pub updated_at: i64,
160}
161
162impl SinkholeRole {
163    /// `sinkhole_type` in its typed form, if this SDK version knows it.
164    pub fn kind(&self) -> Option<SinkholeRoleType> {
165        self.sinkhole_type.parse().ok()
166    }
167}
168
169/// How a rule's pattern matches a name (`SinkholeRole.sinkhole_type`).
170#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
171pub enum SinkholeRoleType {
172    /// One domain name, e.g. `ads.example.com`.
173    Exact,
174    /// `*.example.com`: every name strictly below `example.com`, not `example.com` itself.
175    Wildcard,
176    /// A regular expression over the name (without the trailing dot), evaluated by Relay's
177    /// Rust `regex` engine. Core only checks that it compiles.
178    Regex,
179}
180
181impl SinkholeRoleType {
182    /// The label Core uses (`Exact`, `Wildcard`, `Regex`).
183    pub fn as_wire(self) -> &'static str {
184        match self {
185            Self::Exact => "Exact",
186            Self::Wildcard => "Wildcard",
187            Self::Regex => "Regex",
188        }
189    }
190}
191
192impl fmt::Display for SinkholeRoleType {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        f.write_str(self.as_wire())
195    }
196}
197
198impl FromStr for SinkholeRoleType {
199    type Err = Error;
200
201    /// Case-insensitive wire label (`exact`, `Wildcard`, `REGEX`).
202    fn from_str(text: &str) -> Result<Self> {
203        match text.trim().to_ascii_lowercase().as_str() {
204            "exact" => Ok(Self::Exact),
205            "wildcard" => Ok(Self::Wildcard),
206            "regex" => Ok(Self::Regex),
207            _ => Err(Error::InvalidInput(format!(
208                "unknown sinkhole rule type {text:?} (Exact, Wildcard or Regex)"
209            ))),
210        }
211    }
212}
213
214/// `POST /sinkhole-roles` body.
215#[derive(Debug, Clone, Serialize)]
216pub struct NewSinkholeRole {
217    /// The list to add to.
218    pub list_id: Uuid,
219    /// How the pattern matches. See
220    /// [`ops::infer_sinkhole_role_type`](crate::ops::infer_sinkhole_role_type).
221    pub sinkhole_type: SinkholeRoleType,
222    /// 1–1024 characters; must fit `sinkhole_type` (a name for `Exact`, `*.` + a name for
223    /// `Wildcard`, a valid regex for `Regex`). Case and a trailing dot of a name do not
224    /// matter.
225    pub pattern: String,
226}
227
228/// `PATCH /sinkhole-roles/:uuid` body; `None` fields stay unchanged. The result must still
229/// fit together (see [`NewSinkholeRole::pattern`]). The list cannot change.
230#[derive(Debug, Clone, Default, Serialize)]
231pub struct SinkholeRolePatch {
232    /// New match type.
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub sinkhole_type: Option<SinkholeRoleType>,
235    /// New pattern, 1–1024 characters.
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub pattern: Option<String>,
238}
239
240impl SinkholeRolePatch {
241    /// Whether the patch changes nothing.
242    pub fn is_empty(&self) -> bool {
243        self.sinkhole_type.is_none() && self.pattern.is_none()
244    }
245}
246
247/// A sinkhole list applied to a client identity. Identified by the pair of ids (there is
248/// no link id). Cannot be changed: delete it and create another.
249#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct SinkholeLink {
251    /// The client identity (spelled as on the wire).
252    pub client_identify_id: Uuid,
253    /// The sinkhole list.
254    pub sinkholelist_id: Uuid,
255    /// Unix seconds.
256    pub created_at: i64,
257    /// Unix seconds.
258    pub updated_at: i64,
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    #[test]
266    fn list_with_roles_deserializes() {
267        let list: SinkholeList = serde_json::from_value(serde_json::json!({
268            "uuid": "00000000-0000-0000-0000-000000000001",
269            "name": "ads", "owner_user_id": null, "is_shared": true, "type": "Blacklist",
270            "created_at": 1, "updated_at": 1,
271            "sinkholeRoles": [{
272                "uuid": "00000000-0000-0000-0000-000000000002",
273                "list_id": "00000000-0000-0000-0000-000000000001",
274                "sinkhole_type": "Wildcard", "pattern": "*.ads.example",
275                "created_at": 1, "updated_at": 1
276            }]
277        }))
278        .unwrap();
279        assert_eq!(list.kind(), Some(SinkholeListType::Blacklist));
280        assert_eq!(list.name.as_deref(), Some("ads"));
281        let roles = list.roles.unwrap();
282        assert_eq!(roles[0].kind(), Some(SinkholeRoleType::Wildcard));
283    }
284
285    #[test]
286    fn list_patch_clears_name_with_null() {
287        let patch = SinkholeListPatch {
288            name: Some(None),
289            ..Default::default()
290        };
291        assert_eq!(
292            serde_json::to_value(&patch).unwrap(),
293            serde_json::json!({ "name": null })
294        );
295    }
296
297    #[test]
298    fn created_list_with_links_deserializes() {
299        let created: CreatedSinkholeList = serde_json::from_value(serde_json::json!({
300            "uuid": "00000000-0000-0000-0000-000000000001",
301            "is_shared": false, "type": "Whitelist", "created_at": 1, "updated_at": 1,
302            "links": [{
303                "client_identify_id": "00000000-0000-0000-0000-000000000002",
304                "sinkholelist_id": "00000000-0000-0000-0000-000000000001",
305                "created_at": 1, "updated_at": 1
306            }]
307        }))
308        .unwrap();
309        assert_eq!(created.list.name, None);
310        assert_eq!(created.links.len(), 1);
311    }
312
313    #[test]
314    fn new_role_wire_shape() {
315        let role = NewSinkholeRole {
316            list_id: Uuid::nil(),
317            sinkhole_type: SinkholeRoleType::Exact,
318            pattern: "ads.example.com".into(),
319        };
320        assert_eq!(
321            serde_json::to_value(&role).unwrap(),
322            serde_json::json!({
323                "list_id": "00000000-0000-0000-0000-000000000000",
324                "sinkhole_type": "Exact",
325                "pattern": "ads.example.com",
326            })
327        );
328    }
329}