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
106    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    // Check for Windows reserved names
114    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, check if it's a symlink
125    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                // A concurrent lib test activated the process-global
130                // failpoint; this thread did not arm it, so behave as
131                // if the failpoint were inactive.
132                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
144/// Symlink tail of [`validate_path_safety`]: fetch metadata and reject
145/// symlinks. Extracted so the `cmd::symlink-metadata` failpoint can
146/// fall through to identical behaviour for unarmed threads.
147fn 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
161/// `fs::symlink_metadata` behind the `cmd::symlink-metadata-io`
162/// thread-local fault. The real call only fails on a TOCTOU race (the
163/// path vanishing after `exists()`), so tests inject the failure to
164/// drive the `map_err` branch in [`check_symlink`].
165fn 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/// Thread-local fault injection scoped to the current thread, so
176/// arming a fault in one test cannot leak into concurrently running
177/// tests that validate existing paths.
178#[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    /// Arms `name` for the current thread; disarmed when the returned
187    /// guard drops (panic-safe).
188    pub(super) fn arm(name: &'static str) -> ArmGuard {
189        ARMED.with(|a| a.set(Some(name)));
190        ArmGuard
191    }
192
193    /// Returns whether `name` is armed on the current thread.
194    pub(super) fn armed(name: &str) -> bool {
195        ARMED.with(|a| a.get() == Some(name))
196    }
197
198    /// RAII guard that disarms the thread-local fault on drop.
199    #[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"))] // Unix-specific: path behaviour / error messages differ on Windows
250    #[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    /// Region-free variant of `assert!(matches!(err, InvalidPath))` —
299    /// `matches!` would leave its never-taken false arm uncovered.
300    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        // A trailing colon with no digits skips the port parse
348        // entirely — covers the empty-port branch.
349        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        // `/` has no file stem, covering the `if let Some(stem)` miss
356        // branch; it exists and is not a symlink, so the result is Ok.
357        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        /// RAII guard that disables a `fail` failpoint on drop.
365        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            // Unarmed thread: the active global failpoint must fall
384            // through to the real symlink check.
385            assert!(
386                validate_path_safety(&existing, "test").is_ok(),
387                "unarmed thread must pass through the failpoint"
388            );
389
390            // Armed thread: the injected error short-circuits.
391            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    // -----------------------------------------------------------------
416    // is_valid_url -- edge cases
417    // -----------------------------------------------------------------
418
419    #[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    // -----------------------------------------------------------------
460    // validate_url -- invalid schemes, missing host
461    // -----------------------------------------------------------------
462
463    #[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}