1use super::error::CliError;
7use super::RESERVED_NAMES;
8use std::fs;
9use std::path::Path;
10
11pub fn is_valid_url(s: &str) -> bool {
24 let rest = if let Some(r) = s.strip_prefix("https://") {
25 r
26 } else if let Some(r) = s.strip_prefix("http://") {
27 r
28 } else {
29 return false;
30 };
31
32 if !rest.contains('.') {
34 return false;
35 }
36
37 let authority = rest.split('/').next().unwrap_or(rest);
39 if let Some(colon_pos) = authority.rfind(':') {
40 let port_str = &authority[colon_pos + 1..];
41 if !port_str.is_empty() {
42 match port_str.parse::<u16>() {
43 Ok(_) => {}
44 Err(_) => return false,
45 }
46 }
47 }
48
49 true
50}
51
52pub fn validate_url(url: &str) -> Result<(), CliError> {
61 let xss_patterns = ["javascript:", "data:", "vbscript:"];
62 if xss_patterns.iter().any(|p| url.contains(p)) {
63 return Err(CliError::InvalidUrl(
64 "URL contains unsafe protocol".into(),
65 ));
66 }
67
68 if url.contains('<') || url.contains('>') || url.contains('"') {
69 return Err(CliError::InvalidUrl(
70 "URL contains invalid characters".into(),
71 ));
72 }
73
74 if !is_valid_url(url) {
75 return Err(CliError::InvalidUrl(url.to_string()));
76 }
77 Ok(())
78}
79
80pub(super) fn validate_path_safety(
81 path: &Path,
82 field: &str,
83) -> Result<(), CliError> {
84 let path_str = path.to_string_lossy();
86
87 let invalid_chars = ["<", ">", "|", "\"", "?", "*"];
89 if invalid_chars.iter().any(|&c| path_str.contains(c)) {
90 return Err(CliError::InvalidPath {
91 field: field.to_string(),
92 details: "Path contains invalid characters".to_string(),
93 });
94 }
95
96 #[cfg(not(target_os = "windows"))]
98 if path_str.contains('\\') {
99 return Err(CliError::InvalidPath {
100 field: field.to_string(),
101 details: "Path contains backslashes".to_string(),
102 });
103 }
104
105 let anchored = matches!(
115 path.components().next(),
116 Some(std::path::Component::Prefix(_) | std::path::Component::RootDir)
117 );
118 if !anchored && commons::path::safe_join(Path::new(""), path).is_err() {
119 return Err(CliError::InvalidPath {
120 field: field.to_string(),
121 details: "Path contains parent directory traversal".to_string(),
122 });
123 }
124
125 if let Some(stem) = path.file_stem() {
127 let stem_lower = stem.to_string_lossy().to_lowercase();
128 if RESERVED_NAMES.contains(&stem_lower.as_str()) {
129 return Err(CliError::InvalidPath {
130 field: field.to_string(),
131 details: format!("Path uses reserved name '{stem_lower}'"),
132 });
133 }
134 }
135
136 if path.exists() {
138 fail_point!("cmd::symlink-metadata", |_| {
139 #[cfg(all(test, feature = "test-fault-injection"))]
140 if !fault::armed("cmd::symlink-metadata") {
141 return check_symlink(path, field);
145 }
146 Err(CliError::IoError(std::io::Error::other(
147 "injected: cmd::symlink-metadata",
148 )))
149 });
150 return check_symlink(path, field);
151 }
152
153 Ok(())
154}
155
156fn check_symlink(path: &Path, field: &str) -> Result<(), CliError> {
160 let metadata = symlink_metadata_checked(path).map_err(|_| {
161 CliError::IoError(std::io::Error::other("Failed to get path metadata"))
162 })?;
163
164 if metadata.file_type().is_symlink() {
165 return Err(CliError::InvalidPath {
166 field: field.to_string(),
167 details: "Path is a symlink".to_string(),
168 });
169 }
170 Ok(())
171}
172
173fn symlink_metadata_checked(path: &Path) -> std::io::Result<fs::Metadata> {
178 #[cfg(all(test, feature = "test-fault-injection"))]
179 if fault::armed("cmd::symlink-metadata-io") {
180 return Err(std::io::Error::other(
181 "injected: cmd::symlink-metadata-io",
182 ));
183 }
184 fs::symlink_metadata(path)
185}
186
187#[cfg(all(test, feature = "test-fault-injection"))]
191mod fault {
192 use std::cell::Cell;
193
194 thread_local! {
195 static ARMED: Cell<Option<&'static str>> = const { Cell::new(None) };
196 }
197
198 pub(super) fn arm(name: &'static str) -> ArmGuard {
201 ARMED.with(|a| a.set(Some(name)));
202 ArmGuard
203 }
204
205 pub(super) fn armed(name: &str) -> bool {
207 ARMED.with(|a| a.get() == Some(name))
208 }
209
210 #[derive(Debug)]
212 pub(super) struct ArmGuard;
213
214 impl Drop for ArmGuard {
215 fn drop(&mut self) {
216 ARMED.with(|a| a.set(None));
217 }
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 #[cfg(not(target_os = "windows"))]
225 use clap::Command;
226 use tempfile::tempdir;
227
228 #[test]
229 fn test_url_validation() {
230 let cmd = crate::cmd::Cli::build();
231 let _matches = cmd.get_matches_from(vec![
232 "ssg",
233 "--new",
234 "dummy_site",
235 "--content",
236 "dummy_content",
237 "--output",
238 "dummy_output",
239 "--template",
240 "dummy_template",
241 ]);
242
243 assert!(validate_url("http://example.com").is_ok());
244 assert!(validate_url("javascript:alert(1)").is_err());
245 assert!(validate_url("https://example.com<script>").is_err());
246 }
247
248 #[test]
249 fn test_path_safety() {
250 let valid = Path::new("valid");
251 let absolute_valid = std::env::current_dir().unwrap().join(valid);
252 assert!(validate_path_safety(&absolute_valid, "test").is_ok());
253 }
254
255 #[test]
256 fn test_absolute_path_validation() {
257 let path = std::env::current_dir().unwrap().join("valid_path");
258 assert!(validate_path_safety(&path, "test").is_ok());
259 }
260
261 #[cfg(not(target_os = "windows"))] #[test]
263 fn test_path_with_separators() {
264 let cmd = Command::new("test_no_required_args");
265 let _matches = cmd.get_matches_from(vec!["test_no_required_args"]);
266
267 let path = Path::new("path/to\\file");
268 let result = validate_path_safety(path, "test");
269 assert!(result.is_err(), "Expected error for backslashes");
270 }
271
272 #[test]
273 fn test_symlink_path_validation() {
274 let temp_dir = tempdir().unwrap();
275 let target = temp_dir.path().join("target");
276 let symlink = temp_dir.path().join("symlink");
277
278 fs::write(&target, "content").unwrap();
279
280 #[cfg(unix)]
281 std::os::unix::fs::symlink(&target, &symlink).unwrap();
282 #[cfg(windows)]
283 std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
284
285 let resolved_path = fs::canonicalize(&symlink).unwrap();
286 let normalized_target = fs::canonicalize(&target).unwrap();
287 println!("Resolved symlink path: {resolved_path:?}");
288 println!("Normalized target path: {normalized_target:?}");
289
290 let result = validate_path_safety(&symlink, "symlink");
291 assert!(result.is_err(), "Expected error for symlink path");
292 assert!(matches!(
293 result,
294 Err(CliError::InvalidPath { field: _, details }) if details.contains("symlink")
295 ));
296 }
297
298 #[test]
299 fn test_url_edge_cases() {
300 assert!(validate_url("http://").is_err());
301 assert!(validate_url("https://").is_err());
302 assert!(validate_url("http://example.com:65536").is_err());
303 }
304
305 #[test]
306 fn test_validate_url_ftp_scheme() {
307 assert!(validate_url("ftp://example.com").is_err());
308 }
309
310 fn assert_invalid_path(result: Result<(), CliError>) {
313 let err = result.expect_err("expected InvalidPath error");
314 assert!(format!("{err:?}").starts_with("InvalidPath"));
315 }
316
317 #[test]
318 fn test_validate_path_with_invalid_chars() {
319 let result =
320 validate_path_safety(Path::new("path<with>invalid"), "test");
321 assert_invalid_path(result);
322 }
323
324 #[test]
325 fn test_validate_path_with_traversal() {
326 let result = validate_path_safety(Path::new("../etc/passwd"), "test");
327 assert_invalid_path(result);
328 }
329
330 #[test]
331 fn dotted_names_are_not_traversal() {
332 for p in ["a..b.md", "content/v1..v2", "..hidden"] {
334 assert!(validate_path_safety(Path::new(p), "test").is_ok(), "{p}");
335 }
336 }
337
338 #[test]
339 fn relative_paths_must_not_climb_out() {
340 for p in ["..", "../x", "a/../../x", "a/./../../x"] {
341 assert_invalid_path(validate_path_safety(Path::new(p), "test"));
342 }
343 }
344
345 #[test]
346 fn backtracking_inside_a_relative_path_is_allowed() {
347 assert!(validate_path_safety(Path::new("a/../b"), "test").is_ok());
348 }
349
350 #[test]
351 fn test_validate_path_with_reserved_name() {
352 assert_invalid_path(validate_path_safety(Path::new("con"), "test"));
353 assert_invalid_path(validate_path_safety(Path::new("aux"), "test"));
354 }
355
356 #[cfg(not(target_os = "windows"))]
357 #[test]
358 fn test_validate_path_with_backslash() {
359 let result =
360 validate_path_safety(Path::new("path\\with\\backslash"), "test");
361 assert_invalid_path(result);
362 }
363
364 #[cfg(unix)]
365 #[test]
366 fn test_validate_path_existing_symlink() {
367 let temp_dir = tempdir().unwrap();
368 let target = temp_dir.path().join("real");
369 let link = temp_dir.path().join("link");
370 fs::create_dir(&target).unwrap();
371 std::os::unix::fs::symlink(&target, &link).unwrap();
372
373 let result = validate_path_safety(&link, "test");
374 assert_invalid_path(result);
375 }
376
377 #[test]
378 fn is_valid_url_empty_port_after_colon_is_accepted() {
379 assert!(is_valid_url("http://example.com:"));
382 assert!(is_valid_url("https://example.com:/path"));
383 }
384
385 #[test]
386 fn validate_path_root_has_no_file_stem() {
387 assert!(validate_path_safety(Path::new("/"), "test").is_ok());
390 }
391
392 #[cfg(windows)]
393 #[test]
394 fn validate_path_accepts_windows_anchored_paths() {
395 for p in [r"\site", r"C:site", r"\..\x"] {
398 assert!(
399 validate_path_safety(Path::new(p), "test").is_ok(),
400 "{p} should be accepted"
401 );
402 }
403 }
404
405 #[cfg(feature = "test-fault-injection")]
406 mod fault_injection {
407 use super::*;
408
409 struct FailGuard<'a>(&'a str);
411
412 impl Drop for FailGuard<'_> {
413 fn drop(&mut self) {
414 let _ = fail::cfg(self.0, "off");
415 }
416 }
417
418 #[test]
419 fn symlink_metadata_failpoint_short_circuits_armed_thread_only() {
420 let temp_dir = tempdir().unwrap();
421 let existing = temp_dir.path().join("real.txt");
422 fs::write(&existing, "x").unwrap();
423
424 let _fail_guard = FailGuard("cmd::symlink-metadata");
425 fail::cfg("cmd::symlink-metadata", "return")
426 .expect("activate failpoint");
427
428 assert!(
431 validate_path_safety(&existing, "test").is_ok(),
432 "unarmed thread must pass through the failpoint"
433 );
434
435 let _arm = fault::arm("cmd::symlink-metadata");
437 let err = validate_path_safety(&existing, "test")
438 .expect_err("armed failpoint must short-circuit");
439 assert!(
440 format!("{err}").contains("injected: cmd::symlink-metadata")
441 );
442 }
443
444 #[test]
445 fn symlink_metadata_io_fault_drives_map_err() {
446 let temp_dir = tempdir().unwrap();
447 let existing = temp_dir.path().join("real.txt");
448 fs::write(&existing, "x").unwrap();
449
450 let _arm = fault::arm("cmd::symlink-metadata-io");
451 let err = validate_path_safety(&existing, "test")
452 .expect_err("injected metadata error must propagate");
453 assert!(
454 format!("{err}").contains("Failed to get path metadata"),
455 "map_err must wrap the injected error: {err}"
456 );
457 }
458 }
459
460 #[test]
465 fn is_valid_url_empty_string() {
466 assert!(!is_valid_url(""));
467 }
468
469 #[test]
470 fn is_valid_url_no_dot_in_host() {
471 assert!(!is_valid_url("http://localhost"));
472 }
473
474 #[test]
475 fn is_valid_url_just_scheme() {
476 assert!(!is_valid_url("http://"));
477 assert!(!is_valid_url("https://"));
478 }
479
480 #[test]
481 fn is_valid_url_with_port() {
482 assert!(is_valid_url("http://example.com:8080"));
483 assert!(is_valid_url("https://example.com:443"));
484 }
485
486 #[test]
487 fn is_valid_url_with_path() {
488 assert!(is_valid_url("http://example.com/path/to/page"));
489 assert!(is_valid_url("https://example.com/"));
490 }
491
492 #[test]
493 fn is_valid_url_invalid_port() {
494 assert!(!is_valid_url("http://example.com:99999"));
495 assert!(!is_valid_url("http://example.com:notaport"));
496 }
497
498 #[test]
499 fn is_valid_url_no_scheme() {
500 assert!(!is_valid_url("example.com"));
501 assert!(!is_valid_url("ftp://example.com"));
502 }
503
504 #[test]
509 fn validate_url_data_scheme_rejected() {
510 assert!(validate_url("data:text/html,<h1>hi</h1>").is_err());
511 }
512
513 #[test]
514 fn validate_url_vbscript_scheme_rejected() {
515 assert!(validate_url("vbscript:MsgBox").is_err());
516 }
517
518 #[test]
519 fn validate_url_missing_host_after_scheme() {
520 assert!(validate_url("http://").is_err());
521 }
522
523 #[test]
524 fn validate_url_angle_brackets_rejected() {
525 assert!(validate_url("http://example.com/<script>").is_err());
526 }
527
528 #[test]
529 fn validate_url_quote_rejected() {
530 assert!(validate_url("http://example.com/\"test").is_err());
531 }
532}