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"))]
145 {
146 libc::renameat2(
147 libc::AT_FDCWD,
148 source.as_ptr(),
149 libc::AT_FDCWD,
150 destination.as_ptr(),
151 libc::RENAME_NOREPLACE,
152 )
153 }
154 #[cfg(target_vendor = "apple")]
155 {
156 libc::renamex_np(source.as_ptr(), destination.as_ptr(), libc::RENAME_EXCL)
157 }
158 };
159 if result == 0 {
160 return Some(Ok(()));
161 }
162 let error = io::Error::last_os_error();
163 match error.raw_os_error() {
164 Some(code)
165 if code == libc::ENOSYS
166 || code == libc::EINVAL
167 || code == libc::ENOTSUP
168 || code == libc::EOPNOTSUPP =>
169 {
170 None
171 }
172 _ => Some(Err(error)),
173 }
174 }
175}
176
177#[cfg(windows)]
178mod platform {
179 use std::io;
180 use std::os::windows::ffi::OsStrExt;
181 use std::path::Path;
182
183 pub(super) fn wide(path: &Path) -> Vec<u16> {
186 use std::ffi::OsString;
187 use std::path::{Component, Prefix};
188 const LONG: usize = 248;
191 let plain = || path.as_os_str().encode_wide().chain(Some(0)).collect();
192 if path.as_os_str().len() < LONG || !path.is_absolute() {
193 return plain();
194 }
195 let mut verbatim = OsString::new();
198 for component in path.components() {
199 match component {
200 Component::Prefix(prefix) => match prefix.kind() {
201 Prefix::Disk(drive) => {
202 verbatim.push(format!(r"\\?\{}:", char::from(drive)));
203 }
204 Prefix::UNC(server, share) => {
205 verbatim.push(r"\\?\UNC\");
206 verbatim.push(server);
207 verbatim.push(r"\");
208 verbatim.push(share);
209 }
210 Prefix::Verbatim(_)
211 | Prefix::VerbatimDisk(_)
212 | Prefix::VerbatimUNC(..)
213 | Prefix::DeviceNS(_) => return plain(),
214 },
215 Component::RootDir | Component::CurDir => {}
216 Component::Normal(name) => {
217 verbatim.push(r"\");
218 verbatim.push(name);
219 }
220 Component::ParentDir => return plain(),
221 }
222 }
223 verbatim.encode_wide().chain(Some(0)).collect()
224 }
225
226 pub(super) fn rename_noreplace(source: &Path, destination: &Path) -> Option<io::Result<()>> {
227 let (source, destination) = (wide(source), wide(destination));
228 let ok = unsafe {
231 windows_sys::Win32::Storage::FileSystem::MoveFileExW(
232 source.as_ptr(),
233 destination.as_ptr(),
234 0,
235 )
236 };
237 Some(if ok == 0 {
238 Err(io::Error::last_os_error())
239 } else {
240 Ok(())
241 })
242 }
243}
244
245#[cfg(not(any(
246 target_os = "linux",
247 target_os = "android",
248 target_vendor = "apple",
249 windows
250)))]
251mod platform {
252 pub(super) fn rename_noreplace(
253 _source: &std::path::Path,
254 _destination: &std::path::Path,
255 ) -> Option<std::io::Result<()>> {
256 None
257 }
258}
259
260pub(crate) fn remove_path(path: &Path) -> io::Result<()> {
262 match fs::symlink_metadata(path) {
263 Ok(metadata) if metadata.is_dir() => fs::remove_dir_all(path),
264 Ok(_) => remove_link_or_file(path),
265 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
266 Err(error) => Err(error),
267 }
268}
269
270fn remove_link_or_file(path: &Path) -> io::Result<()> {
271 #[cfg(windows)]
272 {
273 use std::os::windows::fs::FileTypeExt;
274 if fs::symlink_metadata(path)?.file_type().is_symlink_dir() {
275 return fs::remove_dir(path);
276 }
277 }
278 fs::remove_file(path)
279}
280
281pub(crate) fn symlink(target: &Path, source: &Path, destination: &Path) -> io::Result<()> {
283 #[cfg(unix)]
284 {
285 let _ = source;
286 std::os::unix::fs::symlink(target, destination)
287 }
288 #[cfg(windows)]
289 {
290 if fs::metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
291 std::os::windows::fs::symlink_dir(target, destination)
292 } else {
293 std::os::windows::fs::symlink_file(target, destination)
294 }
295 }
296}
297
298pub(crate) fn staging_dir(sibling: &Path, prefix: &str) -> io::Result<tempfile::TempDir> {
301 let parent = parent_or_current(sibling);
302 let mut builder = tempfile::Builder::new();
303 builder.prefix(prefix);
304 #[cfg(unix)]
305 {
306 use std::os::unix::fs::PermissionsExt;
307 builder.permissions(fs::Permissions::from_mode(0o700));
308 }
309 builder.tempdir_in(parent)
310}
311
312pub const STAGING_PREFIX: &str = ".gravityfile-";
314
315pub(crate) fn parent_or_current(path: &Path) -> &Path {
316 path.parent()
317 .filter(|parent| !parent.as_os_str().is_empty())
318 .unwrap_or(Path::new("."))
319}
320
321pub(crate) fn canonicalize_lenient(path: &Path) -> Option<PathBuf> {
324 if let Ok(path) = dunce::canonicalize(path) {
325 return Some(path);
326 }
327 let absolute = if path.is_absolute() {
328 path.to_path_buf()
329 } else {
330 std::env::current_dir().ok()?.join(path)
331 };
332 let mut existing = absolute.as_path();
333 let mut suffix: Vec<OsString> = Vec::new();
334 while let Some(name) = existing.file_name() {
335 suffix.push(name.to_owned());
336 existing = existing.parent()?;
337 if let Ok(mut canonical) = dunce::canonicalize(existing) {
338 canonical.extend(suffix.iter().rev());
339 return Some(canonical);
340 }
341 }
342 None
343}
344
345pub(crate) fn is_within_directory(source: &Path, destination: &Path) -> bool {
348 if !fs::symlink_metadata(source).is_ok_and(|metadata| metadata.is_dir()) {
349 return false;
350 }
351 match (
352 canonicalize_lenient(source),
353 canonicalize_lenient(destination),
354 ) {
355 (Some(source), Some(destination)) => destination.starts_with(source),
356 _ => false,
357 }
358}
359
360pub(crate) fn same_object(a: &Path, b: &Path) -> bool {
363 match (file_id(a), file_id(b)) {
364 (Ok(a), Ok(b)) => a == b,
365 _ => false,
366 }
367}
368
369#[cfg(unix)]
371fn file_id(path: &Path) -> io::Result<(u64, u64)> {
372 use std::os::unix::fs::MetadataExt;
373 let metadata = fs::symlink_metadata(path)?;
374 Ok((metadata.dev(), metadata.ino()))
375}
376
377#[cfg(windows)]
381fn file_id(path: &Path) -> io::Result<(u64, u64)> {
382 use std::os::windows::fs::OpenOptionsExt;
383 use std::os::windows::io::AsRawHandle;
384 use windows_sys::Win32::Storage::FileSystem::{
385 BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
386 GetFileInformationByHandle,
387 };
388 let file = fs::OpenOptions::new()
391 .access_mode(0)
392 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
393 .open(path)?;
394 let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
396 if unsafe { GetFileInformationByHandle(file.as_raw_handle() as _, &mut info) } == 0 {
399 return Err(io::Error::last_os_error());
400 }
401 let index = (u64::from(info.nFileIndexHigh) << 32) | u64::from(info.nFileIndexLow);
402 Ok((u64::from(info.dwVolumeSerialNumber), index))
403}
404
405#[cfg(not(any(unix, windows)))]
406fn file_id(_path: &Path) -> io::Result<(u64, u64)> {
407 Err(io::Error::new(
408 io::ErrorKind::Unsupported,
409 "file identity is not available on this platform",
410 ))
411}
412
413pub(crate) fn unchanged_file(before: &fs::Metadata, after: &fs::Metadata) -> bool {
415 if !after.is_file()
416 || before.len() != after.len()
417 || before.modified().ok() != after.modified().ok()
418 {
419 return false;
420 }
421 #[cfg(unix)]
422 {
423 use std::os::unix::fs::MetadataExt;
424 if before.ino() != after.ino()
425 || before.dev() != after.dev()
426 || before.ctime() != after.ctime()
427 || before.ctime_nsec() != after.ctime_nsec()
428 {
429 return false;
430 }
431 }
432 true
433}
434
435pub(crate) fn resolves_within(root: &Path, path: &Path) -> io::Result<bool> {
442 const MAX_LINKS: usize = 40;
443 let mut resolved = PathBuf::new();
444 let mut pending: Vec<OsString> = Vec::new();
445 push_components(&mut pending, path, &mut resolved);
446 let mut links = 0;
447 while let Some(component) = pending.pop() {
448 if component == ".." {
449 resolved.pop();
450 continue;
451 }
452 let candidate = resolved.join(&component);
453 match fs::symlink_metadata(&candidate) {
454 Ok(metadata) if metadata.file_type().is_symlink() => {
455 links += 1;
456 if links > MAX_LINKS {
457 return Err(io::Error::other("too many levels of symbolic links"));
458 }
459 let target = fs::read_link(&candidate)?;
460 push_components(&mut pending, &target, &mut resolved);
461 }
462 Ok(_) => resolved = candidate,
463 Err(error) if error.kind() == io::ErrorKind::NotFound => resolved = candidate,
464 Err(error) => return Err(error),
465 }
466 }
467 Ok(resolved.starts_with(root))
468}
469
470fn push_components(pending: &mut Vec<OsString>, path: &Path, resolved: &mut PathBuf) {
473 let mut parts = Vec::new();
474 for component in path.components() {
475 match component {
476 Component::Prefix(_) | Component::RootDir => {
477 *resolved = PathBuf::from(component.as_os_str());
478 parts.clear();
479 }
480 Component::CurDir => {}
481 Component::ParentDir => parts.push(OsString::from("..")),
482 Component::Normal(name) => parts.push(name.to_owned()),
483 }
484 }
485 pending.extend(parts.into_iter().rev());
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491
492 #[test]
493 fn rename_noreplace_refuses_existing_destination() {
494 let dir = tempfile::tempdir().unwrap();
495 let (a, b) = (dir.path().join("a"), dir.path().join("b"));
496 fs::write(&a, "a").unwrap();
497 fs::write(&b, "b").unwrap();
498 assert!(matches!(
499 rename_noreplace(&a, &b),
500 Err(RenameError::DestinationExists)
501 ));
502 assert_eq!(fs::read_to_string(&b).unwrap(), "b");
503 fs::remove_file(&b).unwrap();
504 rename_noreplace(&a, &b).unwrap();
505 assert_eq!(fs::read_to_string(&b).unwrap(), "a");
506 }
507
508 #[test]
509 fn fallback_is_exclusive_for_files_and_directories() {
510 let dir = tempfile::tempdir().unwrap();
511 let file = dir.path().join("file");
512 let taken = dir.path().join("taken");
513 fs::write(&file, "data").unwrap();
514 fs::write(&taken, "keep").unwrap();
515 assert!(matches!(
516 rename_noreplace_fallback(&file, &taken),
517 Err(RenameError::DestinationExists)
518 ));
519 rename_noreplace_fallback(&file, &dir.path().join("moved")).unwrap();
520 assert!(!file.exists());
521
522 let tree = dir.path().join("tree");
523 fs::create_dir_all(tree.join("inner")).unwrap();
524 let occupied = dir.path().join("occupied");
525 fs::create_dir(&occupied).unwrap();
526 fs::write(occupied.join("keep"), "x").unwrap();
527 assert!(rename_noreplace_fallback(&tree, &occupied).is_err());
528 assert!(occupied.join("keep").exists());
529 rename_noreplace_fallback(&tree, &dir.path().join("tree2")).unwrap();
530 assert!(dir.path().join("tree2/inner").is_dir());
531 }
532
533 #[test]
534 fn lenient_canonicalization_appends_missing_suffix() {
535 let dir = tempfile::tempdir().unwrap();
536 let root = dunce::canonicalize(dir.path()).unwrap();
537 assert_eq!(
538 canonicalize_lenient(&dir.path().join("missing/child")).unwrap(),
539 root.join("missing/child")
540 );
541 assert!(is_within_directory(dir.path(), &dir.path().join("x/y")));
542 assert!(!is_within_directory(&dir.path().join("x"), dir.path()));
543 }
544
545 #[cfg(unix)]
546 #[test]
547 fn chained_symlinks_are_resolved_like_the_kernel() {
548 let dir = tempfile::tempdir().unwrap();
549 fs::create_dir_all(dir.path().join("root/d")).unwrap();
550 let root = dunce::canonicalize(dir.path().join("root")).unwrap();
551 std::os::unix::fs::symlink("..", root.join("d/l1")).unwrap();
552 std::os::unix::fs::symlink("l1/../..", root.join("d/esc")).unwrap();
555 assert!(resolves_within(&root, &root.join("d/l1")).unwrap());
556 assert!(!resolves_within(&root, &root.join("d/esc")).unwrap());
557 assert!(resolves_within(&root, &root.join("d/missing/file")).unwrap());
558 }
559}