1use std::fmt;
2use std::str::FromStr;
3
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7use crate::error::{Error, Result};
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SinkholeList {
17 pub uuid: Uuid,
19 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub name: Option<String>,
22 #[serde(default, skip_serializing_if = "Option::is_none")]
25 pub owner_user_id: Option<Uuid>,
26 pub is_shared: bool,
29 #[serde(rename = "type")]
32 pub list_type: String,
33 pub created_at: i64,
35 pub updated_at: i64,
37 #[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 pub fn kind(&self) -> Option<SinkholeListType> {
50 self.list_type.parse().ok()
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56pub enum SinkholeListType {
57 Blacklist,
59 Whitelist,
61}
62
63impl SinkholeListType {
64 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 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#[derive(Debug, Clone, Serialize)]
97pub struct NewSinkholeList {
98 #[serde(rename = "type")]
100 pub list_type: SinkholeListType,
101 #[serde(skip_serializing_if = "Option::is_none")]
103 pub name: Option<String>,
104 #[serde(skip_serializing_if = "Option::is_none")]
106 pub is_shared: Option<bool>,
107 #[serde(skip_serializing_if = "Vec::is_empty")]
110 pub link_to: Vec<Uuid>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct CreatedSinkholeList {
117 #[serde(flatten)]
119 pub list: SinkholeList,
120 #[serde(default)]
122 pub links: Vec<SinkholeLink>,
123}
124
125#[derive(Debug, Clone, Default, Serialize)]
128pub struct SinkholeListPatch {
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub is_shared: Option<bool>,
132 #[serde(skip_serializing_if = "Option::is_none")]
134 pub name: Option<Option<String>>,
135}
136
137impl SinkholeListPatch {
138 pub fn is_empty(&self) -> bool {
140 self.is_shared.is_none() && self.name.is_none()
141 }
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct SinkholeRole {
147 pub uuid: Uuid,
149 pub list_id: Uuid,
151 pub sinkhole_type: String,
154 pub pattern: String,
156 pub created_at: i64,
158 pub updated_at: i64,
160}
161
162impl SinkholeRole {
163 pub fn kind(&self) -> Option<SinkholeRoleType> {
165 self.sinkhole_type.parse().ok()
166 }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
171pub enum SinkholeRoleType {
172 Exact,
174 Wildcard,
176 Regex,
179}
180
181impl SinkholeRoleType {
182 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 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#[derive(Debug, Clone, Serialize)]
216pub struct NewSinkholeRole {
217 pub list_id: Uuid,
219 pub sinkhole_type: SinkholeRoleType,
222 pub pattern: String,
226}
227
228#[derive(Debug, Clone, Default, Serialize)]
231pub struct SinkholeRolePatch {
232 #[serde(skip_serializing_if = "Option::is_none")]
234 pub sinkhole_type: Option<SinkholeRoleType>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 pub pattern: Option<String>,
238}
239
240impl SinkholeRolePatch {
241 pub fn is_empty(&self) -> bool {
243 self.sinkhole_type.is_none() && self.pattern.is_none()
244 }
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct SinkholeLink {
251 pub client_identify_id: Uuid,
253 pub sinkholelist_id: Uuid,
255 pub created_at: i64,
257 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}