Skip to main content

ssg/cmd/
validation.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! URL and path validation utilities.
5
6use super::error::CliError;
7use super::RESERVED_NAMES;
8use std::fs;
9use std::path::Path;
10
11/// Returns `true` if `s` looks like a valid HTTP(S) URL.
12///
13/// # Examples
14///
15/// ```rust
16/// use ssg::cmd::is_valid_url;
17///
18/// assert!(is_valid_url("https://example.com"));
19/// assert!(is_valid_url("http://example.com:8080/path"));
20/// assert!(!is_valid_url("ftp://example.com"));
21/// assert!(!is_valid_url("http://localhost"));
22/// ```
23pub 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    // Must have a dot in the host portion
33    if !rest.contains('.') {
34        return false;
35    }
36
37    // Validate port if present: split host from path, then check for ':'
38    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
52/// Validates a URL for security and format.
53///
54/// # Examples
55/// ```
56/// use ssg::cmd::validate_url;
57/// assert!(validate_url("http://example.com").is_ok());
58/// assert!(validate_url("javascript:alert(1)").is_err());
59/// ```
60pub 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    // Check for invalid characters and mixed separators
85    let path_str = path.to_string_lossy();
86
87    // Basic invalid characters
88    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    // Check for mixed/invalid path separators (only on non-Windows)
97    #[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    // Parent directory traversal check. A relative path is resolved
106    // lexically against where it starts and refused only if it climbs
107    // above that point, so `a..b.md` and `a/../b` are fine and
108    // `../x` or `a/../../x` are not. Absolute paths are left alone:
109    // the user typed them on the command line or in their own config,
110    // and an absolute path can name any location with or without `..`.
111    // "Absolute" here means anchored: it starts at a root or a drive
112    // prefix. On Windows `\foo` and `C:foo` are anchored but not
113    // `is_absolute()`, and must be accepted as they were before.
114    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    // Check for Windows reserved names
126    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, check if it's a symlink
137    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                // A concurrent lib test activated the process-global
142                // failpoint; this thread did not arm it, so behave as
143                // if the failpoint were inactive.
144                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
156/// Symlink tail of [`validate_path_safety`]: fetch metadata and reject
157/// symlinks. Extracted so the `cmd::symlink-metadata` failpoint can
158/// fall through to identical behaviour for unarmed threads.
159fn 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
173/// `fs::symlink_metadata` behind the `cmd::symlink-metadata-io`
174/// thread-local fault. The real call only fails on a TOCTOU race (the
175/// path vanishing after `exists()`), so tests inject the failure to
176/// drive the `map_err` branch in [`check_symlink`].
177fn 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/// Thread-local fault injection scoped to the current thread, so
188/// arming a fault in one test cannot leak into concurrently running
189/// tests that validate existing paths.
190#[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    /// Arms `name` for the current thread; disarmed when the returned
199    /// guard drops (panic-safe).
200    pub(super) fn arm(name: &'static str) -> ArmGuard {
201        ARMED.with(|a| a.set(Some(name)));
202        ArmGuard
203    }
204
205    /// Returns whether `name` is armed on the current thread.
206    pub(super) fn armed(name: &str) -> bool {
207        ARMED.with(|a| a.get() == Some(name))
208    }
209
210    /// RAII guard that disarms the thread-local fault on drop.
211    #[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"))] // Unix-specific: path behaviour / error messages differ on Windows
262    #[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    /// Region-free variant of `assert!(matches!(err, InvalidPath))` —
311    /// `matches!` would leave its never-taken false arm uncovered.
312    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        // A substring check for ".." used to reject these.
333        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        // A trailing colon with no digits skips the port parse
380        // entirely — covers the empty-port branch.
381        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        // `/` has no file stem, covering the `if let Some(stem)` miss
388        // branch; it exists and is not a symlink, so the result is Ok.
389        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        // Anchored at a root or a drive but not `is_absolute()` on
396        // Windows; 0.0.73 accepted them and so must the safe_join check.
397        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        /// RAII guard that disables a `fail` failpoint on drop.
410        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            // Unarmed thread: the active global failpoint must fall
429            // through to the real symlink check.
430            assert!(
431                validate_path_safety(&existing, "test").is_ok(),
432                "unarmed thread must pass through the failpoint"
433            );
434
435            // Armed thread: the injected error short-circuits.
436            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    // -----------------------------------------------------------------
461    // is_valid_url -- edge cases
462    // -----------------------------------------------------------------
463
464    #[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    // -----------------------------------------------------------------
505    // validate_url -- invalid schemes, missing host
506    // -----------------------------------------------------------------
507
508    #[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}