Skip to main content

subx_cli/core/
fs_util.rs

1//! Utility functions for filesystem operations with CIFS compatibility.
2//!
3//! Provides helpers to perform file copy operations that avoid POSIX metadata
4//! copy which may not be supported on CIFS (SMB) filesystems.
5
6use std::fs::{File, OpenOptions};
7use std::io::{self, copy};
8use std::path::Path;
9
10#[cfg(unix)]
11use std::os::unix::fs::OpenOptionsExt;
12
13/// Copies file contents from `source` to `destination` without copying metadata.
14///
15/// This function opens the source file and creates/truncates the destination file,
16/// then copies the data stream. It avoids POSIX permission copy to maintain
17/// compatibility with CIFS filesystems where metadata operations may fail.
18///
19/// # Errors
20///
21/// Returns an `io::Error` if reading from source or writing to destination fails.
22pub fn copy_file_cifs_safe(source: &Path, destination: &Path) -> io::Result<u64> {
23    let mut src = File::open(source)?;
24    let mut dst = File::create(destination)?;
25    copy(&mut src, &mut dst)
26}
27
28/// Atomically creates a new file at `path`, failing if it already exists.
29///
30/// Uses `O_CREAT | O_EXCL` semantics via `create_new(true)` so that file creation
31/// is race-free. On Unix systems the file is created with mode `0o644`.
32///
33/// # Errors
34///
35/// Returns `io::ErrorKind::AlreadyExists` if the path already exists, or any
36/// other I/O error from the underlying `open` call.
37pub fn atomic_create_file(path: &Path) -> io::Result<File> {
38    let mut opts = OpenOptions::new();
39    opts.write(true).create_new(true);
40    #[cfg(unix)]
41    {
42        opts.mode(0o644);
43    }
44    opts.open(path)
45}
46
47/// Validates that `target` resolves within `expected_parent` and is not itself a symlink.
48///
49/// The validation canonicalizes the parent directory of `target` and compares it to
50/// the canonicalized `expected_parent` to ensure no symlink in the parent chain
51/// escapes the intended directory. If `target` itself exists and is a symbolic link,
52/// an error is returned.
53///
54/// # Errors
55///
56/// Returns `io::ErrorKind::PermissionDenied` if the canonicalized parent escapes
57/// `expected_parent`, or if `target` is a symbolic link. Returns other I/O errors
58/// from canonicalization failures.
59pub fn validate_write_target(target: &Path, expected_parent: &Path) -> io::Result<()> {
60    let target_parent = target.parent().ok_or_else(|| {
61        io::Error::new(
62            io::ErrorKind::InvalidInput,
63            "target has no parent directory",
64        )
65    })?;
66
67    let canon_parent = target_parent.canonicalize()?;
68    let canon_expected = expected_parent.canonicalize()?;
69
70    if !canon_parent.starts_with(&canon_expected) {
71        return Err(io::Error::new(
72            io::ErrorKind::PermissionDenied,
73            format!(
74                "target parent {} escapes expected parent {}",
75                canon_parent.display(),
76                canon_expected.display()
77            ),
78        ));
79    }
80
81    match std::fs::symlink_metadata(target) {
82        Ok(meta) => {
83            if meta.file_type().is_symlink() {
84                return Err(io::Error::new(
85                    io::ErrorKind::PermissionDenied,
86                    format!(
87                        "refusing to operate on symlink target: {}",
88                        target.display()
89                    ),
90                ));
91            }
92        }
93        Err(e) if e.kind() == io::ErrorKind::NotFound => {}
94        Err(e) => return Err(e),
95    }
96
97    Ok(())
98}
99
100/// Checks that a file does not exceed the specified size limit.
101///
102/// Returns `Ok(())` if the file size is within bounds, or an error with a
103/// descriptive message including the file label, actual size, and limit.
104///
105/// # Errors
106///
107/// Returns an `io::Error` of kind `InvalidInput` if the file exceeds `max_bytes`.
108pub fn check_file_size(path: &Path, max_bytes: u64, label: &str) -> io::Result<()> {
109    let metadata = std::fs::metadata(path)?;
110    let size = metadata.len();
111    if size > max_bytes {
112        return Err(io::Error::new(
113            io::ErrorKind::InvalidInput,
114            format!(
115                "{} file too large: {} bytes (limit: {} bytes): {}",
116                label,
117                size,
118                max_bytes,
119                path.display()
120            ),
121        ));
122    }
123    Ok(())
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use std::fs;
130    use std::io::Write;
131    use tempfile::TempDir;
132
133    #[test]
134    fn test_copy_file_cifs_safe() -> io::Result<()> {
135        let temp = TempDir::new()?;
136        let src_path = temp.path().join("src.txt");
137        let dst_path = temp.path().join("dst.txt");
138        let content = b"hello cifs safe copy";
139        fs::write(&src_path, content)?;
140        let bytes = copy_file_cifs_safe(&src_path, &dst_path)?;
141        assert_eq!(bytes as usize, content.len());
142        let copied = fs::read(&dst_path)?;
143        assert_eq!(copied, content);
144        Ok(())
145    }
146
147    #[test]
148    fn test_atomic_create_file_new() -> io::Result<()> {
149        let temp = TempDir::new()?;
150        let path = temp.path().join("new.txt");
151        let mut f = atomic_create_file(&path)?;
152        f.write_all(b"data")?;
153        drop(f);
154        assert_eq!(fs::read(&path)?, b"data");
155        Ok(())
156    }
157
158    #[test]
159    fn test_atomic_create_file_existing_fails() -> io::Result<()> {
160        let temp = TempDir::new()?;
161        let path = temp.path().join("exists.txt");
162        fs::write(&path, b"x")?;
163        let err = atomic_create_file(&path).unwrap_err();
164        assert_eq!(err.kind(), io::ErrorKind::AlreadyExists);
165        Ok(())
166    }
167
168    #[cfg(unix)]
169    #[test]
170    fn test_atomic_create_file_mode() -> io::Result<()> {
171        use std::os::unix::fs::PermissionsExt;
172        let temp = TempDir::new()?;
173        let path = temp.path().join("mode.txt");
174        atomic_create_file(&path)?;
175        let meta = fs::metadata(&path)?;
176        let mode = meta.permissions().mode() & 0o777;
177        // umask may strip some bits, but 0o644 should at minimum be the ceiling
178        assert!(mode & !0o644 == 0, "unexpected mode: {:o}", mode);
179        Ok(())
180    }
181
182    #[test]
183    fn test_validate_write_target_ok() -> io::Result<()> {
184        let temp = TempDir::new()?;
185        let target = temp.path().join("file.txt");
186        validate_write_target(&target, temp.path())?;
187        Ok(())
188    }
189
190    #[cfg(unix)]
191    #[test]
192    fn test_validate_write_target_rejects_symlink_target() -> io::Result<()> {
193        let temp = TempDir::new()?;
194        let real = temp.path().join("real.txt");
195        fs::write(&real, b"x")?;
196        let link = temp.path().join("link.txt");
197        std::os::unix::fs::symlink(&real, &link)?;
198        let err = validate_write_target(&link, temp.path()).unwrap_err();
199        assert_eq!(err.kind(), io::ErrorKind::PermissionDenied);
200        Ok(())
201    }
202
203    #[test]
204    fn test_check_file_size_under_limit() -> io::Result<()> {
205        let temp = TempDir::new()?;
206        let path = temp.path().join("small.txt");
207        fs::write(&path, b"hello")?;
208        check_file_size(&path, 1024, "Test")?;
209        Ok(())
210    }
211
212    #[test]
213    fn test_check_file_size_over_limit() -> io::Result<()> {
214        let temp = TempDir::new()?;
215        let path = temp.path().join("big.txt");
216        fs::write(&path, vec![0u8; 2048])?;
217        let err = check_file_size(&path, 1024, "Test").unwrap_err();
218        assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
219        assert!(err.to_string().contains("Test file too large"));
220        Ok(())
221    }
222
223    #[test]
224    fn test_check_file_size_at_limit() -> io::Result<()> {
225        let temp = TempDir::new()?;
226        let path = temp.path().join("exact.txt");
227        fs::write(&path, vec![0u8; 1024])?;
228        check_file_size(&path, 1024, "Test")?;
229        Ok(())
230    }
231}