Skip to main content

readstat/
rs_path.rs

1//! Path validation for SAS file input.
2//!
3//! [`ReadStatPath`] validates the input `.sas7bdat` file path and converts it
4//! to a C-compatible string for the FFI layer.
5
6use std::{
7    ffi::CString,
8    path::{Path, PathBuf},
9};
10
11use crate::err::ReadStatError;
12
13// Only data files are parseable. SAS catalog files (`.sas7bcat`) are rejected
14// up front rather than failing later with an opaque C library error.
15const IN_EXTENSIONS: &[&str] = &["sas7bdat"];
16
17/// Validated file path for SAS file input.
18///
19/// Encapsulates the input `.sas7bdat` path (validated to exist with correct extension).
20/// The input path is also converted to a [`CString`] for passing to the `ReadStat` C library.
21#[derive(Debug, Clone)]
22pub struct ReadStatPath {
23    /// Absolute path to the input `.sas7bdat` file.
24    pub path: PathBuf,
25    /// Input path as a C-compatible string for FFI. Internal plumbing.
26    pub(crate) cstring_path: CString,
27}
28
29impl ReadStatPath {
30    /// Creates a new `ReadStatPath` after validating the input path.
31    ///
32    /// Accepts anything that references a [`Path`] (`&str`, [`String`],
33    /// `&Path`, [`PathBuf`], …).
34    ///
35    /// # Errors
36    ///
37    /// Returns [`ReadStatError`] if the path does not exist or has an invalid extension.
38    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, ReadStatError> {
39        let p = Self::validate_path(path.as_ref())?;
40        Self::validate_in_extension(&p)?;
41        let csp = Self::path_to_cstring(&p)?;
42
43        Ok(Self {
44            path: p,
45            cstring_path: csp,
46        })
47    }
48
49    /// Converts a file path to a [`CString`] for FFI. Uses raw bytes on Unix.
50    #[cfg(unix)]
51    pub(crate) fn path_to_cstring(path: &Path) -> Result<CString, ReadStatError> {
52        use std::os::unix::ffi::OsStrExt;
53        let bytes = path.as_os_str().as_bytes();
54        Ok(CString::new(bytes)?)
55    }
56
57    /// Converts a file path to a [`CString`] for FFI. Uses UTF-8 on non-Unix platforms.
58    #[cfg(not(unix))]
59    pub(crate) fn path_to_cstring(path: &Path) -> Result<CString, ReadStatError> {
60        let rust_str = path
61            .as_os_str()
62            .to_str()
63            .ok_or_else(|| ReadStatError::Other("Invalid path".to_string()))?;
64        Ok(CString::new(rust_str)?)
65    }
66
67    fn validate_in_extension(path: &Path) -> Result<String, ReadStatError> {
68        match path.extension().and_then(|e| e.to_str()) {
69            Some(e) if IN_EXTENSIONS.contains(&e) => Ok(e.to_owned()),
70            _ => Err(ReadStatError::UnsupportedInputExtension(path.to_owned())),
71        }
72    }
73
74    fn validate_path(path: &Path) -> Result<PathBuf, ReadStatError> {
75        let abs_path = std::path::absolute(path)
76            .map_err(|e| ReadStatError::Other(format!("Failed to resolve path: {e}")))?;
77
78        if abs_path.exists() {
79            Ok(abs_path)
80        } else {
81            Err(ReadStatError::FileNotFound(abs_path))
82        }
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    // --- validate_in_extension ---
91
92    #[test]
93    fn valid_sas7bdat_extension() {
94        let path = Path::new("/some/file.sas7bdat");
95        assert_eq!(
96            ReadStatPath::validate_in_extension(path).unwrap(),
97            "sas7bdat"
98        );
99    }
100
101    #[test]
102    fn sas7bcat_extension_rejected() {
103        // Catalog files are not parseable and are rejected up front.
104        let path = Path::new("/some/file.sas7bcat");
105        assert!(ReadStatPath::validate_in_extension(path).is_err());
106    }
107
108    #[test]
109    fn invalid_extension() {
110        let path = Path::new("/some/file.csv");
111        assert!(ReadStatPath::validate_in_extension(path).is_err());
112    }
113
114    #[test]
115    fn no_extension() {
116        let path = Path::new("/some/file");
117        assert!(ReadStatPath::validate_in_extension(path).is_err());
118    }
119
120    // --- path_to_cstring ---
121
122    #[test]
123    fn path_to_cstring_normal() {
124        let path = PathBuf::from("/tmp/test.sas7bdat");
125        let cs = ReadStatPath::path_to_cstring(&path).unwrap();
126        assert_eq!(cs.to_str().unwrap(), "/tmp/test.sas7bdat");
127    }
128
129    // --- Property-based tests ---
130
131    mod property_tests {
132        use super::*;
133        use proptest::prelude::*;
134
135        proptest! {
136            /// Arbitrary filenames with .sas7bdat extension always pass extension validation.
137            #[test]
138            fn sas7bdat_extension_always_valid(name in "[a-zA-Z0-9_]{1,50}") {
139                let path = PathBuf::from(format!("/tmp/{name}.sas7bdat"));
140                let result = ReadStatPath::validate_in_extension(&path);
141                prop_assert!(result.is_ok(), "Expected Ok for {:?}", path);
142                prop_assert_eq!(result.unwrap(), "sas7bdat");
143            }
144
145            /// Arbitrary filenames with .sas7bcat extension are always rejected
146            /// (catalog files are not parseable).
147            #[test]
148            fn sas7bcat_extension_always_invalid(name in "[a-zA-Z0-9_]{1,50}") {
149                let path = PathBuf::from(format!("/tmp/{name}.sas7bcat"));
150                let result = ReadStatPath::validate_in_extension(&path);
151                prop_assert!(result.is_err(), "Expected Err for {:?}", path);
152            }
153
154            /// Non-SAS extensions always fail validation.
155            #[test]
156            fn non_sas_extensions_always_invalid(
157                name in "[a-zA-Z0-9_]{1,50}",
158                ext in "[a-z]{1,10}".prop_filter("not sas", |e| e != "sas7bdat" && e != "sas7bcat")
159            ) {
160                let path = PathBuf::from(format!("/tmp/{name}.{ext}"));
161                let result = ReadStatPath::validate_in_extension(&path);
162                prop_assert!(result.is_err(), "Expected Err for {:?}", path);
163            }
164
165            /// Files with no extension always fail validation.
166            #[test]
167            fn no_extension_always_invalid(name in "[a-zA-Z0-9_]{1,50}") {
168                let path = PathBuf::from(format!("/tmp/{name}"));
169                let result = ReadStatPath::validate_in_extension(&path);
170                prop_assert!(result.is_err(), "Expected Err for {:?}", path);
171            }
172        }
173    }
174}