Skip to main content

copy_metadata/
lib.rs

1#![warn(clippy::cargo)]
2
3#[cfg(unix)]
4use std::os::unix::fs::PermissionsExt as _;
5#[cfg(unix)]
6use std::os::unix::io::AsRawFd;
7use std::{fs::File, io, path::Path};
8
9#[cfg(feature = "copy-time")]
10use filetime::{set_file_handle_times, FileTime};
11
12const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x2000000;
13const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x00200000;
14
15/// Safely open a file handle, specifically for reading or modifying Metadata
16#[inline]
17fn open_file_for_metadata(path: &Path, is_source: bool) -> io::Result<File> {
18    let mut opts = std::fs::OpenOptions::new();
19
20    #[cfg(windows)]
21    {
22        use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
23        opts.read(true);
24        // 0x0100 = FILE_WRITE_ATTRIBUTES (needed if this is the target file)
25        // 0x0080 = FILE_READ_ATTRIBUTES (needed if this is the source file)
26        let access = if is_source { 0x0080 } else { 0x0100 };
27        opts.access_mode(access)
28            .share_mode(0x7) // FILE_SHARE_READ | WRITE | DELETE allows opening while another process is using the
29            // file
30            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); // Allows opening directories and reparse points
31
32        let f = opts.open(path)?;
33
34        // Check if it's a reparse point (e.g., symlink or directory junction) and
35        // reject it to prevent TOCTOU
36        if f.metadata()?.file_attributes() & 0x400 != 0 {
37            // 0x400 = FILE_ATTRIBUTE_REPARSE_POINT
38            return Err(io::Error::new(
39                io::ErrorKind::InvalidInput,
40                "Symlinks not supported",
41            ));
42        }
43
44        Ok(f)
45    }
46
47    #[cfg(unix)]
48    {
49        use std::os::unix::fs::OpenOptionsExt;
50
51        // O_NONBLOCK prevents hanging on FIFOs/special files.
52        // O_NOFOLLOW prevents TOCTOU symlink attacks.
53        // Note: O_RDONLY works fine for both files and directories, so we don't need
54        // O_DIRECTORY or any prior metadata checks.
55        let flags = libc::O_NONBLOCK | libc::O_NOFOLLOW;
56        opts.custom_flags(flags);
57
58        // Try opening with read-only access
59        opts.read(true);
60        match opts.open(path) {
61            Ok(f) => Ok(f),
62            Err(e) if e.raw_os_error() == Some(libc::ELOOP) => Err(io::Error::new(
63                io::ErrorKind::InvalidInput,
64                "Symlinks not supported",
65            )),
66            Err(e) if e.kind() == io::ErrorKind::PermissionDenied && !is_source => {
67                // If it's the target file and we have no read permission (e.g. --w-------),
68                // try opening write-only to obtain an fd.
69                // Note: If the path is a directory, O_WRONLY will naturally fail with EISDIR,
70                // which is acceptable since we cannot get a handle for a write-only directory.
71                let mut write_opts = std::fs::OpenOptions::new();
72                write_opts.write(true).custom_flags(flags);
73                match write_opts.open(path) {
74                    Ok(f) => Ok(f),
75                    Err(we) if we.raw_os_error() == Some(libc::ELOOP) => Err(io::Error::new(
76                        io::ErrorKind::InvalidInput,
77                        "Symlinks not supported",
78                    )),
79                    Err(we) => Err(we),
80                }
81            }
82            Err(e) => Err(e),
83        }
84    }
85}
86
87#[cfg(unix)]
88fn copy_permission_impl(
89    to_file: &File,
90    from_meta: &std::fs::Metadata,
91    to_meta: &std::fs::Metadata,
92) -> io::Result<()> {
93    use std::os::unix::fs::MetadataExt;
94
95    let from_gid = from_meta.gid();
96    let to_gid = to_meta.gid();
97    let from_uid = from_meta.uid();
98
99    let mut perms = from_meta.permissions();
100    perms.set_mode(perms.mode() & 0o0777);
101
102    // 1. Use handle-based fchown, completely preventing TOCTOU symlink attacks
103    if from_gid != to_gid {
104        let fd = to_file.as_raw_fd();
105        // Try to change owner and group. If we are not root, changing uid may fail;
106        // the main goal here is to synchronize the gid
107        let res = unsafe { libc::fchown(fd, from_uid, from_gid) };
108
109        if res != 0 {
110            // Security fallback: If fchown fails (usually because the user is not root
111            // and the source group differs from the user's default group), the target
112            // file will be owned by the user's default group.
113            // To prevent privilege escalation, we must not grant the original group
114            // permissions to the new group, because the new group might contain users
115            // who only had 'other' access to the source file.
116            // Therefore, we downgrade the group permissions to match the 'other'
117            // permissions.
118            let new_perms = (perms.mode() & 0o0707) | ((perms.mode() & 0o07) << 3);
119            perms.set_mode(new_perms);
120        }
121    }
122
123    // 2. Use handle-based fchmod (Rust's File::set_permissions uses fchmod on Unix
124    //    under the hood)
125    to_file.set_permissions(perms)
126}
127
128#[cfg(windows)]
129#[inline]
130fn copy_permission_impl(
131    to_file: &File,
132    from_meta: &std::fs::Metadata,
133    _to_meta: &std::fs::Metadata,
134) -> io::Result<()> {
135    // On Windows, Rust 1.63+ uses handle-based SetFileInformationByHandle for
136    // File::set_permissions, so this is safe with no TOCTOU risk.
137    to_file.set_permissions(from_meta.permissions())
138}
139
140/// Copy Metadata (permissions and timestamps), 100% handle-based with no TOCTOU
141/// risk
142pub fn copy_metadata(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
143    // 1. Open the source file handle to prevent the source from being replaced
144    //    mid-operation
145    let from_file = open_file_for_metadata(from.as_ref(), true)?;
146    let from_meta = from_file.metadata()?;
147
148    // 2. Open the target file handle
149    let to_file = open_file_for_metadata(to.as_ref(), false)?;
150    let to_meta = to_file.metadata()?;
151
152    #[cfg(feature = "copy-time")]
153    {
154        let atime = FileTime::from_last_access_time(&from_meta);
155        let mtime = FileTime::from_last_modification_time(&from_meta);
156
157        // 3. Set timestamps using the handle (calls futimens or SetFileTime internally)
158        set_file_handle_times(&to_file, Some(atime), Some(mtime))?;
159    }
160
161    // 4. Set permissions using the handle
162    copy_permission_impl(&to_file, &from_meta, &to_meta)
163}
164
165/// Copy only permissions
166pub fn copy_permission(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
167    let from_file = open_file_for_metadata(from.as_ref(), true)?;
168    let from_meta = from_file.metadata()?;
169
170    let to_file = open_file_for_metadata(to.as_ref(), false)?;
171    let to_meta = to_file.metadata()?;
172
173    copy_permission_impl(&to_file, &from_meta, &to_meta)
174}
175
176/// Copy only timestamps
177#[cfg(feature = "copy-time")]
178pub fn copy_time(from: impl AsRef<Path>, to: impl AsRef<Path>) -> io::Result<()> {
179    let from_file = open_file_for_metadata(from.as_ref(), true)?;
180    let from_meta = from_file.metadata()?;
181
182    let atime = FileTime::from_last_access_time(&from_meta);
183    let mtime = FileTime::from_last_modification_time(&from_meta);
184
185    let to_file = open_file_for_metadata(to.as_ref(), false)?;
186    set_file_handle_times(&to_file, Some(atime), Some(mtime))
187}