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 if !path.is_absolute() && path_str.contains("..") {
107 return Err(CliError::InvalidPath {
108 field: field.to_string(),
109 details: "Path contains parent directory traversal".to_string(),
110 });
111 }
112
113 if let Some(stem) = path.file_stem() {
115 let stem_lower = stem.to_string_lossy().to_lowercase();
116 if RESERVED_NAMES.contains(&stem_lower.as_str()) {
117 return Err(CliError::InvalidPath {
118 field: field.to_string(),
119 details: format!("Path uses reserved name '{stem_lower}'"),
120 });
121 }
122 }
123
124 if path.exists() {
126 fail_point!("cmd::symlink-metadata", |_| {
127 #[cfg(all(test, feature = "test-fault-injection"))]
128 if !fault::armed("cmd::symlink-metadata") {
129 return check_symlink(path, field);
133 }
134 Err(CliError::IoError(std::io::Error::other(
135 "injected: cmd::symlink-metadata",
136 )))
137 });
138 return check_symlink(path, field);
139 }
140
141 Ok(())
142}
143
144fn check_symlink(path: &Path, field: &str) -> Result<(), CliError> {
148 let metadata = symlink_metadata_checked(path).map_err(|_| {
149 CliError::IoError(std::io::Error::other("Failed to get path metadata"))
150 })?;
151
152 if metadata.file_type().is_symlink() {
153 return Err(CliError::InvalidPath {
154 field: field.to_string(),
155 details: "Path is a symlink".to_string(),
156 });
157 }
158 Ok(())
159}
160
161fn symlink_metadata_checked(path: &Path) -> std::io::Result<fs::Metadata> {
166 #[cfg(all(test, feature = "test-fault-injection"))]
167 if fault::armed("cmd::symlink-metadata-io") {
168 return Err(std::io::Error::other(
169 "injected: cmd::symlink-metadata-io",
170 ));
171 }
172 fs::symlink_metadata(path)
173}
174
175#[cfg(all(test, feature = "test-fault-injection"))]
179mod fault {
180 use std::cell::Cell;
181
182 thread_local! {
183 static ARMED: Cell<Option<&'static str>> = const { Cell::new(None) };
184 }
185
186 pub(super) fn arm(name: &'static str) -> ArmGuard {
189 ARMED.with(|a| a.set(Some(name)));
190 ArmGuard
191 }
192
193 pub(super) fn armed(name: &str) -> bool {
195 ARMED.with(|a| a.get() == Some(name))
196 }
197
198 #[derive(Debug)]
200 pub(super) struct ArmGuard;
201
202 impl Drop for ArmGuard {
203 fn drop(&mut self) {
204 ARMED.with(|a| a.set(None));
205 }
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212 #[cfg(not(target_os = "windows"))]
213 use clap::Command;
214 use tempfile::tempdir;
215
216 #[test]
217 fn test_url_validation() {
218 let cmd = crate::cmd::Cli::build();
219 let _matches = cmd.get_matches_from(vec![
220 "ssg",
221 "--new",
222 "dummy_site",
223 "--content",
224 "dummy_content",
225 "--output",
226 "dummy_output",
227 "--template",
228 "dummy_template",
229 ]);
230
231 assert!(validate_url("http://example.com").is_ok());
232 assert!(validate_url("javascript:alert(1)").is_err());
233 assert!(validate_url("https://example.com<script>").is_err());
234 }
235
236 #[test]
237 fn test_path_safety() {
238 let valid = Path::new("valid");
239 let absolute_valid = std::env::current_dir().unwrap().join(valid);
240 assert!(validate_path_safety(&absolute_valid, "test").is_ok());
241 }
242
243 #[test]
244 fn test_absolute_path_validation() {
245 let path = std::env::current_dir().unwrap().join("valid_path");
246 assert!(validate_path_safety(&path, "test").is_ok());
247 }
248
249 #[cfg(not(target_os = "windows"))] #[test]
251 fn test_path_with_separators() {
252 let cmd = Command::new("test_no_required_args");
253 let _matches = cmd.get_matches_from(vec!["test_no_required_args"]);
254
255 let path = Path::new("path/to\\file");
256 let result = validate_path_safety(path, "test");
257 assert!(result.is_err(), "Expected error for backslashes");
258 }
259
260 #[test]
261 fn test_symlink_path_validation() {
262 let temp_dir = tempdir().unwrap();
263 let target = temp_dir.path().join("target");
264 let symlink = temp_dir.path().join("symlink");
265
266 fs::write(&target, "content").unwrap();
267
268 #[cfg(unix)]
269 std::os::unix::fs::symlink(&target, &symlink).unwrap();
270 #[cfg(windows)]
271 std::os::windows::fs::symlink_file(&target, &symlink).unwrap();
272
273 let resolved_path = fs::canonicalize(&symlink).unwrap();
274 let normalized_target = fs::canonicalize(&target).unwrap();
275 println!("Resolved symlink path: {resolved_path:?}");
276 println!("Normalized target path: {normalized_target:?}");
277
278 let result = validate_path_safety(&symlink, "symlink");
279 assert!(result.is_err(), "Expected error for symlink path");
280 assert!(matches!(
281 result,
282 Err(CliError::InvalidPath { field: _, details }) if details.contains("symlink")
283 ));
284 }
285
286 #[test]
287 fn test_url_edge_cases() {
288 assert!(validate_url("http://").is_err());
289 assert!(validate_url("https://").is_err());
290 assert!(validate_url("http://example.com:65536").is_err());
291 }
292
293 #[test]
294 fn test_validate_url_ftp_scheme() {
295 assert!(validate_url("ftp://example.com").is_err());
296 }
297
298 fn assert_invalid_path(result: Result<(), CliError>) {
301 let err = result.expect_err("expected InvalidPath error");
302 assert!(format!("{err:?}").starts_with("InvalidPath"));
303 }
304
305 #[test]
306 fn test_validate_path_with_invalid_chars() {
307 let result =
308 validate_path_safety(Path::new("path<with>invalid"), "test");
309 assert_invalid_path(result);
310 }
311
312 #[test]
313 fn test_validate_path_with_traversal() {
314 let result = validate_path_safety(Path::new("../etc/passwd"), "test");
315 assert_invalid_path(result);
316 }
317
318 #[test]
319 fn test_validate_path_with_reserved_name() {
320 assert_invalid_path(validate_path_safety(Path::new("con"), "test"));
321 assert_invalid_path(validate_path_safety(Path::new("aux"), "test"));
322 }
323
324 #[cfg(not(target_os = "windows"))]
325 #[test]
326 fn test_validate_path_with_backslash() {
327 let result =
328 validate_path_safety(Path::new("path\\with\\backslash"), "test");
329 assert_invalid_path(result);
330 }
331
332 #[cfg(unix)]
333 #[test]
334 fn test_validate_path_existing_symlink() {
335 let temp_dir = tempdir().unwrap();
336 let target = temp_dir.path().join("real");
337 let link = temp_dir.path().join("link");
338 fs::create_dir(&target).unwrap();
339 std::os::unix::fs::symlink(&target, &link).unwrap();
340
341 let result = validate_path_safety(&link, "test");
342 assert_invalid_path(result);
343 }
344
345 #[test]
346 fn is_valid_url_empty_port_after_colon_is_accepted() {
347 assert!(is_valid_url("http://example.com:"));
350 assert!(is_valid_url("https://example.com:/path"));
351 }
352
353 #[test]
354 fn validate_path_root_has_no_file_stem() {
355 assert!(validate_path_safety(Path::new("/"), "test").is_ok());
358 }
359
360 #[cfg(feature = "test-fault-injection")]
361 mod fault_injection {
362 use super::*;
363
364 struct FailGuard<'a>(&'a str);
366
367 impl Drop for FailGuard<'_> {
368 fn drop(&mut self) {
369 let _ = fail::cfg(self.0, "off");
370 }
371 }
372
373 #[test]
374 fn symlink_metadata_failpoint_short_circuits_armed_thread_only() {
375 let temp_dir = tempdir().unwrap();
376 let existing = temp_dir.path().join("real.txt");
377 fs::write(&existing, "x").unwrap();
378
379 let _fail_guard = FailGuard("cmd::symlink-metadata");
380 fail::cfg("cmd::symlink-metadata", "return")
381 .expect("activate failpoint");
382
383 assert!(
386 validate_path_safety(&existing, "test").is_ok(),
387 "unarmed thread must pass through the failpoint"
388 );
389
390 let _arm = fault::arm("cmd::symlink-metadata");
392 let err = validate_path_safety(&existing, "test")
393 .expect_err("armed failpoint must short-circuit");
394 assert!(
395 format!("{err}").contains("injected: cmd::symlink-metadata")
396 );
397 }
398
399 #[test]
400 fn symlink_metadata_io_fault_drives_map_err() {
401 let temp_dir = tempdir().unwrap();
402 let existing = temp_dir.path().join("real.txt");
403 fs::write(&existing, "x").unwrap();
404
405 let _arm = fault::arm("cmd::symlink-metadata-io");
406 let err = validate_path_safety(&existing, "test")
407 .expect_err("injected metadata error must propagate");
408 assert!(
409 format!("{err}").contains("Failed to get path metadata"),
410 "map_err must wrap the injected error: {err}"
411 );
412 }
413 }
414
415 #[test]
420 fn is_valid_url_empty_string() {
421 assert!(!is_valid_url(""));
422 }
423
424 #[test]
425 fn is_valid_url_no_dot_in_host() {
426 assert!(!is_valid_url("http://localhost"));
427 }
428
429 #[test]
430 fn is_valid_url_just_scheme() {
431 assert!(!is_valid_url("http://"));
432 assert!(!is_valid_url("https://"));
433 }
434
435 #[test]
436 fn is_valid_url_with_port() {
437 assert!(is_valid_url("http://example.com:8080"));
438 assert!(is_valid_url("https://example.com:443"));
439 }
440
441 #[test]
442 fn is_valid_url_with_path() {
443 assert!(is_valid_url("http://example.com/path/to/page"));
444 assert!(is_valid_url("https://example.com/"));
445 }
446
447 #[test]
448 fn is_valid_url_invalid_port() {
449 assert!(!is_valid_url("http://example.com:99999"));
450 assert!(!is_valid_url("http://example.com:notaport"));
451 }
452
453 #[test]
454 fn is_valid_url_no_scheme() {
455 assert!(!is_valid_url("example.com"));
456 assert!(!is_valid_url("ftp://example.com"));
457 }
458
459 #[test]
464 fn validate_url_data_scheme_rejected() {
465 assert!(validate_url("data:text/html,<h1>hi</h1>").is_err());
466 }
467
468 #[test]
469 fn validate_url_vbscript_scheme_rejected() {
470 assert!(validate_url("vbscript:MsgBox").is_err());
471 }
472
473 #[test]
474 fn validate_url_missing_host_after_scheme() {
475 assert!(validate_url("http://").is_err());
476 }
477
478 #[test]
479 fn validate_url_angle_brackets_rejected() {
480 assert!(validate_url("http://example.com/<script>").is_err());
481 }
482
483 #[test]
484 fn validate_url_quote_rejected() {
485 assert!(validate_url("http://example.com/\"test").is_err());
486 }
487}