1use std::ffi::OsString;
6use std::fs;
7use std::io;
8use std::path::{Component, Path, PathBuf};
9
10pub(crate) const CANCELLED: &str = "operation cancelled";
12
13pub(crate) fn cancelled() -> io::Error {
14 io::Error::new(io::ErrorKind::Interrupted, CANCELLED)
15}
16
17#[derive(Debug, thiserror::Error)]
22pub(crate) enum RenameError {
23 #[error("destination already exists")]
24 DestinationExists,
25 #[error("source and destination are on different filesystems")]
26 CrossDevice,
27 #[error("{0}")]
28 Io(io::Error),
29}
30
31impl RenameError {
32 fn classify(error: io::Error) -> Self {
33 if is_cross_device(&error) {
34 Self::CrossDevice
35 } else if error.kind() == io::ErrorKind::AlreadyExists {
36 Self::DestinationExists
37 } else {
38 Self::Io(error)
39 }
40 }
41}
42
43impl From<RenameError> for io::Error {
44 fn from(error: RenameError) -> Self {
45 match error {
46 RenameError::DestinationExists => {
47 io::Error::new(io::ErrorKind::AlreadyExists, "destination already exists")
48 }
49 RenameError::CrossDevice => io::Error::new(
50 io::ErrorKind::CrossesDevices,
51 "source and destination are on different filesystems",
52 ),
53 RenameError::Io(error) => error,
54 }
55 }
56}
57
58#[cfg(unix)]
59fn is_cross_device(error: &io::Error) -> bool {
60 error.raw_os_error() == Some(libc::EXDEV)
61}
62
63#[cfg(windows)]
64fn is_cross_device(error: &io::Error) -> bool {
65 const ERROR_NOT_SAME_DEVICE: i32 = 17;
66 error.raw_os_error() == Some(ERROR_NOT_SAME_DEVICE)
67}
68
69#[cfg(not(any(unix, windows)))]
70fn is_cross_device(_error: &io::Error) -> bool {
71 false
72}
73
74pub(crate) fn rename_noreplace(source: &Path, destination: &Path) -> Result<(), RenameError> {
83 match platform::rename_noreplace(source, destination) {
84 Some(result) => result.map_err(RenameError::classify),
85 None => rename_noreplace_fallback(source, destination),
86 }
87}
88
89fn rename_noreplace_fallback(source: &Path, destination: &Path) -> Result<(), RenameError> {
90 let metadata = fs::symlink_metadata(source).map_err(RenameError::Io)?;
91 if metadata.is_dir() {
92 fs::create_dir(destination).map_err(RenameError::classify)?;
95 if let Err(error) = fs::rename(source, destination) {
96 let _ = fs::remove_dir(destination);
97 return Err(RenameError::classify(error));
98 }
99 return Ok(());
100 }
101 match fs::hard_link(source, destination) {
102 Ok(()) => fs::remove_file(source).map_err(|error| {
103 RenameError::Io(io::Error::new(
105 error.kind(),
106 format!("renamed by linking, but the original name remains: {error}"),
107 ))
108 }),
109 Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {
110 Err(RenameError::DestinationExists)
111 }
112 Err(error) if is_cross_device(&error) => Err(RenameError::CrossDevice),
113 Err(_) => {
116 if fs::symlink_metadata(destination).is_ok() {
117 return Err(RenameError::DestinationExists);
118 }
119 fs::rename(source, destination).map_err(RenameError::classify)
120 }
121 }
122}
123
124#[cfg(any(target_os = "linux", target_os = "android", target_vendor = "apple"))]
125mod platform {
126 use std::ffi::CString;
127 use std::io;
128 use std::os::unix::ffi::OsStrExt;
129 use std::path::Path;
130
131 pub(super) fn rename_noreplace(source: &Path, destination: &Path) -> Option<io::Result<()>> {
133 let (Ok(source), Ok(destination)) = (
134 CString::new(source.as_os_str().as_bytes()),
135 CString::new(destination.as_os_str().as_bytes()),
136 ) else {
137 return Some(Err(io::Error::new(
138 io::ErrorKind::InvalidInput,
139 "path contains a NUL byte",
140 )));
141 };
142 let result = unsafe {
144 #[cfg(any(target_os = "linux", target_os = "android"))]
148 {
149 libc::syscall(
150 libc::SYS_renameat2,
151 libc::AT_FDCWD,
152 source.as_ptr(),
153 libc::AT_FDCWD,
154 destination.as_ptr(),
155 libc::RENAME_NOREPLACE,
156 ) as libc::c_int
157 }
158 #[cfg(target_vendor = "apple")]
159 {
160 libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL)
161 }
162 };
163 if result == 0 {
164 return Some(Ok(()));
165 }
166 let error = io::Error::last_os_error();
167 match error.raw_os_error() {
168 Some(code)
169 if code == libc::ENOSYS
170 || code == libc::EINVAL
171 || code == libc::ENOTSUP
172 || code == libc::EOPNOTSUPP =>
173 {
174 None
175 }
176 _ => Some(Err(error)),
177 }
178 }
179}
180
181#[cfg(windows)]
182mod platform {
183 use std::io;
184 use std::os::windows::ffi::OsStrExt;
185 use std::path::Path;
186
187 pub(super) fn wide(path: &Path) -> Vec<u16> {
190 use std::ffi::OsString;
191 use std::path::{Component, Prefix};
192 const LONG: usize = 248;
195 let plain = || path.as_os_str().encode_wide().chain(Some(0)).collect();
196 if path.as_os_str().len() < LONG || !path.is_absolute() {
197 return plain();
198 }
199 let mut verbatim = OsString::new();
202 for component in path.components() {
203 match component {
204 Component::Prefix(prefix) => match prefix.kind() {
205 Prefix::Disk(drive) => {
206 verbatim.push(format!(r"\\?\{}:", char::from(drive)));
207 }
208 Prefix::UNC(server, share) => {
209 verbatim.push(r"\\?\UNC\");
210 verbatim.push(server);
211 verbatim.push(r"\");
212 verbatim.push(share);
213 }
214 Prefix::Verbatim(_)
215 | Prefix::VerbatimDisk(_)
216 | Prefix::VerbatimUNC(..)
217 | Prefix::DeviceNS(_) => return plain(),
218 },
219 Component::RootDir | Component::CurDir => {}
220 Component::Normal(name) => {
221 verbatim.push(r"\");
222 verbatim.push(name);
223 }
224 Component::ParentDir => return plain(),
225 }
226 }
227 verbatim.encode_wide().chain(Some(0)).collect()
228 }
229
230 pub(super) fn rename_noreplace(source: &Path, destination: &Path) -> Option<io::Result<()>> {
231 let (source, destination) = (wide(source), wide(destination));
232 let ok = unsafe {
235 windows_sys::Win32::Storage::FileSystem::MoveFileExW(
236 source.as_ptr(),
237 destination.as_ptr(),
238 0,
239 )
240 };
241 Some(if ok == 0 {
242 Err(io::Error::last_os_error())
243 } else {
244 Ok(())
245 })
246 }
247}
248
249#[cfg(not(any(
250 target_os = "linux",
251 target_os = "android",
252 target_vendor = "apple",
253 windows
254)))]
255mod platform {
256 pub(super) fn rename_noreplace(
257 _source: &std::path::Path,
258 _destination: &std::path::Path,
259 ) -> Option<std::io::Result<()>> {
260 None
261 }
262}
263
264pub(crate) fn remove_path(path: &Path) -> io::Result<()> {
266 match fs::symlink_metadata(path) {
267 Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path),
268 Ok(_) => remove_link_or_file(path),
269 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
270 Err(error) => Err(error),
271 }
272}
273
274fn remove_link_or_file(path: &Path) -> io::Result<()> {
275 #[cfg(windows)]
276 {
277 use std::os::windows::fs::FileTypeExt;
278 if fs::symlink_metadata(path)?.file_type().is_symlink_dir() {
279 return fs::remove_dir(path);
280 }
281 }
282 fs::remove_file(path)
283}
284
285pub(crate) fn symlink(target: &Path, source: &Path, destination: &Path) -> io::Result<()> {
287 #[cfg(unix)]
288 {
289 let _ = source;
290 std::os::unix::fs::symlink(target, destination)
291 }
292 #[cfg(windows)]
293 {
294 if fs::metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
295 std::os::windows::fs::symlink_dir(target, destination)
296 } else {
297 std::os::windows::fs::symlink_file(target, destination)
298 }
299 }
300}
301
302pub(crate) fn staging_dir(sibling: &Path, prefix: &str) -> io::Result<tempfile::TempDir> {
305 let parent = parent_or_current(sibling);
306 let mut builder = tempfile::Builder::new();
307 builder.prefix(prefix);
308 #[cfg(unix)]
309 {
310 use std::os::unix::fs::PermissionsExt;
311 builder.permissions(fs::Permissions::from_mode(0o700));
312 }
313 builder.tempdir_in(parent)
314}
315
316pub const STAGING_PREFIX: &str = ".gravityfile-";
318
319pub(crate) fn parent_or_current(path: &Path) -> &Path {
320 path.parent()
321 .filter(|parent| !parent.as_os_str().is_empty())
322 .unwrap_or(Path::new("."))
323}
324
325pub(crate) fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
328 if let Ok(path) = dunce::canonicalize(path) {
329 return Some(path);
330 }
331 let absolute = if path.is_absolute() {
332 path.to_path_buf()
333 } else {
334 std::env::current_dir().ok()?.join(path)
335 };
336 let mut existing = absolute.as_path();
337 let mut suffix: Vec<OsString> = Vec::new();
338 while let Some(name) = existing.file_name() {
339 suffix.push(name.to_owned());
340 existing = existing.parent()?;
341 if let Ok(mut canonical) = dunce::canonicalize(existing) {
342 canonical.extend(suffix.iter().rev());
343 return Some(canonical);
344 }
345 }
346 None
347}
348
349pub(crate) fn is_within_directory(source: &Path, destination: &Path) -> bool {
352 if !fs::symlink_metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
353 return false;
354 }
355 match (
356 canonicalize_lenient(source),
357 canonicalize_lenient(destination),
358 ) {
359 (Some(source), Some(destination)) => destination.starts_with(source),
360 _ => false,
361 }
362}
363
364pub(crate) fn same_object(a: &Path, b: &Path) -> bool {
367 match (file_id(a), file_id(b)) {
368 (Ok(a), Ok(b)) => a == b,
369 _ => false,
370 }
371}
372
373#[cfg(unix)]
375fn file_id(path: &Path) -> io::Result<(u64, u64)> {
376 use std::os::unix::fs::MetadataExt;
377 let metadata = fs::symlink_metadata(path)?;
378 Ok((metadata.dev(), metadata.ino()))
379}
380
381#[cfg(windows)]
385fn file_id(path: &Path) -> io::Result<(u64, u64)> {
386 use std::os::windows::fs::OpenOptionsExt;
387 use std::os::windows::io::AsRawHandle;
388 use windows_sys::Win32::Storage::FileSystem::{
389 BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
390 GetFileInformationByHandle,
391 };
392 let file = fs::OpenOptions::new()
395 .access_mode(0)
396 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
397 .open(path)?;
398 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
400 if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 {
403 return Err(io::Error::last_os_error());
404 }
405 let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow);
406 Ok((u64::from(info.dwVolumeSerialNumber), index))
407}
408
409#[cfg(not(any(unix, windows)))]
410fn file_id(_path: &Path) -> io::Result<(u64, u64)> {
411 Err(io::Error::new(
412 io::ErrorKind::Unsupported,
413 "file identity is not available on this platform",
414 ))
415}
416
417pub(crate) fn unchanged_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
419 if !after.is_file()
420 || before.len() != after.len()
421 || before.modified().ok() != after.modified().ok()
422 {
423 return false;
424 }
425 #[cfg(unix)]
426 {
427 use std::os::unix::fs::MetadataExt;
428 if before.ino() != after.ino()
429 || before.dev() != after.dev()
430 || before.ctime() != after.ctime()
431 || before.ctime_nsec() != after.ctime_nsec()
432 {
433 return false;
434 }
435 }
436 true
437}
438
439pub(crate) fn resolves_within(root: &Path, path: &Path) -> io::Result<bool> {
446 const MAX_LINKS: usize = 40;
447 let mut resolved = PathBuf::new();
448 let mut pending: Vec<OsString> = Vec::new();
449 push_components(&mut pending, path, &mut resolved);
450 let mut links = 0;
451 while let Some(component) = pending.pop() {
452 if component == ".." {
453 resolved.pop();
454 continue;
455 }
456 let candidate = resolved.join(&component);
457 match fs::symlink_metadata(&candidate) {
458 Ok(metadata) if metadata.file_type().is_symlink() => {
459 links += 1;
460 if links > MAX_LINKS {
461 return Err(io::Error::other("too many levels of symbolic links"));
462 }
463 let target = fs::read_link(&candidate)?;
464 push_components(&mut pending, &target, &mut resolved);
465 }
466 Ok(_) => resolved = candidate,
467 Err(error) if error.kind() == io::ErrorKind::NotFound => resolved = candidate,
468 Err(error) => return Err(error),
469 }
470 }
471 Ok(resolved.starts_with(root))
472}
473
474fn push_components(pending: &mut Vec<OsString>, path: &Path, resolved: &mut PathBuf) {
477 let mut parts = Vec::new();
478 for component in path.components() {
479 match component {
480 Component::Prefix(_) | Component::RootDir => {
481 *resolved = PathBuf::from(component.as_os_str());
482 parts.clear();
483 }
484 Component::CurDir => {}
485 Component::ParentDir => parts.push(OsString::from("..")),
486 Component::Normal(name) => parts.push(name.to_owned()),
487 }
488 }
489 pending.extend(parts.into_iter().rev());
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn rename_noreplace_refuses_existing_destination() {
498 let dir = tempfile::tempdir().unwrap();
499 let (a, b) = (dir.path().join("a"), dir.path().join("b"));
500 fs::write(&a, "a").unwrap();
501 fs::write(&b, "b").unwrap();
502 assert!(matches!(
503 rename_noreplace(&a, &b),
504 Err(RenameError::DestinationExists)
505 ));
506 assert_eq!(fs::read_to_string(&b).unwrap(), "b");
507 fs::remove_file(&b).unwrap();
508 rename_noreplace(&a, &b).unwrap();
509 assert_eq!(fs::read_to_string(&b).unwrap(), "a");
510 }
511
512 #[test]
513 fn fallback_is_exclusive_for_files_and_directories() {
514 let dir = tempfile::tempdir().unwrap();
515 let file = dir.path().join("file");
516 let taken = dir.path().join("taken");
517 fs::write(&file, "data").unwrap();
518 fs::write(&taken, "keep").unwrap();
519 assert!(matches!(
520 rename_noreplace_fallback(&file, &taken),
521 Err(RenameError::DestinationExists)
522 ));
523 rename_noreplace_fallback(&file, &dir.path().join("moved")).unwrap();
524 assert!(!file.exists());
525
526 let tree = dir.path().join("tree");
527 fs::create_dir_all(tree.join("inner")).unwrap();
528 let occupied = dir.path().join("occupied");
529 fs::create_dir(&occupied).unwrap();
530 fs::write(occupied.join("keep"), "x").unwrap();
531 assert!(rename_noreplace_fallback(&tree, &occupied).is_err());
532 assert!(occupied.join("keep").exists());
533 rename_noreplace_fallback(&tree, &dir.path().join("tree2")).unwrap();
534 assert!(dir.path().join("tree2/inner").is_dir());
535 }
536
537 #[test]
538 fn lenient_canonicalization_appends_missing_suffix() {
539 let dir = tempfile::tempdir().unwrap();
540 let root = dunce::canonicalize(dir.path()).unwrap();
541 assert_eq!(
542 canonicalize_lenient(&dir.path().join("missing/child")).unwrap(),
543 root.join("missing/child")
544 );
545 assert!(is_within_directory(dir.path(), &dir.path().join("x/y")));
546 assert!(!is_within_directory(&dir.path().join("x"), dir.path()));
547 }
548
549 #[cfg(unix)]
550 #[test]
551 fn chained_symlinks_are_resolved_like_the_kernel() {
552 let dir = tempfile::tempdir().unwrap();
553 fs::create_dir_all(dir.path().join("root/d")).unwrap();
554 let root = dunce::canonicalize(dir.path().join("root")).unwrap();
555 std::os::unix::fs::symlink("..", root.join("d/l1")).unwrap();
556 std::os::unix::fs::symlink("l1/../..", root.join("d/esc")).unwrap();
559 assert!(resolves_within(&root, &root.join("d/l1")).unwrap());
560 assert!(!resolves_within(&root, &root.join("d/esc")).unwrap());
561 assert!(resolves_within(&root, &root.join("d/missing/file")).unwrap());
562 }
563}