Skip to main content

ripsync_core/
meta.rs

1//! Metadata preservation and the destination-containment safety checks.
2//!
3//! Containment is the defence against the rsync symlink path-traversal CVE class
4//! (CVE-2024-12087/12088): before ripsync writes a file, creates a symlink, or
5//! deletes anything, the *real* parent directory of the target must resolve to a
6//! location inside the destination root. A pre-existing symlink in the
7//! destination that redirects a write outside the root is therefore refused
8//! rather than followed.
9
10use std::path::{Component, Path, PathBuf};
11
12use filetime::FileTime;
13
14use crate::{Error, Result};
15
16/// Emit a one-time warning that a POSIX-only metadata facility is unavailable on
17/// this platform (Windows). The operation degrades to a no-op; mtime is still
18/// preserved.
19#[cfg(not(unix))]
20fn warn_once(flag: &std::sync::atomic::AtomicBool, what: &str) {
21    use std::sync::atomic::Ordering;
22    if !flag.swap(true, Ordering::Relaxed) {
23        tracing::warn!("{what} is not supported on this platform; skipping (mtime is preserved)");
24    }
25}
26
27/// The kind of a stat-ed entry.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum FileTypeKind {
30    /// Regular file.
31    File,
32    /// Directory.
33    Dir,
34    /// Symbolic link.
35    Symlink,
36    /// Anything else (socket, FIFO, device) — unsupported.
37    Other,
38}
39
40/// Minimal metadata ripsync needs per entry: type, size, mtime, mode, and
41/// inode/device (for hardlink detection and the persistent index).
42#[derive(Debug, Clone, Copy)]
43pub struct MinMeta {
44    /// Entry kind.
45    pub kind: FileTypeKind,
46    /// Byte length (files only).
47    pub len: u64,
48    /// Modification time of the entry itself (links not followed).
49    pub mtime: FileTime,
50    /// Unix permission+type bits.
51    pub mode: u32,
52    /// Inode number.
53    pub ino: u64,
54    /// Device id.
55    pub dev: u64,
56    /// User id.
57    pub uid: u32,
58    /// Group id.
59    pub gid: u32,
60}
61
62/// Fetch [`MinMeta`] for `path` without following a final symlink.
63///
64/// On Linux this uses `statx` with a minimal field mask (cheaper than a full
65/// `stat`); elsewhere it falls back to `symlink_metadata`.
66///
67/// # Errors
68///
69/// Returns an error if the path cannot be stat-ed.
70#[cfg(target_os = "linux")]
71pub fn meta_min(path: &Path) -> Result<MinMeta> {
72    use rustix::fs::{AtFlags, CWD, StatxFlags, statx};
73
74    const S_IFMT: u32 = 0o170_000;
75
76    let mask = StatxFlags::TYPE
77        | StatxFlags::MODE
78        | StatxFlags::SIZE
79        | StatxFlags::MTIME
80        | StatxFlags::INO
81        | StatxFlags::UID
82        | StatxFlags::GID;
83    let stx = statx(CWD, path, AtFlags::SYMLINK_NOFOLLOW, mask)
84        .map_err(|e| Error::io(path, std::io::Error::from_raw_os_error(e.raw_os_error())))?;
85
86    let mode = u32::from(stx.stx_mode);
87    let kind = match mode & S_IFMT {
88        0o100_000 => FileTypeKind::File,
89        0o040_000 => FileTypeKind::Dir,
90        0o120_000 => FileTypeKind::Symlink,
91        _ => FileTypeKind::Other,
92    };
93    let mtime = FileTime::from_unix_time(stx.stx_mtime.tv_sec, stx.stx_mtime.tv_nsec as u32);
94    let dev = rustix::fs::makedev(stx.stx_dev_major, stx.stx_dev_minor);
95    Ok(MinMeta {
96        kind,
97        len: stx.stx_size,
98        mtime,
99        mode,
100        ino: stx.stx_ino,
101        dev,
102        uid: stx.stx_uid,
103        gid: stx.stx_gid,
104    })
105}
106
107/// Portable fallback using `symlink_metadata`.
108///
109/// # Errors
110///
111/// Returns an error if the path cannot be stat-ed.
112#[cfg(not(target_os = "linux"))]
113pub fn meta_min(path: &Path) -> Result<MinMeta> {
114    let meta = std::fs::symlink_metadata(path).map_err(|e| Error::io(path, e))?;
115    let ftype = meta.file_type();
116    let kind = if ftype.is_symlink() {
117        FileTypeKind::Symlink
118    } else if ftype.is_dir() {
119        FileTypeKind::Dir
120    } else if ftype.is_file() {
121        FileTypeKind::File
122    } else {
123        FileTypeKind::Other
124    };
125    #[cfg(unix)]
126    let (mode, ino, dev, uid, gid) = {
127        use std::os::unix::fs::MetadataExt;
128        (meta.mode(), meta.ino(), meta.dev(), meta.uid(), meta.gid())
129    };
130    #[cfg(not(unix))]
131    let (mode, ino, dev, uid, gid) = (0u32, 0u64, 0u64, 0u32, 0u32);
132    Ok(MinMeta {
133        kind,
134        len: meta.len(),
135        mtime: FileTime::from_last_modification_time(&meta),
136        mode,
137        ino,
138        dev,
139        uid,
140        gid,
141    })
142}
143
144/// Set selected ownership fields. On non-Unix platforms this is a no-op.
145///
146/// # Errors
147///
148/// Returns an error if ownership was requested but could not be changed.
149pub fn set_owner_group(
150    path: &Path,
151    uid: u32,
152    gid: u32,
153    owner: bool,
154    group: bool,
155    follow: bool,
156) -> Result<()> {
157    #[cfg(unix)]
158    if owner || group {
159        use rustix::fs::{AtFlags, CWD, Gid, Uid};
160
161        let uid = owner.then(|| Uid::from_raw(uid));
162        let gid = group.then(|| Gid::from_raw(gid));
163        let flags = if follow {
164            AtFlags::empty()
165        } else {
166            AtFlags::SYMLINK_NOFOLLOW
167        };
168        rustix::fs::chownat(CWD, path, uid, gid, flags)
169            .map_err(|error| Error::io(path, std::io::Error::from(error)))?;
170    }
171    #[cfg(not(unix))]
172    {
173        if owner || group {
174            static WARNED: std::sync::atomic::AtomicBool =
175                std::sync::atomic::AtomicBool::new(false);
176            warn_once(&WARNED, "owner/group preservation");
177        }
178        let _ = (path, uid, gid, follow);
179    }
180    Ok(())
181}
182
183/// Copy selected extended attributes from `src` to `dst`.
184///
185/// POSIX ACLs are represented by dedicated system xattrs on Linux and by the
186/// system security attribute on macOS. `xattrs` and `acls` select those groups
187/// independently.
188///
189/// # Errors
190///
191/// Returns an error if selected attributes cannot be listed, read, or written.
192pub fn copy_xattrs(src: &Path, dst: &Path, xattrs: bool, acls: bool) -> Result<()> {
193    #[cfg(unix)]
194    if xattrs || acls {
195        for name in xattr::list(src).map_err(|error| Error::io(src, error))? {
196            let is_acl = is_acl_xattr(&name);
197            if (is_acl && !acls) || (!is_acl && !xattrs) {
198                continue;
199            }
200            if let Some(value) = xattr::get(src, &name).map_err(|error| Error::io(src, error))? {
201                xattr::set(dst, &name, &value).map_err(|error| Error::io(dst, error))?;
202            }
203        }
204    }
205    #[cfg(not(unix))]
206    {
207        if xattrs || acls {
208            static WARNED: std::sync::atomic::AtomicBool =
209                std::sync::atomic::AtomicBool::new(false);
210            warn_once(&WARNED, "extended attributes / POSIX ACLs");
211        }
212        let _ = (src, dst);
213    }
214    Ok(())
215}
216
217#[cfg(unix)]
218fn is_acl_xattr(name: &std::ffi::OsStr) -> bool {
219    let bytes = std::os::unix::ffi::OsStrExt::as_bytes(name);
220    bytes.starts_with(b"system.posix_acl_") || bytes == b"com.apple.system.Security"
221}
222
223/// Canonicalize `root`, creating it first if it does not yet exist.
224///
225/// # Errors
226///
227/// Returns an error if the directory cannot be created or canonicalized.
228pub fn canonical_root(root: &Path) -> Result<PathBuf> {
229    if !root.exists() {
230        std::fs::create_dir_all(root).map_err(|e| Error::io(root, e))?;
231    }
232    std::fs::canonicalize(root).map_err(|e| Error::io(root, e))
233}
234
235/// Reject a relative path that tries to escape its root via `..` or an absolute
236/// component. Source-walk relative paths should never contain these; this is a
237/// belt-and-braces check before any join.
238///
239/// # Errors
240///
241/// Returns [`Error::Containment`] if `rel` contains a parent-dir or root component.
242pub fn check_relative(rel: &Path) -> Result<()> {
243    for comp in rel.components() {
244        match comp {
245            Component::Normal(_) | Component::CurDir => {}
246            Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
247                return Err(Error::Containment(rel.to_path_buf()));
248            }
249        }
250    }
251    Ok(())
252}
253
254/// Verify that the real parent directory of `target` lies within `root_canon`,
255/// returning the concrete path to write/create.
256///
257/// `target` itself need not exist, but its parent directory must (create
258/// directories first). The parent is canonicalized — following any symlinks — and
259/// checked against the canonical root.
260///
261/// # Errors
262///
263/// Returns [`Error::Containment`] if the resolved parent escapes `root_canon`.
264pub fn contained_target(root_canon: &Path, target: &Path) -> Result<PathBuf> {
265    let parent = target
266        .parent()
267        .ok_or_else(|| Error::Containment(target.to_path_buf()))?;
268    let parent_canon = std::fs::canonicalize(parent).map_err(|e| Error::io(parent, e))?;
269    if !parent_canon.starts_with(root_canon) {
270        return Err(Error::Containment(target.to_path_buf()));
271    }
272    let name = target
273        .file_name()
274        .ok_or_else(|| Error::Containment(target.to_path_buf()))?;
275    Ok(parent_canon.join(name))
276}
277
278/// Apply `mode` (Unix permission bits) to `path`. No-op on non-Unix platforms.
279///
280/// # Errors
281///
282/// Returns an error if the permissions cannot be set.
283#[allow(unused_variables)]
284pub fn set_mode(path: &Path, mode: u32) -> Result<()> {
285    #[cfg(unix)]
286    {
287        use std::os::unix::fs::PermissionsExt;
288        // strip setuid/setgid and file-type bits; propagating them from a remote sender is a security risk
289        let perm = std::fs::Permissions::from_mode(mode & !0o6000 & 0o7777);
290        std::fs::set_permissions(path, perm).map_err(|e| Error::io(path, e))?;
291    }
292    #[cfg(not(unix))]
293    {
294        static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
295        warn_once(&WARNED, "POSIX permission bits");
296    }
297    Ok(())
298}
299
300/// Set the modification time of `path` (symlinks are followed; use
301/// [`set_symlink_mtime`] for the link itself).
302///
303/// # Errors
304///
305/// Returns an error if the time cannot be set.
306pub fn set_mtime(path: &Path, mtime: FileTime) -> Result<()> {
307    filetime::set_file_mtime(path, mtime).map_err(|e| Error::io(path, e))
308}
309
310/// Set the modification time of the symlink at `path` without following it.
311///
312/// # Errors
313///
314/// Returns an error if the time cannot be set.
315pub fn set_symlink_mtime(path: &Path, mtime: FileTime) -> Result<()> {
316    filetime::set_symlink_file_times(path, mtime, mtime).map_err(|e| Error::io(path, e))
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322
323    #[test]
324    fn rejects_parent_escape() {
325        assert!(check_relative(Path::new("../etc/passwd")).is_err());
326        assert!(check_relative(Path::new("a/../../b")).is_err());
327        assert!(check_relative(Path::new("a/b/c")).is_ok());
328    }
329
330    #[test]
331    fn contained_target_blocks_escape_via_symlink() {
332        let tmp = tempfile::tempdir().unwrap();
333        let root = tmp.path().join("dst");
334        let outside = tmp.path().join("outside");
335        std::fs::create_dir_all(&root).unwrap();
336        std::fs::create_dir_all(&outside).unwrap();
337        let root_canon = canonical_root(&root).unwrap();
338
339        // A symlink inside dst that points outside dst.
340        #[cfg(unix)]
341        {
342            let link = root.join("escape");
343            std::os::unix::fs::symlink(&outside, &link).unwrap();
344            // Writing "through" the link must be refused.
345            let target = link.join("evil.txt");
346            assert!(matches!(
347                contained_target(&root_canon, &target),
348                Err(Error::Containment(_))
349            ));
350        }
351
352        // A normal path inside dst is fine.
353        let ok = root.join("file.txt");
354        assert!(contained_target(&root_canon, &ok).is_ok());
355    }
356}