1use crate::addr::constants;
6use std::path::Path;
8use url::Url;
9
10use super::{Address, GitRepository, HttpResource, LocalPath};
11
12pub type ValidationResult = Result<(), Vec<ValidationError>>;
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct ValidationError {
18 pub field: String,
20 pub message: String,
22 pub code: String,
24}
25
26impl ValidationError {
27 pub fn new(field: &str, message: &str, code: &str) -> Self {
28 Self {
29 field: field.to_string(),
30 message: message.to_string(),
31 code: code.to_string(),
32 }
33 }
34}
35
36pub trait Validate {
38 fn validate(&self) -> ValidationResult;
40
41 fn is_accessible(&self) -> bool;
43}
44
45impl Validate for Address {
46 fn validate(&self) -> ValidationResult {
47 match self {
48 Address::Git(repo) => repo.validate(),
49 Address::Http(resource) => resource.validate(),
50 Address::Local(path) => path.validate(),
51 }
52 }
53
54 fn is_accessible(&self) -> bool {
55 match self {
56 Address::Git(repo) => repo.is_accessible(),
57 Address::Http(resource) => resource.is_accessible(),
58 Address::Local(path) => path.is_accessible(),
59 }
60 }
61}
62
63impl Validate for GitRepository {
64 fn validate(&self) -> ValidationResult {
65 let mut errors = Vec::new();
66
67 if self.repo().is_empty() {
69 errors.push(ValidationError::new(
70 "repo",
71 "仓库地址不能为空",
72 "EMPTY_REPO",
73 ));
74 } else if !is_valid_git_url(self.repo()) {
75 errors.push(ValidationError::new(
76 "repo",
77 "无效的Git仓库地址格式",
78 "INVALID_GIT_URL",
79 ));
80 }
81
82 if let Some(ssh_key) = &self.ssh_key()
84 && !Path::new(ssh_key).exists()
85 {
86 errors.push(ValidationError::new(
87 "ssh_key",
88 &format!("SSH密钥文件不存在: {ssh_key}",),
89 "SSH_KEY_NOT_FOUND",
90 ));
91 }
92
93 if self.token().is_some() && self.username().is_none() {
95 errors.push(ValidationError::new(
96 "username",
97 "使用Token认证时必须提供用户名",
98 "MISSING_USERNAME",
99 ));
100 }
101
102 let version_count = [
104 self.tag().as_ref(),
105 self.branch().as_ref(),
106 self.rev().as_ref(),
107 ]
108 .iter()
109 .filter(|x| x.is_some())
110 .count();
111
112 if version_count > 1 {
113 errors.push(ValidationError::new(
114 "version",
115 "不能同时指定tag、branch和rev中的多个",
116 "CONFLICTING_VERSIONS",
117 ));
118 }
119
120 if errors.is_empty() {
121 Ok(())
122 } else {
123 Err(errors)
124 }
125 }
126
127 fn is_accessible(&self) -> bool {
128 is_valid_git_url(self.repo())
131 }
132}
133
134impl Validate for HttpResource {
135 fn validate(&self) -> ValidationResult {
136 let mut errors = Vec::new();
137
138 if self.url().is_empty() {
140 errors.push(ValidationError::new("url", "URL不能为空", "EMPTY_URL"));
141 } else if let Err(e) = Url::parse(self.url()) {
142 errors.push(ValidationError::new(
143 "url",
144 &format!("无效的URL格式: {e}"),
145 "INVALID_URL",
146 ));
147 }
148
149 if self.username().is_some() && self.password().is_none() {
151 errors.push(ValidationError::new(
152 "password",
153 "提供用户名时必须提供密码",
154 "MISSING_PASSWORD",
155 ));
156 }
157
158 if errors.is_empty() {
159 Ok(())
160 } else {
161 Err(errors)
162 }
163 }
164
165 fn is_accessible(&self) -> bool {
166 Url::parse(self.url()).is_ok()
168 }
169}
170
171impl Validate for LocalPath {
172 fn validate(&self) -> ValidationResult {
173 let mut errors = Vec::new();
174
175 let path_str = self.path();
177 if path_str.is_empty() {
178 errors.push(ValidationError::new(
179 "path",
180 "本地路径不能为空",
181 "EMPTY_PATH",
182 ));
183 } else {
184 let path = Path::new(path_str);
185
186 if path_str.contains("\\") && cfg!(not(target_os = "windows")) {
188 errors.push(ValidationError::new(
189 "path",
190 "在非Windows系统上使用了反斜杠路径分隔符",
191 "INVALID_PATH_SEPARATOR",
192 ));
193 }
194
195 if path.is_relative() && !path_str.starts_with("./") && !path_str.starts_with("../") {
197 errors.push(ValidationError::new(
198 "path",
199 "相对路径应以./或../开头",
200 "INVALID_RELATIVE_PATH",
201 ));
202 }
203 }
204
205 if errors.is_empty() {
206 Ok(())
207 } else {
208 Err(errors)
209 }
210 }
211
212 fn is_accessible(&self) -> bool {
213 Path::new(self.path()).exists()
214 }
215}
216
217fn is_valid_git_url(url: &str) -> bool {
219 if url.starts_with(constants::git::HTTPS_PREFIX) && url.ends_with(".git") {
221 return Url::parse(url).is_ok();
222 }
223
224 if url.starts_with(constants::git::SSH_PREFIX) && url.contains(':') && url.ends_with(".git") {
226 return true;
227 }
228
229 if url.starts_with(constants::git::GIT_PROTOCOL) && url.ends_with(".git") {
231 return Url::parse(url).is_ok();
232 }
233
234 url.contains("github.com") || url.contains("gitlab.com") || url.contains("gitea.com")
236}
237
238pub fn validate_addresses(addresses: &[Address]) -> ValidationResult {
240 let mut all_errors = Vec::new();
241
242 for (index, addr) in addresses.iter().enumerate() {
243 if let Err(errors) = addr.validate() {
244 for error in errors {
245 all_errors.push(ValidationError::new(
246 &format!("address[{}].{}", index, error.field),
247 &error.message,
248 &error.code,
249 ));
250 }
251 }
252 }
253
254 if all_errors.is_empty() {
255 Ok(())
256 } else {
257 Err(all_errors)
258 }
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 #[test]
266 fn test_git_repository_validation() {
267 let repo = GitRepository::from("https://github.com/user/repo.git");
268 assert!(repo.validate().is_ok());
269
270 let invalid_repo = GitRepository::from("");
271 assert!(invalid_repo.validate().is_err());
272
273 let ssh_repo = GitRepository::from("git@github.com:user/repo.git");
274 assert!(ssh_repo.validate().is_ok());
275 }
276
277 #[test]
278 fn test_http_resource_validation() {
279 let resource = HttpResource::from("https://example.com/file.zip");
280 assert!(resource.validate().is_ok());
281
282 let invalid_resource = HttpResource::from("invalid-url");
283 assert!(invalid_resource.validate().is_err());
284
285 let empty_resource = HttpResource::from("");
286 assert!(empty_resource.validate().is_err());
287 }
288
289 #[test]
290 fn test_local_path_validation() {
291 let path = LocalPath::from("./relative/path");
292 assert!(path.validate().is_ok());
293
294 let absolute_path = LocalPath::from("/absolute/path");
295 assert!(absolute_path.validate().is_ok());
296
297 let invalid_path = LocalPath::from("");
298 assert!(invalid_path.validate().is_err());
299 }
300
301 #[test]
302 fn test_address_validation() {
303 let git_addr = Address::Git(GitRepository::from("https://github.com/user/repo.git"));
304 assert!(git_addr.validate().is_ok());
305
306 let http_addr = Address::Http(HttpResource::from("https://example.com/file.zip"));
307 assert!(http_addr.validate().is_ok());
308
309 let local_addr = Address::Local(LocalPath::from("./path"));
310 assert!(local_addr.validate().is_ok());
311 }
312
313 #[test]
314 fn test_batch_validation() {
315 let addresses = vec![
316 Address::Git(GitRepository::from("https://github.com/user/repo.git")),
317 Address::Http(HttpResource::from("https://example.com/file.zip")),
318 ];
319
320 assert!(validate_addresses(&addresses).is_ok());
321
322 let invalid_addresses = vec![
323 Address::Git(GitRepository::from("")),
324 Address::Http(HttpResource::from("invalid-url")),
325 ];
326
327 assert!(validate_addresses(&invalid_addresses).is_err());
328 }
329
330 #[test]
331 fn test_is_valid_git_url() {
332 assert!(is_valid_git_url("https://github.com/user/repo.git"));
333 assert!(is_valid_git_url("git@github.com:user/repo.git"));
334 assert!(is_valid_git_url("git://github.com/user/repo.git"));
335 assert!(is_valid_git_url("https://gitlab.com/user/repo.git"));
336 assert!(!is_valid_git_url("invalid-url"));
337 assert!(!is_valid_git_url(""));
338 }
339
340 #[test]
341 fn test_git_repository_authentication_and_version_conflicts() {
342 let repo_with_ssh_key = GitRepository::from("https://github.com/user/repo.git")
344 .with_ssh_key("/path/to/ssh/key");
345
346 let result = repo_with_ssh_key.validate();
349 assert!(result.is_err() || result.is_ok()); let repo_with_token_only =
353 GitRepository::from("https://github.com/user/repo.git").with_token("test-token");
354 let result = repo_with_token_only.validate();
355 assert!(result.is_err());
356 if let Err(errors) = result {
357 assert!(errors.iter().any(|e| e.code == "MISSING_USERNAME"));
358 }
359
360 let repo_with_token_and_username = GitRepository::from("https://github.com/user/repo.git")
362 .with_token("test-token")
363 .with_username("test-user");
364 assert!(repo_with_token_and_username.validate().is_ok());
365
366 let repo_with_conflicting_versions =
368 GitRepository::from("https://github.com/user/repo.git")
369 .with_tag("v1.0")
370 .with_branch("main");
371 let result = repo_with_conflicting_versions.validate();
372 assert!(result.is_err());
373 if let Err(errors) = result {
374 assert!(errors.iter().any(|e| e.code == "CONFLICTING_VERSIONS"));
375 }
376
377 let repo_with_valid_tag =
379 GitRepository::from("https://github.com/user/repo.git").with_tag("v1.0");
380 assert!(repo_with_valid_tag.validate().is_ok());
381
382 let repo_with_valid_branch =
383 GitRepository::from("https://github.com/user/repo.git").with_branch("main");
384 assert!(repo_with_valid_branch.validate().is_ok());
385
386 let repo_with_valid_rev =
387 GitRepository::from("https://github.com/user/repo.git").with_rev("abc123");
388 assert!(repo_with_valid_rev.validate().is_ok());
389 }
390
391 #[test]
392 fn test_http_resource_authentication_edge_cases() {
393 let valid_http = HttpResource::from("https://example.com/file.zip")
395 .with_credentials("username", "password");
396 assert!(valid_http.validate().is_ok());
397
398 let mut http_with_username_only = HttpResource::from("https://example.com/file.zip");
400 http_with_username_only.set_username(Some("username".to_string()));
402 let result = http_with_username_only.validate();
403 assert!(result.is_err());
404 if let Err(errors) = result {
405 assert!(errors.iter().any(|e| e.code == "MISSING_PASSWORD"));
406 }
407
408 let invalid_urls = vec!["://missing-protocol", "http//missing-colon.com"];
410
411 for url in invalid_urls {
412 let resource = HttpResource::from(url);
413 let result = resource.validate();
414 assert!(result.is_err(), "URL {url} should be invalid");
415 }
416
417 let valid_but_non_http_urls = vec!["ftp://unsupported-protocol.com"];
420
421 for url in valid_but_non_http_urls {
422 let resource = HttpResource::from(url);
423 assert!(
426 resource.validate().is_ok(),
427 "URL {url} should pass validation (parses as valid)",
428 );
429 }
430
431 let edge_case_urls = vec![
433 "https://example.com/",
434 "https://example.com/path/",
435 "https://example.com/path/to/file.txt",
436 "https://example.com/path/to/file.txt?param=value",
437 "https://example.com/path/to/file.txt#fragment",
438 ];
439
440 for url in edge_case_urls {
441 let resource = HttpResource::from(url);
442 assert!(resource.validate().is_ok(), "URL {url} should be valid");
443 }
444 }
445
446 #[test]
447 fn test_local_path_advanced_validation() {
448 let windows_style_path = LocalPath::from("C:\\windows\\path");
450 if cfg!(not(target_os = "windows")) {
451 let result = windows_style_path.validate();
452 assert!(result.is_err());
453 if let Err(errors) = result {
454 assert!(errors.iter().any(|e| e.code == "INVALID_PATH_SEPARATOR"));
455 }
456 }
457
458 let invalid_relative_path = LocalPath::from("relative/path");
460 let result = invalid_relative_path.validate();
461 assert!(result.is_err());
462 if let Err(errors) = result {
463 assert!(errors.iter().any(|e| e.code == "INVALID_RELATIVE_PATH"));
464 }
465
466 let valid_relative_paths = vec![
468 "./relative/path",
469 "./relative/path/file.txt",
470 "../relative/path",
471 "../relative/path/file.txt",
472 "./",
473 "../",
474 ];
475
476 for path in valid_relative_paths {
477 let local_path = LocalPath::from(path);
478 assert!(local_path.validate().is_ok(), "Path {path} should be valid",);
479 }
480
481 let absolute_paths = if cfg!(target_os = "windows") {
483 vec![
484 "C:\\absolute\\path",
485 "C:\\absolute\\path\\file.txt",
486 "\\\\network\\path",
487 ]
488 } else {
489 vec!["/absolute/path", "/absolute/path/file.txt", "/absolute"]
490 };
491
492 for path in absolute_paths {
493 let local_path = LocalPath::from(path);
494 assert!(local_path.validate().is_ok(), "Path {path} should be valid",);
495 }
496
497 let empty_path = LocalPath::from("");
499 let result = empty_path.validate();
500 assert!(result.is_err());
501 if let Err(errors) = result {
502 assert!(errors.iter().any(|e| e.code == "EMPTY_PATH"));
503 }
504 }
505}