diff --git a/README.md b/README.md index df014a4..646db82 100644 --- a/README.md +++ b/README.md @@ -106,8 +106,10 @@ included, but for what `.gitignore` files ignore and git does not track, such as `target` or `node_modules`, so that a large directory is still quick to fork. Builds in the fork start cold; with `--no-ignore`, for `fsfork` and `confine`, what is ignored is forked too, and they start warm. On macOS, it is -made with `clonefile`, and on Linux with `cp --reflink`, so it takes no -space until either side is written to. +made with `copyfile`, which clones each file, and on Linux with +`cp --reflink`, so it takes no space until either side is written to. It is +made file by file, so what is written to the directory while it is forked +may be in the fork or not, each file by itself. `fsfork` asks git what is ignored, in a repository of its own, as `fsdiff` does, so the directory's configuration cannot make it run anything. If git diff --git a/cmd/fsfork/fsfork.1 b/cmd/fsfork/fsfork.1 index 61027e8..5b2e9dc 100644 --- a/cmd/fsfork/fsfork.1 +++ b/cmd/fsfork/fsfork.1 @@ -290,18 +290,26 @@ Fork the working directory with its build output, and go there: .RE .SH CAVEATS On macOS, the fork is made with -.BR clonefile (2), -which needs APFS, and the fork on the same volume as the directory. +.BR copyfile (3), +which clones each file in the directory, rather than with +.BR clonefile (2) +on the directory at once, as that holds off every change to the directory +until it is done, which its manual page strongly discourages. +It needs APFS to take no space, and the fork on the same volume as the +directory. On Linux, it is made with .B cp \-\-reflink=auto and takes no space only on a file system that can share blocks between files, such as Btrfs or XFS; elsewhere, it is a full copy. .PP +The fork is made file by file, so what is written to the directory while it +is forked may be in the fork or not, each file by itself. +.PP Hard links in the directory are not hard links in the fork. .PP A directory with something ignored in it is made anew, with its permissions -and modification time, but not its other attributes, and on macOS, sockets -and devices beside what is ignored are left out. +and modification time, but not its other attributes. +On macOS, sockets and devices are left out, and FIFOs made anew. .PP The fork is at another path than the directory, so tools that remember absolute paths, such as build caches, may rebuild in it. @@ -309,4 +317,5 @@ absolute paths, such as build caches, may rebuild in it. .BR confine (1), .BR fsdiff (1), .BR git (1), -.BR clonefile (2) +.BR clonefile (2), +.BR copyfile (3) diff --git a/cmd/fsfork/tests/fsfork.rs b/cmd/fsfork/tests/fsfork.rs index 2f23f2a..b01e300 100644 --- a/cmd/fsfork/tests/fsfork.rs +++ b/cmd/fsfork/tests/fsfork.rs @@ -15,6 +15,7 @@ use std::{ path::{Path, PathBuf}, process::{self, Command, Output, Stdio}, ptr, thread, + time::{Duration, SystemTime}, }; #[test] @@ -257,6 +258,60 @@ fn copies_everything_in_the_directory_as_it_is_with_no_ignore() { assert_eq!(fs::read_link(fork.join("link")).unwrap(), Path::new("a")); } +#[test] +fn copies_fifos_links_and_modification_times_deep_in_the_directory() { + let world = World::new(); + let dir = world.project(); + write(&dir, "sub/deeper/file", "deep\n"); + let made = Command::new("mkfifo") + .arg(dir.join("sub/deeper/pipe")) + .status() + .unwrap(); + assert!(made.success()); + // The path to a socket is limited in length, which the world's exceeds. + let short = env::temp_dir().join(format!("fsfork-test-deep.{}", process::id())); + symlink(dir.join("sub"), &short).unwrap(); + let socket = UnixListener::bind(short.join("sock")); + fs::remove_file(&short).unwrap(); + let _socket = socket.unwrap(); + symlink("nowhere", dir.join("sub/dangling")).unwrap(); + symlink("nowhere", dir.join("dangling")).unwrap(); + let then = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000); + for sub in ["sub/deeper", "sub"] { + File::open(dir.join(sub)) + .unwrap() + .set_modified(then) + .unwrap(); + } + let out = fsfork(&dir, &["--no-ignore"]); + assert!(out.status.success(), "{}", stderr(&out)); + for tree in [world.path("project.1"), world.path(".project.1.base")] { + assert_eq!(read(&tree, "sub/deeper/file"), "deep\n"); + let pipe = tree.join("sub/deeper/pipe").symlink_metadata().unwrap(); + assert!(pipe.file_type().is_fifo()); + // On Linux, `cp -a` makes sockets anew. + if cfg!(target_os = "macos") { + assert!(tree.join("sub/sock").symlink_metadata().is_err()); + } + for link in ["sub/dangling", "dangling"] { + assert_eq!( + fs::read_link(tree.join(link)).unwrap(), + Path::new("nowhere") + ); + } + for sub in ["sub/deeper", "sub"] { + let modified = fs::metadata(tree.join(sub)).unwrap().modified().unwrap(); + assert_eq!(modified, then, "{sub}"); + } + } + let mut left: Vec<_> = fs::read_dir(&world.dir) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + left.sort(); + assert_eq!(left, [".project.1.base", "project", "project.1"]); +} + #[test] fn leaves_out_what_gitignore_files_ignore() { let world = World::new(); diff --git a/fsfork/darwin.rs b/fsfork/darwin.rs index 2741e6d..048aef9 100644 --- a/fsfork/darwin.rs +++ b/fsfork/darwin.rs @@ -1,47 +1,239 @@ -//! macOS: a directory is cloned with `clonefile`. +//! macOS: a directory is cloned with `copyfile`, which clones each file in +//! it. +//! +//! `clonefile` can clone a whole directory in one call, but its man page +//! says "Cloning directories with these functions is strongly discouraged. +//! Use copyfile(3) to clone directories instead.", as cloning a directory +//! holds off every change to it until the clone is done, which on a large +//! directory, or one the system uses, can stall the system, and in the worst +//! case panic the kernel: +//! +//! - +//! - +//! +//! So the directory is cloned file by file, and something written to it +//! meanwhile may be in the clone or not, each file by itself. use std::{ - ffi::{CString, OsString}, - io, + ffi::{CStr, CString, OsStr, OsString}, + fs, io, os::unix::{ ffi::OsStrExt, fs::{FileTypeExt, MetadataExt}, }, - path::Path, + path::{Path, PathBuf}, + process, + time::SystemTime, }; -/// Copies symbolic links rather than what they point to. -const CLONE_NOFOLLOW: u32 = 0x0001; +use libc::{c_char, c_int, c_void, copyfile_state_t}; /// Clones the directory tree at `from` to `to`, which must not exist yet. /// The clone shares its blocks with the original until either is written. +/// +/// The clone is made next to `to` and moved there when done, so that `to` +/// is never there half made, and fails if `to` exists, as `copyfile` would +/// copy into it instead. pub fn clone(from: &Path, to: &Path) -> io::Result<()> { - let from = CString::new(from.as_os_str().as_bytes())?; - let to = CString::new(to.as_os_str().as_bytes())?; - if unsafe { libc::clonefile(from.as_ptr(), to.as_ptr(), CLONE_NOFOLLOW) } != 0 { - return Err(io::Error::last_os_error()); + same_volume(from, to)?; + let part = part_of(to); + let made = copy(from, &part, true).and_then(|()| rename_new(&part, to)); + if made.is_err() { + let _ = fs::remove_dir_all(&part); } - Ok(()) + made } /// Clones `names`, entries of the directory `from`, into the directory `to`. /// -/// `clonefile` takes these in a directory, but not by themselves: FIFOs are -/// made anew, and sockets and devices left out, as nothing is listening on -/// or behind a clone of them. +/// FIFOs are made anew, and sockets and devices left out, as nothing is +/// listening on or behind a clone of them, and an entry gone since it was +/// listed is left out. pub fn clone_entries(from: &Path, names: &[OsString], to: &Path) -> io::Result<()> { for name in names { let (from, to) = (from.join(name), to.join(name)); - let meta = from.symlink_metadata()?; - let kind = meta.file_type(); - if kind.is_fifo() { - let to = CString::new(to.as_os_str().as_bytes())?; - if unsafe { libc::mkfifo(to.as_ptr(), meta.mode() as libc::mode_t) } != 0 { - return Err(io::Error::last_os_error()); - } - } else if !(kind.is_socket() || kind.is_block_device() || kind.is_char_device()) { - clone(&from, &to)?; + let meta = match from.symlink_metadata() { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + }; + if !special(&meta, &to)? { + copy(&from, &to, meta.is_dir())?; + } + } + Ok(()) +} + +/// Fails as `clonefile` would if `to` is on another volume than `from`, +/// where `copyfile` would copy every file rather than clone it. +fn same_volume(from: &Path, to: &Path) -> io::Result<()> { + let parent = to.parent().unwrap_or(Path::new(".")); + if from.metadata()?.dev() != parent.metadata()?.dev() { + return Err(io::Error::from_raw_os_error(libc::EXDEV)); + } + Ok(()) +} + +/// Where `to` is made before it is moved there: hidden next to it, and +/// named after this process, so that two that make the same `to` at once do +/// not make it in the same place. +fn part_of(to: &Path) -> PathBuf { + let mut name = OsString::from("."); + name.push(to.file_name().unwrap_or_default()); + name.push(format!(".{}.part", process::id())); + to.with_file_name(name) +} + +/// Moves `from` to `to`, failing if `to` exists. +fn rename_new(from: &Path, to: &Path) -> io::Result<()> { + let (from, to) = (cstring(from)?, cstring(to)?); + if unsafe { libc::renamex_np(from.as_ptr(), to.as_ptr(), libc::RENAME_EXCL) } != 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) +} + +/// Makes a FIFO at `to` like the one `meta` is of, and leaves sockets and +/// devices out. Says whether it was one of them. +fn special(meta: &fs::Metadata, to: &Path) -> io::Result { + let kind = meta.file_type(); + if kind.is_fifo() { + let to = cstring(to)?; + if unsafe { libc::mkfifo(to.as_ptr(), meta.mode() as libc::mode_t) } != 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(kind.is_fifo() || kind.is_socket() || kind.is_block_device() || kind.is_char_device()) +} + +/// What a copy is at, for its callback. +#[derive(Default)] +struct Copying { + /// Why it stopped, if it did. + failed: Option, + /// The directories made, and when those they were made from were + /// modified, which they are set to once all in them is made. + dirs: Vec<(PathBuf, SystemTime)>, +} + +/// Clones `from` to `to`, which must not exist yet, and if `from` is a +/// directory, all in it. A symbolic link is cloned itself, but only given +/// as a directory's entry if copying recursively, as `copyfile` follows it +/// otherwise. +fn copy(from: &Path, to: &Path, dir: bool) -> io::Result<()> { + let (src, dst) = (cstring(from)?, cstring(to)?); + let mut copying = Copying::default(); + let state = unsafe { libc::copyfile_state_alloc() }; + if state.is_null() { + return Err(io::Error::last_os_error()); + } + let callback: extern "C" fn( + c_int, + c_int, + copyfile_state_t, + *const c_char, + *const c_char, + *mut c_void, + ) -> c_int = status; + let copied = unsafe { + libc::copyfile_state_set( + state, + libc::COPYFILE_STATE_STATUS_CB as u32, + callback as *const c_void, + ); + libc::copyfile_state_set( + state, + libc::COPYFILE_STATE_STATUS_CTX as u32, + &mut copying as *mut Copying as *const c_void, + ); + let copied = libc::copyfile( + src.as_ptr(), + dst.as_ptr(), + state, + if dir { + libc::COPYFILE_CLONE | libc::COPYFILE_RECURSIVE + } else { + libc::COPYFILE_CLONE + }, + ); + let e = io::Error::last_os_error(); + libc::copyfile_state_free(state); + if copied == 0 { Ok(()) } else { Err(e) } + }; + if let Some(e) = copying.failed { + return Err(e); + } + copied?; + // Making a symbolic link in a directory changes its modification time, + // and `copyfile` makes them after it sets the directory's. + for (dir, modified) in copying.dirs.iter().rev() { + match fs::File::open(dir).and_then(|dir| dir.set_modified(*modified)) { + Err(e) if e.kind() != io::ErrorKind::NotFound => return Err(e), + _ => {} } } Ok(()) } + +/// `copyfile`'s callback, for each file and directory it copies: makes +/// FIFOs anew, leaves out sockets, devices and what is gone since it was +/// listed, notes the directories made, and stops at anything else that +/// fails. +extern "C" fn status( + what: c_int, + stage: c_int, + _state: copyfile_state_t, + src: *const c_char, + dst: *const c_char, + ctx: *mut c_void, +) -> c_int { + let e = io::Error::last_os_error(); + let copying = unsafe { &mut *(ctx as *mut Copying) }; + let path = |p: *const c_char| -> PathBuf { + if p.is_null() { + return PathBuf::new(); + } + PathBuf::from(OsStr::from_bytes(unsafe { CStr::from_ptr(p) }.to_bytes())) + }; + if stage == libc::COPYFILE_ERR { + if e.kind() == io::ErrorKind::NotFound { + return libc::COPYFILE_SKIP; + } + copying.failed = Some(io::Error::new( + e.kind(), + format!("{}: {e}", path(src).display()), + )); + return libc::COPYFILE_QUIT; + } + if stage != libc::COPYFILE_START { + return libc::COPYFILE_CONTINUE; + } + let (src, dst) = (path(src), path(dst)); + let meta = match src.symlink_metadata() { + Ok(meta) => meta, + Err(e) if e.kind() == io::ErrorKind::NotFound => return libc::COPYFILE_SKIP, + Err(e) => { + copying.failed = Some(io::Error::new(e.kind(), format!("{}: {e}", src.display()))); + return libc::COPYFILE_QUIT; + } + }; + if what == libc::COPYFILE_RECURSE_DIR { + if let Ok(modified) = meta.modified() { + copying.dirs.push((dst, modified)); + } + } else if what == libc::COPYFILE_RECURSE_FILE { + match special(&meta, &dst) { + Ok(true) => return libc::COPYFILE_SKIP, + Ok(false) => {} + Err(e) => { + copying.failed = Some(io::Error::new(e.kind(), format!("{}: {e}", dst.display()))); + return libc::COPYFILE_QUIT; + } + } + } + libc::COPYFILE_CONTINUE +} + +fn cstring(path: &Path) -> io::Result { + Ok(CString::new(path.as_os_str().as_bytes())?) +}