Skip to content

Commit 0bc084b

Browse files
committed
Implement hard linking support (closes #46)
Signed-off-by: Alex Saveau <[email protected]>
1 parent 1590a37 commit 0bc084b

File tree

10 files changed

+108
-33
lines changed

10 files changed

+108
-33
lines changed

Cargo.lock

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ edition.workspace = true
2626
publish = false
2727

2828
[dev-dependencies]
29-
supercilex-tests = { version = "0.4.16", default-features = false }
29+
supercilex-tests = { version = "0.4.17", default-features = false }
3030

3131
[profile.release]
3232
lto = true

cpz/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ cache-size = "0.7.0"
2626
criterion = "0.6.0"
2727
memmap2 = "0.9.5"
2828
rand = "0.9.1"
29-
supercilex-tests = { version = "0.4.16", default-features = false, features = ["clap"] }
29+
supercilex-tests = { version = "0.4.17", default-features = false, features = ["clap"] }
3030
tempfile = "3.20.0"
3131
trycmd = "0.15.9"
3232

cpz/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ Options:
9292
-L, --dereference
9393
Follow symlinks in the files to be copied rather than copying the symlinks themselves
9494

95+
-l, --link
96+
Create hard links instead of copying file data
97+
9598
-h, --help
9699
Print help (use `-h` for a summary)
97100

cpz/command-reference-short.golden

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@ Options:
1111
-t, --reverse-args Reverse the argument order so that it becomes `cpz <TO> <FROM>...`
1212
-L, --dereference Follow symlinks in the files to be copied rather than copying the symlinks
1313
themselves
14+
-l, --link Create hard links instead of copying file data
1415
-h, --help Print help (use `--help` for more detail)
1516
-V, --version Print version

cpz/command-reference.golden

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ Options:
2323
-L, --dereference
2424
Follow symlinks in the files to be copied rather than copying the symlinks themselves
2525

26+
-l, --link
27+
Create hard links instead of copying file data
28+
2629
-h, --help
2730
Print help (use `-h` for a summary)
2831

cpz/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ struct Cpz {
5050
#[cfg_attr(windows, arg(default_value_t = true))]
5151
dereference: bool,
5252

53+
/// Create hard links instead of copying file data
54+
#[arg(short = 'l', long, default_value_t = false)]
55+
#[arg(aliases = ["hard-link"])]
56+
link: bool,
57+
5358
#[arg(short, long, short_alias = '?', global = true)]
5459
#[arg(action = ArgAction::Help, help = "Print help (use `--help` for more detail)")]
5560
#[arg(long_help = "Print help (use `-h` for a summary)")]
@@ -211,6 +216,7 @@ fn copy(
211216
force,
212217
reverse_args,
213218
dereference,
219+
link,
214220
help: _,
215221
}: Cpz,
216222
) -> Result<(), Error> {
@@ -252,6 +258,7 @@ fn copy(
252258
.files($files)
253259
.force(force)
254260
.follow_symlinks(dereference)
261+
.hard_link(link)
255262
.build()
256263
.run()
257264
};

fuc_engine/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,5 @@ remove_dir_all = { version = "1.0.0", features = ["parallel"] }
2828
ftzz = "3.0.0"
2929
io-adapters = "0.4.0"
3030
rstest = { version = "0.25.0", default-features = false }
31-
supercilex-tests = { version = "0.4.16", default-features = false, features = ["api"] }
31+
supercilex-tests = { version = "0.4.17", default-features = false, features = ["api"] }
3232
tempfile = "3.20.0"

fuc_engine/src/ops/copy.rs

Lines changed: 84 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ pub struct CopyOp<
3232
force: bool,
3333
#[builder(default = false)]
3434
follow_symlinks: bool,
35+
#[builder(default = false)]
36+
hard_link: bool,
3537
#[builder(skip)]
3638
_marker1: PhantomData<&'a I1>,
3739
#[builder(skip)]
@@ -52,7 +54,7 @@ impl<
5254
///
5355
/// Returns the underlying I/O errors that occurred.
5456
pub fn run(self) -> Result<(), Error> {
55-
let copy = compat::copy_impl(self.follow_symlinks);
57+
let copy = compat::copy_impl(self.follow_symlinks, self.hard_link);
5658
let result = schedule_copies(self, &copy);
5759
copy.finish().and(result)
5860
}
@@ -73,6 +75,7 @@ fn schedule_copies<
7375
files,
7476
force,
7577
follow_symlinks,
78+
hard_link,
7679
_marker1: _,
7780
_marker2: _,
7881
}: CopyOp<'a, 'b, I1, I2, F>,
@@ -119,8 +122,20 @@ fn schedule_copies<
119122
Err(e) if e.kind() == io::ErrorKind::NotFound => (),
120123
r => r.map_io_err(|| format!("Failed to remove existing file: {to:?}"))?,
121124
}
122-
std::os::unix::fs::symlink(link, &to)
123-
.map_io_err(|| format!("Failed to create symlink: {to:?}"))?;
125+
if hard_link {
126+
fs::hard_link(&link, &to)
127+
.map_io_err(|| format!("Failed to create hard link: {to:?} -> {link:?}"))?;
128+
} else {
129+
std::os::unix::fs::symlink(&link, &to)
130+
.map_io_err(|| format!("Failed to create symlink: {to:?} -> {link:?}"))?;
131+
}
132+
} else if hard_link {
133+
match fs::remove_file(&to) {
134+
Err(e) if e.kind() == io::ErrorKind::NotFound => (),
135+
r => r.map_io_err(|| format!("Failed to remove existing file: {to:?}"))?,
136+
}
137+
fs::hard_link(&from, &to)
138+
.map_io_err(|| format!("Failed to create hard link: {to:?} -> {from:?}"))?;
124139
} else {
125140
fs::copy(&from, &to).map_io_err(|| format!("Failed to copy file: {from:?}"))?;
126141
}
@@ -156,8 +171,8 @@ mod compat {
156171
use crossbeam_channel::{Receiver, Sender};
157172
use rustix::{
158173
fs::{
159-
AtFlags, CWD, FileType, Mode, OFlags, RawDir, StatxFlags, copy_file_range, mkdirat,
160-
openat, readlinkat, statx, symlinkat,
174+
AtFlags, CWD, FileType, Mode, OFlags, RawDir, StatxFlags, copy_file_range, linkat,
175+
mkdirat, openat, readlinkat, statx, symlinkat,
161176
},
162177
io::Errno,
163178
thread::{UnshareFlags, unshare},
@@ -174,12 +189,17 @@ mod compat {
174189

175190
pub fn copy_impl<'a, 'b>(
176191
follow_symlinks: bool,
192+
hard_link: bool,
177193
) -> impl DirectoryOp<(Cow<'a, Path>, Cow<'b, Path>)> {
178194
let scheduling = LazyCell::new(move || {
179195
let (tx, rx) = crossbeam_channel::unbounded();
180196
(
181197
tx,
182-
thread::spawn(move || root_worker_thread(rx, follow_symlinks)),
198+
if hard_link {
199+
thread::spawn(move || root_worker_thread::<true>(rx, follow_symlinks))
200+
} else {
201+
thread::spawn(move || root_worker_thread::<false>(rx, follow_symlinks))
202+
},
183203
)
184204
});
185205

@@ -221,7 +241,10 @@ mod compat {
221241
}
222242

223243
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(tasks)))]
224-
fn root_worker_thread(tasks: Receiver<TreeNode>, follow_symlinks: bool) -> Result<(), Error> {
244+
fn root_worker_thread<const HARD_LINK: bool>(
245+
tasks: Receiver<TreeNode>,
246+
follow_symlinks: bool,
247+
) -> Result<(), Error> {
225248
unshare_files()?;
226249

227250
let mut available_parallelism = thread::available_parallelism()
@@ -265,13 +288,19 @@ mod compat {
265288
available_parallelism -= 1;
266289
threads.push(scope.spawn({
267290
let tasks = tasks.clone();
268-
move || worker_thread(tasks, root_to_inode, follow_symlinks)
291+
move || {
292+
worker_thread::<HARD_LINK>(
293+
tasks,
294+
root_to_inode,
295+
follow_symlinks,
296+
)
297+
}
269298
}));
270299
}
271300
};
272301
maybe_spawn();
273302

274-
copy_dir(
303+
copy_dir::<HARD_LINK>(
275304
node,
276305
root_to_inode,
277306
follow_symlinks,
@@ -290,7 +319,7 @@ mod compat {
290319
}
291320

292321
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(tasks)))]
293-
fn worker_thread(
322+
fn worker_thread<const HARD_LINK: bool>(
294323
tasks: Receiver<TreeNode>,
295324
root_to_inode: u64,
296325
follow_symlinks: bool,
@@ -300,7 +329,7 @@ mod compat {
300329
let mut buf = [MaybeUninit::<u8>::uninit(); 8192];
301330
let symlink_buf_cache = Cell::new(Vec::new());
302331
for node in tasks {
303-
copy_dir(
332+
copy_dir::<HARD_LINK>(
304333
node,
305334
root_to_inode,
306335
follow_symlinks,
@@ -339,7 +368,7 @@ mod compat {
339368
feature = "tracing",
340369
tracing::instrument(level = "info", skip(messages, buf, symlink_buf_cache, maybe_spawn))
341370
)]
342-
fn copy_dir(
371+
fn copy_dir<const HARD_LINK: bool>(
343372
TreeNode { from, to, messages }: TreeNode,
344373
root_to_inode: u64,
345374
follow_symlinks: bool,
@@ -402,6 +431,20 @@ mod compat {
402431
messages: messages.clone(),
403432
})
404433
.map_err(|_| Error::Internal)?;
434+
} else if HARD_LINK {
435+
let name = file.file_name();
436+
let flags = if follow_symlinks {
437+
AtFlags::SYMLINK_FOLLOW
438+
} else {
439+
AtFlags::empty()
440+
};
441+
linkat(&from_dir, name, &to_dir, name, flags).map_io_err(|| {
442+
format!(
443+
"Failed to create symlink: {:?} -> {:?}",
444+
join_cstr_paths(&to, name),
445+
join_cstr_paths(&from, name),
446+
)
447+
})?;
405448
} else {
406449
copy_one_file(
407450
&from_dir,
@@ -614,8 +657,8 @@ mod compat {
614657

615658
symlinkat(&from_symlink, &to_dir, file_name).map_io_err(|| {
616659
format!(
617-
"Failed to create symlink: {:?}",
618-
join_cstr_paths(to_path, file_name)
660+
"Failed to create symlink: {:?} -> {from_symlink:?}",
661+
join_cstr_paths(to_path, file_name),
619662
)
620663
})?;
621664

@@ -652,18 +695,23 @@ mod compat {
652695

653696
struct Impl {
654697
follow_symlinks: bool,
698+
hard_link: bool,
655699
}
656700

657701
pub fn copy_impl<'a, 'b>(
658702
follow_symlinks: bool,
703+
hard_link: bool,
659704
) -> impl DirectoryOp<(Cow<'a, Path>, Cow<'b, Path>)> {
660-
Impl { follow_symlinks }
705+
Impl {
706+
follow_symlinks,
707+
hard_link,
708+
}
661709
}
662710

663711
impl DirectoryOp<(Cow<'_, Path>, Cow<'_, Path>)> for Impl {
664712
#[cfg_attr(feature = "tracing", tracing::instrument(level = "trace", skip(self)))]
665713
fn run(&self, (from, to): (Cow<Path>, Cow<Path>)) -> Result<(), Error> {
666-
copy_dir(&from, to, self.follow_symlinks, None)
714+
copy_dir(&from, to, self.follow_symlinks, self.hard_link, None)
667715
.map_io_err(|| format!("Failed to copy directory: {from:?}"))
668716
}
669717

@@ -678,6 +726,7 @@ mod compat {
678726
from: P,
679727
to: Q,
680728
follow_symlinks: bool,
729+
hard_link: bool,
681730
root_to_inode: Option<u64>,
682731
) -> Result<(), io::Error> {
683732
let to = to.as_ref();
@@ -713,17 +762,29 @@ mod compat {
713762
};
714763

715764
if file_type.is_dir() {
716-
copy_dir(dir_entry.path(), to, follow_symlinks, root_to_inode)?;
765+
copy_dir(
766+
dir_entry.path(),
767+
to,
768+
follow_symlinks,
769+
hard_link,
770+
root_to_inode,
771+
)?;
717772
} else if file_type.is_symlink() {
718773
let from = fs::read_link(dir_entry.path())?;
719-
#[cfg(unix)]
720-
std::os::unix::fs::symlink(from, to)?;
721-
#[cfg(windows)]
722-
if fs::metadata(&from)?.file_type().is_dir() {
723-
std::os::windows::fs::symlink_dir(from, to)?;
774+
if hard_link {
775+
fs::hard_link(dir_entry.path(), to)?;
724776
} else {
725-
std::os::windows::fs::symlink_file(from, to)?;
777+
#[cfg(unix)]
778+
std::os::unix::fs::symlink(from, to)?;
779+
#[cfg(windows)]
780+
if fs::metadata(&from)?.file_type().is_dir() {
781+
std::os::windows::fs::symlink_dir(from, to)?;
782+
} else {
783+
std::os::windows::fs::symlink_file(from, to)?;
784+
}
726785
}
786+
} else if hard_link {
787+
fs::hard_link(dir_entry.path(), to)?;
727788
} else {
728789
fs::copy(dir_entry.path(), to)?;
729790
}

rmz/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ io-adapters = "0.4.0"
2828
remove_dir_all = { version = "1.0.0", features = ["parallel"] }
2929
rm_og_crappy = { path = "../comparisons/rm_og_crappy" }
3030
rm_rayon = { path = "../comparisons/rm_rayon" }
31-
supercilex-tests = { version = "0.4.16", default-features = false, features = ["clap"] }
31+
supercilex-tests = { version = "0.4.17", default-features = false, features = ["clap"] }
3232
tempfile = "3.20.0"
3333
trycmd = "0.15.9"
3434

0 commit comments

Comments
 (0)