Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 14 additions & 5 deletions cmd/fsfork/fsfork.1
Original file line number Diff line number Diff line change
Expand Up @@ -290,23 +290,32 @@ 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.
.SH SEE ALSO
.BR confine (1),
.BR fsdiff (1),
.BR git (1),
.BR clonefile (2)
.BR clonefile (2),
.BR copyfile (3)
55 changes: 55 additions & 0 deletions cmd/fsfork/tests/fsfork.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ use std::{
path::{Path, PathBuf},
process::{self, Command, Output, Stdio},
ptr, thread,
time::{Duration, SystemTime},
};

#[test]
Expand Down Expand Up @@ -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();
Expand Down
238 changes: 215 additions & 23 deletions fsfork/darwin.rs
Original file line number Diff line number Diff line change
@@ -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:
//!
//! - <https://developer.apple.com/forums/thread/784446>
//! - <https://developer.apple.com/forums/thread/818372>
//!
//! 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<bool> {
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<io::Error>,
/// 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<CString> {
Ok(CString::new(path.as_os_str().as_bytes())?)
}
Loading