eigen_types/
operator_metadata.rs1use alloy::transports::http::reqwest::{self, Url};
2use mime_sniffer::MimeTypeSniffer;
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use std::{path::PathBuf, sync::OnceLock};
6use thiserror::Error;
7
8#[derive(Deserialize, Serialize)]
11pub struct OperatorMetadata {
12 name: String,
14
15 description: String,
17
18 logo: String,
21
22 website: Option<String>,
24
25 twitter: Option<String>,
27}
28
29#[derive(Debug, Error, PartialEq, Eq, Clone, Copy)]
30pub enum OperatorMetadataError {
31 #[error("Name cannot be empty")]
32 NameEmpty,
33 #[error("Name has to be less than 500 characters long")]
34 NameTooLong,
35 #[error("Name contains invalid characters")]
36 NameInvalid,
37
38 #[error("Description cannot be empty")]
39 DescriptionEmpty,
40 #[error("Description has to be less than 500 characters long")]
41 DescriptionTooLong,
42 #[error("Description contains invalid characters")]
43 DescriptionInvalid,
44
45 #[error("Logo cannot be empty")]
46 LogoUrlEmpty,
47 #[error("Logo url is invalid")]
48 LogoUrlInvalid,
49 #[error("Failed to fetch logo")]
50 LogoFetchFailed,
51 #[error("Logo url has an invalid image extension")]
52 LogoUrlInvalidImageExtension,
53 #[error("Logo has an unsupported mime type")]
54 LogoUrlInvalidMimeType,
55
56 #[error("Website url is invalid")]
57 WebsiteUrlInvalid,
58 #[error("Website url is longer than 1024 characters")]
59 WebsiteUrlTooLong,
60 #[error("Website url points to local server")]
61 WebsiteUrlPointsToLocalServer,
62
63 #[error("Twitter url is invalid. It must be of the format https://twitter.com/<username> or https://x.com/<username>")]
64 TwitterUrlInvalid,
65 #[error("Twitter url is longer than 1024 characters")]
66 TwitterUrlTooLong,
67 #[error("Twitter url points to local server")]
68 TwitterUrlPointsToLocalServer,
69}
70
71impl OperatorMetadata {
72 pub async fn validate(&self) -> Result<(), OperatorMetadataError> {
73 use OperatorMetadataError::*;
75
76 if self.name.is_empty() {
78 return Err(NameEmpty);
79 }
80 if self.name.len() > 500 {
81 return Err(NameTooLong);
82 }
83 if !is_valid_text(&self.name) {
84 return Err(NameInvalid);
85 }
86
87 if self.description.is_empty() {
89 return Err(DescriptionEmpty);
90 }
91 if self.description.len() > 500 {
92 return Err(DescriptionTooLong);
93 }
94 if !is_valid_text(&self.description) {
95 return Err(DescriptionInvalid);
96 }
97
98 if self.logo.is_empty() {
101 return Err(LogoUrlEmpty);
102 }
103 let Ok(url) = Url::parse(&self.logo) else {
104 return Err(LogoUrlInvalid);
105 };
106 let path = PathBuf::from(url.path());
107 if path.extension().map(|ext| ext != "png").unwrap_or(true) {
108 return Err(LogoUrlInvalidImageExtension);
109 }
110 let response = reqwest::get(&self.logo).await.ok().ok_or(LogoFetchFailed)?;
112 let body = response.bytes().await.ok().ok_or(LogoFetchFailed)?;
113
114 if body.sniff_mime_type() != Some("image/png") {
115 return Err(LogoUrlInvalidMimeType);
116 }
117
118 if self.website.as_ref().is_some_and(|s| !s.is_empty()) {
121 let website = self.website.as_ref().unwrap();
122 if website.len() > 1024 {
123 return Err(WebsiteUrlTooLong);
124 }
125 let url = Url::parse(website).ok().ok_or(WebsiteUrlInvalid)?;
126
127 let host = url.host_str().ok_or(WebsiteUrlInvalid)?;
128 if url.scheme().is_empty() || host.is_empty() {
129 return Err(WebsiteUrlInvalid);
130 }
131 if host == "localhost" || host == "127.0.0.1" {
132 return Err(WebsiteUrlPointsToLocalServer);
133 }
134 if !is_website_url(website) {
135 return Err(WebsiteUrlInvalid);
136 }
137 }
138
139 if self.twitter.as_ref().is_some_and(|s| !s.is_empty()) {
142 let twitter = self.twitter.as_ref().unwrap();
143 if twitter.len() > 1024 {
144 return Err(TwitterUrlTooLong);
145 }
146 let url = Url::parse(twitter).ok().ok_or(TwitterUrlInvalid)?;
147
148 let host = url.host_str().ok_or(TwitterUrlInvalid)?;
149 if url.scheme().is_empty() || host.is_empty() {
150 return Err(TwitterUrlInvalid);
151 }
152 if host == "localhost" || host == "127.0.0.1" {
153 return Err(TwitterUrlPointsToLocalServer);
154 }
155 if !is_twitter_url(twitter) {
156 return Err(TwitterUrlInvalid);
157 }
158 }
159
160 Ok(())
161 }
162}
163
164fn is_valid_text(text: &str) -> bool {
165 static REGEX: OnceLock<Regex> = OnceLock::new();
166 let regex = REGEX.get_or_init(|| {
167 regex::Regex::new(r#"^[a-zA-Z0-9 +.,;:?!'’"“”\-_/()\[\]~&#$—%]+$"#).expect("regex is valid")
168 });
169 regex.is_match(text)
170}
171
172fn is_website_url(website_url: &str) -> bool {
173 static REGEX: OnceLock<Regex> = OnceLock::new();
174 let regex = REGEX.get_or_init(|| {
175 regex::Regex::new(r#"^(https?)://[^\s/$.?#].[^\s]*$"#).expect("regex is valid")
176 });
177 regex.is_match(website_url)
178}
179
180fn is_twitter_url(twitter_url: &str) -> bool {
181 static REGEX: OnceLock<Regex> = OnceLock::new();
182 let regex = REGEX.get_or_init(|| {
183 regex::Regex::new(r#"^(?:https?://)?(?:www\.)?(?:twitter\.com/\w+|x\.com/\w+)(?:/?|$)"#)
184 .expect("regex is valid")
185 });
186
187 regex.is_match(twitter_url)
188}
189
190#[cfg(test)]
191mod tests {
192 use crate::{
193 operator::OperatorMetadata,
194 operator_metadata::{is_valid_text, is_website_url, OperatorMetadataError},
195 };
196
197 fn get_default_metadata() -> OperatorMetadata {
198 OperatorMetadata {
199 name: "Ethereum Utopia".to_string(),
200 description: "Rust operator is good operator".to_string(),
201 logo: "https://goerli-operator-metadata.s3.amazonaws.com/eigenlayer.png".to_string(),
202 website: Some("https://test.com".to_string()),
203 twitter: Some("https://twitter.com/test".to_string()),
204 }
205 }
206
207 #[tokio::test]
208 async fn test_is_valid_text() {
209 assert!(is_valid_text("this is some text"));
210 assert!(!is_valid_text("<>"));
211 }
212
213 #[tokio::test]
214 async fn test_is_website_url() {
215 assert!(is_website_url("https://test.com"));
216 assert!(!is_website_url("nothing"));
217 }
218
219 #[tokio::test]
220 async fn test_is_twitter_url() {
221 assert!(is_website_url("https://twitter.com/test"));
222 assert!(is_website_url("https://x.com/test"));
223 assert!(!is_website_url("nothing"));
224 }
225
226 #[tokio::test]
229 async fn test_valid_metadata() {
230 let metadata = get_default_metadata();
231 metadata.validate().await.unwrap();
232 }
233
234 #[tokio::test]
235 async fn test_twitter_url_with_ending_slash() {
236 let mut metadata = get_default_metadata();
237 metadata.twitter = Some("https://twitter.com/test/".to_string());
238 metadata.validate().await.unwrap();
239 }
240
241 #[tokio::test]
242 async fn test_twitter_x_url() {
243 let mut metadata = get_default_metadata();
244 metadata.twitter = Some("https://x.com/test".to_string());
245 metadata.validate().await.unwrap();
246 }
247
248 #[tokio::test]
249 async fn test_empty_website_and_twitter() {
250 let mut metadata = get_default_metadata();
251 metadata.website = None;
252 metadata.twitter = None;
253 metadata.validate().await.unwrap();
254 }
255
256 #[tokio::test]
257 async fn test_invalid_no_name() {
258 let mut metadata = get_default_metadata();
259 metadata.name = "".to_string();
260 let err = metadata.validate().await.unwrap_err();
261 assert_eq!(err, OperatorMetadataError::NameEmpty);
262 }
263
264 #[tokio::test]
265 async fn test_invalid_name_too_long() {
266 let mut metadata = get_default_metadata();
267 metadata.name = "0".repeat(501);
268 let err = metadata.validate().await.unwrap_err();
269 assert_eq!(err, OperatorMetadataError::NameTooLong);
270 }
271
272 #[tokio::test]
273 async fn test_invalid_name_has_js_script() {
274 let mut metadata = get_default_metadata();
275 metadata.name = "<script> alert('test') </script>".to_string();
276 let err = metadata.validate().await.unwrap_err();
277 assert_eq!(err, OperatorMetadataError::NameInvalid);
278 }
279
280 #[tokio::test]
281 async fn test_invalid_no_description() {
282 let mut metadata = get_default_metadata();
283 metadata.description = "".to_string();
284 let err = metadata.validate().await.unwrap_err();
285 assert_eq!(err, OperatorMetadataError::DescriptionEmpty);
286 }
287
288 #[tokio::test]
289 async fn test_invalid_description_too_long() {
290 let mut metadata = get_default_metadata();
291 metadata.description = "0".repeat(501);
292 let err = metadata.validate().await.unwrap_err();
293 assert_eq!(err, OperatorMetadataError::DescriptionTooLong);
294 }
295
296 #[tokio::test]
297 async fn test_invalid_description_has_js_script() {
298 let mut metadata = get_default_metadata();
299 metadata.description = "<script> alert('test') </script>".to_string();
300 let err = metadata.validate().await.unwrap_err();
301 assert_eq!(err, OperatorMetadataError::DescriptionInvalid);
302 }
303
304 #[tokio::test]
305 async fn test_invalid_logo_url_empty() {
306 let mut metadata = get_default_metadata();
307 metadata.logo = "".to_string();
308 let err = metadata.validate().await.unwrap_err();
309 assert_eq!(err, OperatorMetadataError::LogoUrlEmpty);
310 }
311
312 #[tokio::test]
313 async fn test_invalid_logo_wrong_image_format() {
314 let mut metadata = get_default_metadata();
315 metadata.logo = "https://test.com/test.svg".to_string();
316 let err = metadata.validate().await.unwrap_err();
317 assert_eq!(err, OperatorMetadataError::LogoUrlInvalidImageExtension);
318 }
319
320 #[tokio::test]
321 async fn test_invalid_logo_invalid_mime_type() {
322 let mut metadata = get_default_metadata();
323 metadata.logo = "https://goerli-operator-metadata.s3.amazonaws.com/cat.png".to_string();
324 let err = metadata.validate().await.unwrap_err();
325 assert_eq!(err, OperatorMetadataError::LogoUrlInvalidMimeType);
326 }
327
328 #[tokio::test]
329 async fn test_invalid_website_url_1() {
330 let mut metadata = get_default_metadata();
331 metadata.website = Some("https".to_string());
332 let err = metadata.validate().await.unwrap_err();
333 assert_eq!(err, OperatorMetadataError::WebsiteUrlInvalid);
334 }
335
336 #[tokio::test]
337 async fn test_invalid_website_url_2() {
338 let mut metadata = get_default_metadata();
339 metadata.website = Some("https:/test.com".to_string());
340 let err = metadata.validate().await.unwrap_err();
341 assert_eq!(err, OperatorMetadataError::WebsiteUrlInvalid);
342 }
343
344 #[tokio::test]
345 async fn test_invalid_website_url_3() {
346 let mut metadata = get_default_metadata();
347 metadata.website = Some("ps://test.com".to_string());
348 let err = metadata.validate().await.unwrap_err();
349 assert_eq!(err, OperatorMetadataError::WebsiteUrlInvalid);
350 }
351
352 #[tokio::test]
353 async fn test_invalid_twitter_url_1() {
354 let mut metadata = get_default_metadata();
355 metadata.twitter = Some("http".to_string());
356 let err = metadata.validate().await.unwrap_err();
357 assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
358 }
359
360 #[tokio::test]
361 async fn test_invalid_twitter_url_2() {
362 let mut metadata = get_default_metadata();
363 metadata.twitter = Some("ht://twitter.com/test".to_string());
364 let err = metadata.validate().await.unwrap_err();
365 assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
366 }
367
368 #[tokio::test]
369 async fn test_invalid_twitter_url_3() {
370 let mut metadata = get_default_metadata();
371 metadata.twitter = Some("https:/twitt".to_string());
372 let err = metadata.validate().await.unwrap_err();
373 assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
374 }
375
376 #[tokio::test]
377 async fn test_invalid_twitter_url_4() {
378 let mut metadata = get_default_metadata();
379 metadata.twitter = Some("https://facebook.com/test".to_string());
380 let err = metadata.validate().await.unwrap_err();
381 assert_eq!(err, OperatorMetadataError::TwitterUrlInvalid);
382 }
383}