common/link.rs
1use anyhow::{Context, anyhow};
2use async_recursion::async_recursion;
3use std::sync::Arc;
4use tracing::instrument;
5
6use crate::copy;
7use crate::copy::{
8 EmptyDirAction, Settings as CopySettings, Summary as CopySummary, check_empty_dir_cleanup,
9};
10use crate::filecmp;
11use crate::preserve;
12use crate::progress;
13use crate::safedir::Dir;
14use crate::walk::{self, EntryKind, LeafPermit, PermitKind};
15
16/// Error type for link operations. See [`crate::error::OperationError`] for
17/// logging conventions and rationale.
18pub type Error = crate::error::OperationError<Summary>;
19
20#[derive(Debug, Clone)]
21pub struct Settings {
22 pub copy_settings: CopySettings,
23 pub update_compare: filecmp::MetadataCmpSettings,
24 pub update_exclusive: bool,
25 /// filter settings for include/exclude patterns
26 pub filter: Option<crate::filter::FilterSettings>,
27 /// dry-run mode for previewing operations
28 pub dry_run: Option<crate::config::DryRunMode>,
29 /// metadata preservation settings
30 pub preserve: preserve::Settings,
31}
32
33/// Summary with the appropriate `*_skipped` counter set to 1 for the given entry kind.
34/// Special files count as `files_skipped` to match the historical mapping used
35/// when filters skip an entry (`specials_skipped` is reserved for `--skip-specials`).
36fn skipped_summary_for(kind: EntryKind) -> Summary {
37 let copy_summary = match kind {
38 EntryKind::Dir => CopySummary {
39 directories_skipped: 1,
40 ..Default::default()
41 },
42 EntryKind::Symlink => CopySummary {
43 symlinks_skipped: 1,
44 ..Default::default()
45 },
46 EntryKind::File | EntryKind::Special => CopySummary {
47 files_skipped: 1,
48 ..Default::default()
49 },
50 };
51 Summary {
52 copy_summary,
53 ..Default::default()
54 }
55}
56
57#[derive(Copy, Clone, Debug, Default)]
58pub struct Summary {
59 pub hard_links_created: usize,
60 pub hard_links_unchanged: usize,
61 pub copy_summary: CopySummary,
62}
63
64impl std::ops::Add for Summary {
65 type Output = Self;
66 fn add(self, other: Self) -> Self {
67 Self {
68 hard_links_created: self.hard_links_created + other.hard_links_created,
69 hard_links_unchanged: self.hard_links_unchanged + other.hard_links_unchanged,
70 copy_summary: self.copy_summary + other.copy_summary,
71 }
72 }
73}
74
75impl std::fmt::Display for Summary {
76 fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
77 write!(
78 f,
79 "{}\n\
80 link:\n\
81 -----\n\
82 hard-links created: {}\n\
83 hard links unchanged: {}\n",
84 &self.copy_summary, self.hard_links_created, self.hard_links_unchanged
85 )
86 }
87}
88
89/// Hard-link the already-classified source entry (pinned by `src_handle`) to `dst_name` within
90/// `dst_dir`, fd-relative and inode-exact.
91///
92/// `dst_dir.hard_link_handle_at` links the EXACT inode `src_handle` pins (via its `O_PATH` fd,
93/// using `linkat(.., "/proc/self/fd/N", .., AT_SYMLINK_FOLLOW)`) rather than re-resolving the
94/// source by name. This closes a TOCTOU window the old by-name `linkat` had: on an actor-writable
95/// source, `name` could be swapped to a different inode (symlink, FIFO, another file) between
96/// classification and the link, so the by-name link would target the replacement while rlink
97/// reported a hard-linked file. Linking the pinned inode means we either hard-link the exact
98/// regular file we classified or fail closed (`ENOENT` when its last link was removed) — never the
99/// swapped-in replacement. `linkat` still refuses to hard-link a directory (`EPERM`).
100///
101/// On `EEXIST` under `--overwrite`, the existing destination is re-classified through `dst_dir`'s
102/// fd and, if it is an identical hard link (same dev+ino), left as is; otherwise it is removed via
103/// the recheck-guarded [`copy::remove_existing`] and the link is retried — mirroring copy's
104/// fd-relative overwrite branches.
105///
106/// No metadata is applied here, and `f:acl` in particular must never reach this path: a hard-linked
107/// destination SHARES the source's inode, so writing an ACL "to the destination" would rewrite the
108/// SOURCE's permissions. There is nothing to apply anyway — the shared inode already carries the
109/// source's metadata verbatim, which is the whole point of a hard link. `f:acl` therefore applies
110/// only on rlink's real copy path ([`copy::copy_child`], for changed files under `--update`).
111#[instrument(skip(prog_track, settings))]
112#[allow(clippy::too_many_arguments)]
113async fn hard_link_entry_fd(
114 prog_track: &'static progress::Progress,
115 src_handle: &crate::safedir::Handle,
116 dst_dir: &Arc<Dir>,
117 dst_name: &std::ffi::OsStr,
118 dst_path: &std::path::Path,
119 settings: &Settings,
120) -> Result<Summary, Error> {
121 let mut link_summary = Summary::default();
122 match dst_dir.hard_link_handle_at(src_handle, dst_name).await {
123 Ok(()) => {}
124 Err(error)
125 if settings.copy_settings.overwrite
126 && error.kind() == std::io::ErrorKind::AlreadyExists =>
127 {
128 tracing::debug!("'dst' already exists, check if we need to update");
129 let dst_handle = dst_dir
130 .child(dst_name)
131 .await
132 .with_context(|| format!("cannot read {dst_path:?} metadata"))
133 .map_err(|err| Error::new(err, Default::default()))?;
134 // identical hard link: same file type and same (dev, ino) as the source entry. Both
135 // handles pin their inodes (O_PATH), so a matching (dev, ino) genuinely proves the two
136 // names already resolve to the same inode — no change needed.
137 if dst_handle.kind() == src_handle.kind()
138 && dst_handle.dev() == src_handle.dev()
139 && dst_handle.ino() == src_handle.ino()
140 {
141 tracing::debug!("no change, leaving file as is");
142 prog_track.hard_links_unchanged.inc();
143 return Ok(Summary {
144 hard_links_unchanged: 1,
145 ..Default::default()
146 });
147 }
148 tracing::info!("'dst' file type changed, removing and hard-linking");
149 // recheck-guarded, fd-relative removal contained to dst_dir (mirrors copy.rs).
150 let rm_summary = copy::remove_existing(
151 prog_track,
152 dst_dir,
153 dst_name,
154 dst_path,
155 &dst_handle,
156 &settings.copy_settings,
157 )
158 .await
159 .map_err(|err| {
160 link_summary.copy_summary.rm_summary = err.summary.rm_summary;
161 Error::new(err.source, link_summary)
162 })?;
163 link_summary.copy_summary.rm_summary = rm_summary;
164 dst_dir
165 .hard_link_handle_at(src_handle, dst_name)
166 .await
167 .with_context(|| format!("failed to hard link to {dst_path:?}"))
168 .map_err(|err| Error::new(err, link_summary))?;
169 }
170 Err(error) => {
171 return Err(Error::new(
172 anyhow::Error::from(error).context(format!("failed to hard link to {dst_path:?}")),
173 link_summary,
174 ));
175 }
176 }
177 prog_track.hard_links_created.inc();
178 link_summary.hard_links_created = 1;
179 Ok(link_summary)
180}
181
182/// Public entry point for link operations.
183///
184/// The dual-tree link walk is fd-based: the source, optional `update`, and destination roots are
185/// opened relative to their parent directories and every per-entry operation is performed through
186/// file-descriptor-relative syscalls (see [`crate::safedir`]). Hard links are made inode-exact
187/// through the already-classified source `Handle` (`linkat` via `/proc/self/fd/N` with
188/// `AT_SYMLINK_FOLLOW`), so the link targets the exact regular file that was classified, even if
189/// its directory entry is concurrently swapped — never a re-resolved name; entries that must be
190/// copied instead of hard-linked are delegated to `copy::copy_child` with the held parent `Dir`s —
191/// no path is re-resolved from a root. This closes the TOCTOU window the old path-based walk had
192/// between classifying an entry and acting on it. `--dereference` is the one exception — copy still
193/// resolves symlinks by path (`canonicalize`) and is not hardened.
194#[instrument(skip(prog_track, settings))]
195pub async fn link(
196 prog_track: &'static progress::Progress,
197 cwd: &std::path::Path,
198 src: &std::path::Path,
199 dst: &std::path::Path,
200 update: &Option<std::path::PathBuf>,
201 settings: &Settings,
202 is_fresh: bool,
203) -> Result<Summary, Error> {
204 // `cwd` is retained for API/signature parity (callers still pass it) but the fd-based walk
205 // reconstructs every path from the explicit roots, so it is no longer threaded into the walk.
206 let _ = cwd;
207 // A missing --update root is destructive under both --update-exclusive (materialized set =
208 // update set, so nothing materializes) AND --delete (the source-only keep_set makes any dst
209 // entry the missing update tree WOULD have protected look extraneous, and prune wipes it).
210 // In either case `link_internal` hits the recursive early-return / silent `None` fallback
211 // before that destruction would happen, so rlink reports success — silently preserving
212 // stale dst (--update-exclusive) or silently pruning would-be-protected entries (--delete).
213 // Reject at the public entry so a typo'd --update can't quietly do the wrong thing. The
214 // plain "--update without --delete or --update-exclusive" case still falls back to no-update
215 // mode (long-standing behavior), and recursive child-level "update missing" cases stay
216 // handled inside link_internal — they correctly no-op so the parent's prune removes their
217 // dst counterpart per the documented semantics.
218 if let Some(update_path) = update.as_ref() {
219 let destructive = settings.update_exclusive || settings.copy_settings.delete.is_some();
220 if crate::safedir::strict_operand_resolution() {
221 // Under strict operand resolution, validate the update operand prefix UP FRONT
222 // (fd-relative, `openat2(RESOLVE_NO_SYMLINKS)`), unconditionally for ALL --update — so a
223 // plain --update with an excluded source (which returns before the update parent would
224 // otherwise be opened) cannot slip a symlinked update prefix through. A symlink in a
225 // directory component fails closed (ELOOP); this also serves the destructive-mode
226 // existence check below.
227 let kind = crate::safedir::strict_probe_dst_kind(update_path, congestion::Side::Source)
228 .await
229 .map_err(|err| {
230 Error::new(
231 anyhow::Error::new(err).context(format!(
232 "failed reading metadata from update {update_path:?}"
233 )),
234 Default::default(),
235 )
236 })?;
237 if destructive && kind.is_none() {
238 return Err(Error::new(
239 anyhow!(
240 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
241 update_path
242 ),
243 Default::default(),
244 ));
245 }
246 } else if destructive {
247 match crate::walk::run_metadata_probed(
248 congestion::Side::Source,
249 congestion::MetadataOp::Stat,
250 tokio::fs::symlink_metadata(update_path),
251 )
252 .await
253 {
254 Ok(_) => {}
255 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
256 return Err(Error::new(
257 anyhow!(
258 "--update path {:?} does not exist (rejected under --delete or --update-exclusive to avoid silently pruning destination entries the update tree would otherwise have preserved)",
259 update_path
260 ),
261 Default::default(),
262 ));
263 }
264 Err(err) => {
265 return Err(Error::new(
266 anyhow::Error::new(err).context(format!(
267 "failed reading metadata from update {:?}",
268 update_path
269 )),
270 Default::default(),
271 ));
272 }
273 }
274 }
275 }
276 // Source: decompose via the shared helper so `.`/`..` operands (e.g. `rlink . dst`, `rlink
277 // tree/.. dst`) are canonicalized to a real directory + basename instead of being rejected; `/`
278 // is still rejected. (The destination and `--update` operands keep their direct split below.)
279 let src_operand = crate::walk::split_root_operand(src)
280 .await
281 .map_err(|err| Error::new(err, Default::default()))?;
282 let src = src_operand.display.as_path();
283 let src_name = src_operand.name.as_os_str();
284 // under strict operand resolution (--require-toctou-safe) the parent prefix is opened BEFORE
285 // the root filter check, so a symlinked source prefix fails closed even when the root would be
286 // skipped by a filter, and the root is classified fd-relative. On the default path the filter
287 // check keeps the historical path-based `symlink_metadata` classification and runs FIRST: an
288 // excluded root under an execute-only (0111, searchable-not-readable) parent must skip cleanly,
289 // and the O_RDONLY parent open would fail EACCES there.
290 let strict_src_parent: Option<Arc<Dir>> = if crate::safedir::strict_operand_resolution() {
291 let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
292 .await
293 .with_context(|| {
294 format!(
295 "cannot open source parent directory {:?}",
296 src_operand.parent
297 )
298 })
299 .map_err(|err| Error::new(err, Default::default()))?;
300 // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
301 Some(Arc::new(parent.into_tree()))
302 } else {
303 None
304 };
305 // The destination's parent path, split leniently for the up-front strict validation below (a
306 // `.`/`..`/`/` destination has no distinct parent+name; the authoritative split — which rejects
307 // such a destination — runs AFTER the filter, preserving default-mode ordering so a filtered-out
308 // source still skips cleanly). empty parent (a single-component relative path) means ".".
309 let dst_parent_path_opt: Option<&std::path::Path> = match (dst.parent(), dst.file_name()) {
310 (Some(parent), Some(_)) if parent.as_os_str().is_empty() => Some(std::path::Path::new(".")),
311 (Some(parent), Some(_)) => Some(parent),
312 _ => None,
313 };
314 // Under strict operand resolution, resolve the destination parent UP FRONT — before the source
315 // root-filter early-return, unconditionally — so a symlinked destination prefix fails closed in
316 // every mode regardless of filters/flags (see copy::copy for the same pattern). An absent parent
317 // (dry-run previewing into a not-yet-created tree, or a filtered-out root) is not a symlink
318 // violation; only a symlinked prefix component (ELOOP) fails closed here.
319 let strict_dst_parent: Option<Arc<Dir>> = match (
320 crate::safedir::strict_operand_resolution(),
321 dst_parent_path_opt,
322 ) {
323 (true, Some(dst_parent_path)) => {
324 match Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination).await {
325 Ok(parent) => Some(Arc::new(parent.into_tree())),
326 Err(err)
327 if err.kind() == std::io::ErrorKind::NotFound
328 || err.raw_os_error() == Some(libc::ENOTDIR) =>
329 {
330 None
331 }
332 Err(err) => {
333 return Err(Error::new(
334 anyhow::Error::new(err).context(format!(
335 "cannot open destination parent directory {dst_parent_path:?}"
336 )),
337 Default::default(),
338 ));
339 }
340 }
341 }
342 // default mode, or a degenerate destination (rejected by the authoritative split below)
343 _ => None,
344 };
345 // check filter for top-level source (files, directories, and symlinks)
346 if let Some(ref filter) = settings.filter {
347 let (kind, is_dir) = match &strict_src_parent {
348 // strict: classify via the held parent fd (O_NOFOLLOW), never by path
349 Some(parent) => {
350 let root_handle = parent
351 .child(src_name)
352 .await
353 .with_context(|| format!("failed reading metadata from {:?}", &src))
354 .map_err(|err| Error::new(err, Default::default()))?;
355 (root_handle.kind(), root_handle.kind() == EntryKind::Dir)
356 }
357 None => {
358 let src_metadata = crate::walk::run_metadata_probed(
359 congestion::Side::Source,
360 congestion::MetadataOp::Stat,
361 tokio::fs::symlink_metadata(src),
362 )
363 .await
364 .with_context(|| format!("failed reading metadata from {:?}", &src))
365 .map_err(|err| Error::new(err, Default::default()))?;
366 (
367 EntryKind::from_metadata(&src_metadata),
368 src_metadata.is_dir(),
369 )
370 }
371 };
372 let result = filter.should_include_root_item(std::path::Path::new(src_name), is_dir);
373 match result {
374 crate::filter::FilterResult::Included => {}
375 result => {
376 if let Some(mode) = settings.dry_run {
377 crate::dry_run::report_skip(src, &result, mode, kind.label_long());
378 }
379 kind.inc_skipped(prog_track);
380 return Ok(skipped_summary_for(kind));
381 }
382 }
383 }
384 // Authoritative destination split (runs AFTER the filter, preserving default-mode ordering): a
385 // `.`/`..`/`/` destination is not a meaningful link target, and rejecting it avoids clobbering
386 // the cwd. empty parent (a single-component relative path) means the current directory.
387 let (Some(dst_parent_path), Some(_dst_name)) = (dst.parent(), dst.file_name()) else {
388 return Err(Error::new(
389 anyhow!(
390 "link destination {:?} has no parent directory or file name",
391 dst
392 ),
393 Default::default(),
394 ));
395 };
396 let dst_parent_path = if dst_parent_path.as_os_str().is_empty() {
397 std::path::Path::new(".")
398 } else {
399 dst_parent_path
400 };
401 // Open the parent directories of the source, destination, and (optional) update roots for the
402 // walk so each root entry is opened and classified relative to a directory fd — the same
403 // fd-relative path every nested entry takes. The roots are then handed to `link_internal` by
404 // their basenames, exactly like child entries. Under strict operand resolution both parents were
405 // already opened+validated above, so reuse the held fds here.
406 let src_parent = match strict_src_parent {
407 Some(parent) => parent,
408 None => {
409 let parent = Dir::open_parent_dir(&src_operand.parent, congestion::Side::Source)
410 .await
411 .with_context(|| {
412 format!(
413 "cannot open source parent directory {:?}",
414 src_operand.parent
415 )
416 })
417 .map_err(|err| Error::new(err, Default::default()))?;
418 // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below here).
419 Arc::new(parent.into_tree())
420 }
421 };
422 // One `listxattr` on the source ROOT per run — a constant, not a per-entry probe — warning that
423 // a source root carrying an ACL is about to be linked by settings that drop it. WITHOUT
424 // `--update`, files count as already safe whatever `f:acl` says: a hard-linked destination
425 // file IS the source inode, so its ACL cannot be dropped (and must never be written through,
426 // which is why `f:acl` applies only on rlink's real copy path). WITH `--update` that certainty
427 // is gone — a changed file is materialized by COPYING the update-tree entry, which drops its
428 // ACL unless `f:acl` is on — so the file half then defers to the setting like any copy. (The
429 // probe still examines the SOURCE root; whether the update entry differs — and so whether the
430 // copy path actually runs — is not known yet, matching the probe's documented
431 // root-only-heuristic character.)
432 crate::safedir::warn_if_root_acl_unpreserved_at(
433 &src_parent,
434 src_name,
435 src,
436 update.is_none() || settings.preserve.file.acl,
437 settings.preserve.dir.acl,
438 )
439 .await;
440 // In dry-run we never touch the destination, so we don't open its parent at all (it may not
441 // even exist). `dst_parent == None` is the signal throughout the walk that destination
442 // operations must be skipped. In a real link, reuse the strict-validated parent, or open it
443 // following symlinks (default mode; a symlinked destination container is followed into the real
444 // dir).
445 let dst_parent = if settings.dry_run.is_some() {
446 None
447 } else {
448 match strict_dst_parent {
449 Some(parent) => Some(parent),
450 None => {
451 let dir = Dir::open_parent_dir(dst_parent_path, congestion::Side::Destination)
452 .await
453 .with_context(|| {
454 format!(
455 "cannot open destination parent directory {:?}",
456 dst_parent_path
457 )
458 })
459 .map_err(|err| Error::new(err, Default::default()))?;
460 // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below).
461 Some(Arc::new(dir.into_tree()))
462 }
463 }
464 };
465 // The update tree (if present) is rooted at `update`; open its parent and remember the root
466 // basename so `link_internal` can classify it via the held fd. A missing update root is handled
467 // inside `link_internal` (recursive early-return / silent None fallback) exactly as before.
468 //
469 // For plain `--update` (no `--delete`, no `--update-exclusive`) a missing parent is treated the
470 // same as a missing update root: fall back silently to no-update mode. This preserves the long-
471 // standing behavior where `rlink --update /tmp/no/such src dst` (with `/tmp/no` absent) proceeds
472 // by linking from `src` rather than erroring — the existing missing-update-root fallback already
473 // applies to that case; the parent-open merely must not ENOENT-fail before `link_internal` can
474 // apply it. Under `--delete` or `--update-exclusive` the missing-root is already rejected above,
475 // so those cases never reach here with a missing update; any open_parent_dir error there is
476 // unexpected and propagates as before.
477 let update_parent = match update.as_ref() {
478 Some(update_path) => {
479 // decompose the update operand the same way as the source: the update tree is a READ
480 // tree, so `.`/`..`/`dir/..` are meaningful and `split_root_operand` canonicalizes them
481 // (and rejects `/`). This makes `rlink --update . src dst` / `--update tree/.. src dst`
482 // work instead of erroring, matching the source-operand handling. The helper already
483 // normalizes an empty parent to ".", so no `resolve_parent` is needed here.
484 let update_operand = crate::walk::split_root_operand(update_path)
485 .await
486 .map_err(|err| Error::new(err, Default::default()))?;
487 let update_parent_path = update_operand.parent;
488 let update_name = update_operand.name;
489 // the update tree's TRUSTED parent prefix is resolved following symlinks (see the
490 // source parent above): a symlinked update container must be followed into the real dir.
491 let fallback_eligible =
492 !settings.update_exclusive && settings.copy_settings.delete.is_none();
493 match Dir::open_parent_dir(&update_parent_path, congestion::Side::Source).await {
494 // cross from the trusted parent prefix into the hardened tree (O_NOFOLLOW below).
495 Ok(dir) => Some((Arc::new(dir.into_tree()), update_name)),
496 Err(err)
497 if fallback_eligible
498 && (matches!(
499 err.kind(),
500 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
501 ) || err.raw_os_error() == Some(libc::ENOTDIR)) =>
502 {
503 // the update path's parent (or an ancestor) doesn't exist — treat the whole
504 // update tree as absent and fall back to no-update mode, exactly as when the
505 // update ROOT itself is missing (handled inside link_internal).
506 tracing::debug!(
507 "update parent {:?} not found ({:#}); falling back to no-update mode",
508 update_parent_path,
509 err
510 );
511 None
512 }
513 Err(err) => {
514 return Err(Error::new(
515 anyhow::Error::new(err).context(format!(
516 "cannot open update parent directory {:?}",
517 update_parent_path
518 )),
519 Default::default(),
520 ));
521 }
522 }
523 }
524 None => None,
525 };
526 let update_ref = update_parent
527 .as_ref()
528 .map(|(dir, name)| (dir, name.as_os_str()));
529 link_internal(
530 prog_track,
531 &src_parent,
532 update_ref,
533 dst_parent.as_ref(),
534 src_name,
535 src,
536 dst,
537 update.as_deref(),
538 std::path::Path::new(""),
539 settings,
540 is_fresh,
541 None,
542 )
543 .await
544}
545/// Tracks which child names will be materialized at the destination for a single directory
546/// pass, used by `--delete` to decide what to prune. Operations are named after their
547/// semantic intent so the call sites don't repeat the gating conditions (delete-on-vs-off,
548/// `--update-exclusive` carve-out, skip-special-vs-real materialization).
549///
550/// When `--delete` is off the inner set is `None` and every method is a no-op — zero heap
551/// cost in the hot path.
552struct DeleteKeepSet {
553 inner: Option<std::collections::HashSet<std::ffi::OsString>>,
554 /// Under `--update-exclusive` with an active update tree, the source loop must NOT
555 /// register source-only entries — only the update set materializes.
556 src_records_disabled: bool,
557}
558
559impl DeleteKeepSet {
560 fn new(
561 delete: Option<©::DeleteSettings>,
562 update_exclusive: bool,
563 update_present: bool,
564 ) -> Self {
565 Self {
566 inner: delete.is_some().then(std::collections::HashSet::new),
567 src_records_disabled: update_exclusive && update_present,
568 }
569 }
570 /// Source loop: this src entry passed the filter. Called even when `--skip-specials`
571 /// will skip materialization — the dst counterpart still needs to be retained.
572 fn record_src(&mut self, name: &std::ffi::OsStr) {
573 if let Some(set) = &mut self.inner
574 && !self.src_records_disabled
575 {
576 set.insert(name.to_owned());
577 }
578 }
579 /// Update loop: this update entry passed the filter at its logical path.
580 fn record_update(&mut self, name: &std::ffi::OsStr) {
581 if let Some(set) = &mut self.inner {
582 set.insert(name.to_owned());
583 }
584 }
585 /// Borrow the underlying set for `prune_extraneous`. `None` means `--delete` is off and
586 /// the caller should skip the prune entirely.
587 fn as_set(&self) -> Option<&std::collections::HashSet<std::ffi::OsString>> {
588 self.inner.as_ref()
589 }
590}
591
592/// Per-entry worker of the fd-based dual-tree link walk.
593///
594/// `src_parent` is the open source directory holding `name`; `update` is `Some((dir, name))` when
595/// an update tree is present at this level (its directory handle plus the update root's basename
596/// for the root entry); `dst_parent` is the open destination directory (`None` in dry-run).
597/// `src_root`/`dst_root`/`update_root` are the user-specified roots and `rel_path` is this entry's
598/// path relative to those roots (empty for the root entry) — joined onto a root they reconstruct
599/// the real path used for diagnostics, `rm`, and `--dereference`. `rel_path` is also this entry's
600/// logical filter path.
601///
602/// The src entry is classified via `src_parent.child(name)` (fstat-authoritative; the getdents
603/// hint is only a spawn-loop heuristic). When an update entry exists at this name it is classified
604/// too, and the hard-link-vs-copy decision mirrors the old `--update` overlay logic exactly:
605/// a type-mismatch / changed file / symlink in the update tree is COPIED from the update version
606/// via [`copy::copy_child`]; an unchanged file is HARD-LINKED from the source; a directory recurses
607/// the dual tree. With no update tree, a source file is hard-linked, a source symlink is copied,
608/// and a directory recurses.
609///
610/// `permit` is the leaf permit the spawn loop pre-acquired for a regular-file src hint (via
611/// [`walk::preacquire_leaf_permit`]). It is USED only on the one path that copies a *changed*
612/// same-type regular file from the update tree (the data copy reuses it); on every other path it is
613/// dropped at the single consolidated drop site below — see that comment for why this is rlink's
614/// acknowledged dual-tree special case.
615#[instrument(skip(prog_track, src_parent, update, dst_parent, settings, permit))]
616#[async_recursion]
617#[allow(clippy::too_many_arguments)]
618async fn link_internal(
619 prog_track: &'static progress::Progress,
620 src_parent: &Arc<Dir>,
621 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
622 dst_parent: Option<&Arc<Dir>>,
623 name: &std::ffi::OsStr,
624 src_root: &std::path::Path,
625 dst_root: &std::path::Path,
626 update_root: Option<&std::path::Path>,
627 rel_path: &std::path::Path,
628 settings: &Settings,
629 is_fresh: bool,
630 permit: Option<LeafPermit>,
631) -> Result<Summary, Error> {
632 let _prog_guard = prog_track.ops.guard();
633 // real filesystem paths reconstructed from the roots + accumulated relative path. used for
634 // diagnostics, the path-based `--delete` prune scan / `rm`, the `--dereference` canonicalize
635 // fallback inside copy, and to derive `dst_name`. joining an empty `rel_path` (the root entry)
636 // would append a trailing separator, so use the root verbatim when `rel_path` is empty.
637 let (src_path, dst_path) = if rel_path.as_os_str().is_empty() {
638 (src_root.to_path_buf(), dst_root.to_path_buf())
639 } else {
640 (src_root.join(rel_path), dst_root.join(rel_path))
641 };
642 let update_path = update_root.map(|root| {
643 if rel_path.as_os_str().is_empty() {
644 root.to_path_buf()
645 } else {
646 root.join(rel_path)
647 }
648 });
649 // the destination entry's name within `dst_parent`. for nested entries this equals the source
650 // `name`, but for the root the source and destination basenames differ (e.g. linking `foo` to
651 // `bar`), so destination operations must use this name.
652 let dst_name = dst_path
653 .file_name()
654 .ok_or_else(|| {
655 Error::new(
656 anyhow!("link destination {:?} has no file name", &dst_path),
657 Default::default(),
658 )
659 })?
660 .to_owned();
661 tracing::debug!("classifying source entry");
662 let src_handle = src_parent
663 .child(name)
664 .await
665 .with_context(|| format!("failed reading metadata from {:?}", &src_path))
666 .map_err(|err| Error::new(err, Default::default()))?;
667 // classify the update entry at this name (if an update tree is present at this level). a
668 // NotFound is the "this path is missing from update" case; under --update-exclusive it means
669 // we're done (nothing materializes), otherwise we fall back to no-update mode for this entry.
670 let mut update_handle = match update {
671 Some((update_dir, update_name)) => {
672 tracing::debug!("classifying 'update' entry");
673 match update_dir.child(update_name).await {
674 Ok(handle) => Some(handle),
675 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
676 if settings.update_exclusive {
677 // the path is missing from update, we're done
678 return Ok(Default::default());
679 }
680 None
681 }
682 Err(error) => {
683 return Err(Error::new(
684 anyhow::Error::new(error)
685 .context(format!("failed reading metadata from {:?}", &update_path)),
686 Default::default(),
687 ));
688 }
689 }
690 }
691 None => None,
692 };
693 // Re-evaluate the filter using the UPDATE entry's authoritative type before letting it drive
694 // the materialization decision below. The spawn loop in `link_dir_contents` evaluated the
695 // filter against the SOURCE entry's type; when the same name is a TYPE MISMATCH (e.g. src
696 // `cache` is a file, update `cache` is a directory) a type-dependent pattern like the dir-only
697 // `cache/` can pass the src (a dir-only pattern doesn't match a file) yet exclude the update.
698 // Without this check the type-mismatch branch would `delegate_copy` the excluded update entry
699 // (`copy_child` does NOT re-apply the top-level filter to the delegated root), copying an
700 // excluded subtree. Note a filtered-out update reaching here is necessarily a type mismatch: if
701 // the pattern were type-independent it would have excluded the src too, so the src loop would
702 // not have spawned this worker.
703 if let Some(handle) = update_handle.as_ref() {
704 let update_is_dir = handle.kind() == EntryKind::Dir;
705 // For the ROOT entry (`rel_path` empty) judge the update side with root-item semantics —
706 // symmetric with how the source root was filtered (`should_include_root_item` at the top of
707 // `link`) and with how main's delegated copy filtered the update root — rather than the
708 // nested `should_include("")` path, which would judge the root by different rules than the
709 // source (e.g. a non-anchored `--include data` includes a root file `data` under root-item
710 // semantics but not under the nested empty-path check). Nested entries keep the accumulated
711 // `rel_path` with the normal nested filter.
712 let update_excluded = match settings.filter.as_ref() {
713 Some(filter) if rel_path.as_os_str().is_empty() => {
714 let (_, update_name) =
715 update.expect("update_handle is Some only when update is Some");
716 !matches!(
717 filter.should_include_root_item(
718 std::path::Path::new(update_name),
719 update_is_dir,
720 ),
721 crate::filter::FilterResult::Included
722 )
723 }
724 _ => walk::should_skip_entry(&settings.filter, rel_path, update_is_dir).is_some(),
725 };
726 if update_excluded {
727 if settings.update_exclusive {
728 // --update-exclusive materializes only the (filter-passing) update set, so an
729 // excluded update entry materializes nothing — exactly the NotFound case above.
730 // The src is not materialized; under --delete its keep-set entry was never recorded
731 // (`record_src` is a no-op when `src_records_disabled`), so the dst counterpart is
732 // pruned/exclude-protected by the prune scan, never materialized-then-pruned.
733 tracing::debug!(
734 "update entry {:?} is filtered out under --update-exclusive; materializing nothing",
735 update_path
736 );
737 return Ok(Default::default());
738 }
739 // Normal --update (union): the update's version of this name is excluded, so the src's
740 // version stands. Treat the update entry as absent and fall through to the no-update
741 // src handling (hard-link a src file, copy a src symlink, recurse a src dir). The src
742 // already passed its own filter in the spawn loop, so its --delete keep-set entry
743 // correctly stays recorded.
744 tracing::debug!(
745 "update entry {:?} is filtered out; falling back to source-only handling",
746 update_path
747 );
748 update_handle = None;
749 }
750 }
751 // ── rlink's acknowledged dual-tree special case (docs/tocttou.md, "One shared traversal driver"): the single permit-drop site ──
752 //
753 // The spawn loop in `link_dir_contents` pre-acquired this leaf permit (open-files pool) for a
754 // regular-file src `hint`, but the authoritative dual-tree decision below may instead COPY
755 // (possibly recursing, when the update entry is a directory), HARD-LINK, SKIP, or recurse a
756 // directory. The permit is USED on exactly ONE path: copying a *changed* same-type regular file
757 // from the update tree (`copy_child` reuses it for the data copy). On every other path — both
758 // the recursing ones (a type-mismatch where the update side may be a directory, and a same-type
759 // directory) and the non-recursive leaf ones (hard-link, symlink copy, special, no-update) — the
760 // permit must NOT be held: a recursing path holding it across `copy_child`/`link_dir_entry` is
761 // the hold-and-wait deadlock the leaf-permit lifecycle eliminates, and a leaf path holding the
762 // mismatched permit across its own `.await` is pointless.
763 //
764 // So decide once, here, before any `.await` that isn't the intended copy: extract the open-files
765 // guard for the file-changed-copy path and drop the permit for everything else. Replacing the
766 // seven hand-maintained `drop(open_file_guard)` sites with this one is the Class-1 cleanup; this
767 // is the one site exempted from the Phase H no-manual-drop lint, because rlink's dual-tree walk
768 // is not a `WalkVisitor` and cannot use the driver's single drop-before-recurse home.
769 let is_file_changed_copy = match update_handle.as_ref() {
770 Some(update_handle) => {
771 update_handle.kind() == src_handle.kind()
772 && update_handle.kind() == EntryKind::File
773 && !filecmp::metadata_equal(
774 &settings.update_compare,
775 src_handle.meta(),
776 update_handle.meta(),
777 )
778 }
779 None => false,
780 };
781 let copy_guard: Option<throttle::OpenFileGuard> = if is_file_changed_copy {
782 // keep the permit for the data copy: hand the inner open-files guard to `copy_child`.
783 match permit {
784 Some(LeafPermit::OpenFile(guard)) => Some(guard),
785 // a non-OpenFile permit can never reach rlink (its `want` only takes from the OpenFile
786 // pool), and `None` means the pool was disabled — either way there is nothing to pass.
787 _ => None,
788 }
789 } else {
790 // every non-copy / recursing branch: release the leaf permit now (the consolidated drop).
791 drop(permit);
792 None
793 };
794 if let Some(update_handle) = update_handle.as_ref() {
795 let (update_dir, update_name) = update.unwrap();
796 let update_path = update_path.as_deref().unwrap();
797 if update_handle.kind() != src_handle.kind() {
798 // file type changed, just copy the updated one
799 tracing::debug!(
800 "link: file type of {:?} ({:?}) and {:?} ({:?}) differs - copying from update",
801 src_path,
802 src_handle.kind(),
803 update_path,
804 update_handle.kind()
805 );
806 // the leaf permit was already released at the consolidated drop site above (this is the
807 // type-mismatch path, where the update side may be a directory and we recurse via copy).
808 // delegate at this entry's logical path so that, under --delete, pruning inside the
809 // delegated subtree matches include/exclude descendants at the correct filter root
810 // (e.g. `node/*.log`). pass the HELD update parent + name, never a re-resolved path.
811 return delegate_copy(
812 prog_track,
813 update_dir,
814 dst_parent,
815 update_name,
816 update_path,
817 &dst_path,
818 rel_path,
819 settings,
820 is_fresh,
821 None,
822 )
823 .await;
824 }
825 if update_handle.kind() == EntryKind::File {
826 // check if the file is unchanged and if so hard-link, otherwise copy from the updated one
827 if filecmp::metadata_equal(
828 &settings.update_compare,
829 src_handle.meta(),
830 update_handle.meta(),
831 ) {
832 // unchanged file: hard-link from src. the permit was already dropped above (this is
833 // not the file-changed-copy path), so `copy_guard` is None and there is nothing held.
834 tracing::debug!("no change, hard link 'src'");
835 if settings.dry_run.is_some() {
836 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
837 return Ok(Summary {
838 hard_links_created: 1,
839 ..Default::default()
840 });
841 }
842 let dst_dir =
843 dst_parent.expect("destination parent must be open for a real hard link");
844 return hard_link_entry_fd(
845 prog_track,
846 &src_handle,
847 dst_dir,
848 &dst_name,
849 &dst_path,
850 settings,
851 )
852 .await;
853 }
854 tracing::debug!(
855 "link: {:?} metadata has changed, copying from {:?}",
856 src_path,
857 update_path
858 );
859 // changed file: delegate to copy, reusing the pre-acquired permit (the common path: the
860 // spawn loop pre-acquires for regular-file hints). `copy_guard` is the open-files guard
861 // the consolidated decision above extracted for exactly this path. copy_child
862 // re-classifies and applies its own --overwrite/dry-run logic for a file.
863 return delegate_copy(
864 prog_track,
865 update_dir,
866 dst_parent,
867 update_name,
868 update_path,
869 &dst_path,
870 rel_path,
871 settings,
872 is_fresh,
873 copy_guard,
874 )
875 .await;
876 }
877 if update_handle.kind() == EntryKind::Symlink {
878 // update symlink: copy it. the permit was already dropped at the consolidated site.
879 tracing::debug!("'update' is a symlink so just symlink that");
880 return delegate_copy(
881 prog_track,
882 update_dir,
883 dst_parent,
884 update_name,
885 update_path,
886 &dst_path,
887 rel_path,
888 settings,
889 is_fresh,
890 None,
891 )
892 .await;
893 }
894 } else {
895 // update hasn't been specified (or is absent at this name): hard-link a source file,
896 // copy a source symlink.
897 // the permit (if any) was already released at the consolidated drop site above, so the
898 // no-update hard-link / symlink-copy paths here hold nothing.
899 tracing::debug!("no 'update' entry");
900 if src_handle.kind() == EntryKind::File {
901 if settings.dry_run.is_some() {
902 crate::dry_run::report_action("link", &src_path, Some(&dst_path), "file");
903 return Ok(Summary {
904 hard_links_created: 1,
905 ..Default::default()
906 });
907 }
908 let dst_dir = dst_parent.expect("destination parent must be open for a real hard link");
909 return hard_link_entry_fd(
910 prog_track,
911 &src_handle,
912 dst_dir,
913 &dst_name,
914 &dst_path,
915 settings,
916 )
917 .await;
918 }
919 if src_handle.kind() == EntryKind::Symlink {
920 tracing::debug!("'src' is a symlink so just symlink that");
921 return delegate_copy(
922 prog_track, src_parent, dst_parent, name, &src_path, &dst_path, rel_path, settings,
923 is_fresh, None,
924 )
925 .await;
926 }
927 }
928 if src_handle.kind() != EntryKind::Dir {
929 // special file (or unsupported type): non-recursive; the permit is already released above.
930 if settings.copy_settings.skip_specials {
931 tracing::debug!(
932 "skipping special file {:?} (kind: {:?})",
933 src_path,
934 src_handle.kind()
935 );
936 if let Some(mode) = settings.dry_run {
937 match mode {
938 crate::config::DryRunMode::Brief => {}
939 crate::config::DryRunMode::All => println!("skip special {:?}", src_path),
940 crate::config::DryRunMode::Explain => {
941 println!(
942 "skip special {:?} (unsupported file type: {:?})",
943 src_path,
944 src_handle.kind()
945 );
946 }
947 }
948 }
949 prog_track.specials_skipped.inc();
950 return Ok(Summary {
951 copy_summary: CopySummary {
952 specials_skipped: 1,
953 ..Default::default()
954 },
955 ..Default::default()
956 });
957 }
958 return Err(Error::new(
959 anyhow!(
960 "copy: {:?} -> {:?} failed, unsupported src file type: {:?}",
961 src_path,
962 dst_path,
963 src_handle.kind()
964 ),
965 Default::default(),
966 ));
967 }
968 // directory: recurse the dual tree. the leaf permit was released at the consolidated drop site
969 // above — this recursing path never holds it (the hold-and-wait deadlock invariant).
970 debug_assert!(
971 update_handle.is_none() || update_handle.as_ref().unwrap().kind() == EntryKind::Dir
972 );
973 // Only drive the dual-tree update walk when an update directory entry actually exists at this
974 // name. If `update_handle` is None (the update tree has no counterpart for this src dir, the
975 // recursive "update missing" case), process this subtree in no-update mode: hard-link the whole
976 // source subtree. Passing the parent update tuple here would make `link_dir_entry` try to
977 // `open_dir` a non-existent update child.
978 let update_for_dir = update.filter(|_| update_handle.is_some());
979 let update_root_for_dir = update_root.filter(|_| update_handle.is_some());
980 link_dir_entry(
981 prog_track,
982 src_parent,
983 update_for_dir,
984 dst_parent,
985 name,
986 &dst_name,
987 src_root,
988 dst_root,
989 update_root_for_dir,
990 rel_path,
991 &src_path,
992 &dst_path,
993 update_path.as_deref().filter(|_| update_handle.is_some()),
994 settings,
995 is_fresh,
996 )
997 .await
998}
999
1000/// Delegate a single entry to the fd-based copy ([`copy::copy_child`]), passing the HELD parent
1001/// directory handles plus the entry `name` — never re-resolving a path. `filter_base` for the
1002/// delegation is the entry's logical relative path (so `--delete` pruning inside the subtree
1003/// matches include/exclude patterns at the entry's true path). The returned copy summary is folded
1004/// into a link `Summary`.
1005#[allow(clippy::too_many_arguments)]
1006async fn delegate_copy(
1007 prog_track: &'static progress::Progress,
1008 src_parent: &Arc<Dir>,
1009 dst_parent: Option<&Arc<Dir>>,
1010 name: &std::ffi::OsStr,
1011 src_path: &std::path::Path,
1012 dst_path: &std::path::Path,
1013 filter_base: &std::path::Path,
1014 settings: &Settings,
1015 is_fresh: bool,
1016 open_file_guard: Option<throttle::OpenFileGuard>,
1017) -> Result<Summary, Error> {
1018 let copy_summary = copy::copy_child(
1019 prog_track,
1020 src_parent,
1021 dst_parent,
1022 name,
1023 src_path,
1024 dst_path,
1025 filter_base,
1026 &settings.copy_settings,
1027 &settings.preserve,
1028 is_fresh,
1029 open_file_guard,
1030 )
1031 .await
1032 .map_err(|err| {
1033 let copy_summary = err.summary;
1034 Error::new(
1035 err.source,
1036 Summary {
1037 copy_summary,
1038 ..Default::default()
1039 },
1040 )
1041 })?;
1042 Ok(Summary {
1043 copy_summary,
1044 ..Default::default()
1045 })
1046}
1047
1048/// Resolve (create / reuse / overwrite) the destination directory fd-relative, open the source
1049/// (and update) directories, then recurse via [`link_dir_contents`]. Mirrors copy's
1050/// [`copy::resolve_dst_dir`] for the overwrite branches (recheck-guarded, fd-relative removal).
1051#[allow(clippy::too_many_arguments)]
1052async fn link_dir_entry(
1053 prog_track: &'static progress::Progress,
1054 src_parent: &Arc<Dir>,
1055 update: Option<(&Arc<Dir>, &std::ffi::OsStr)>,
1056 dst_parent: Option<&Arc<Dir>>,
1057 name: &std::ffi::OsStr,
1058 dst_name: &std::ffi::OsStr,
1059 src_root: &std::path::Path,
1060 dst_root: &std::path::Path,
1061 update_root: Option<&std::path::Path>,
1062 rel_path: &std::path::Path,
1063 src_path: &std::path::Path,
1064 dst_path: &std::path::Path,
1065 update_path: Option<&std::path::Path>,
1066 settings: &Settings,
1067 is_fresh: bool,
1068) -> Result<Summary, Error> {
1069 let src_dir = src_parent
1070 .open_dir(name)
1071 .await
1072 .with_context(|| format!("cannot open directory {:?} for reading", src_path))
1073 .map_err(|err| Error::new(err, Default::default()))?;
1074 let src_dir = Arc::new(src_dir);
1075 // open the update directory too (it has the same file type as src here — both are dirs).
1076 let update_dir = match update {
1077 Some((update_parent, update_name)) => {
1078 let dir = update_parent
1079 .open_dir(update_name)
1080 .await
1081 .with_context(|| {
1082 format!("cannot open update directory {:?} for reading", update_path)
1083 })
1084 .map_err(|err| Error::new(err, Default::default()))?;
1085 Some(Arc::new(dir))
1086 }
1087 None => None,
1088 };
1089 // dry-run: report the directory and traverse its contents, but never create a destination dir.
1090 if settings.dry_run.is_some() {
1091 crate::dry_run::report_action("link", src_path, Some(dst_path), "dir");
1092 let base = Summary {
1093 copy_summary: CopySummary {
1094 directories_created: 1, // report as would-be-created
1095 ..Default::default()
1096 },
1097 ..Default::default()
1098 };
1099 return link_dir_contents(
1100 prog_track,
1101 &src_dir,
1102 update_dir.as_ref(),
1103 None, // dry-run: no destination dir
1104 None, // dry-run: no destination parent
1105 dst_name,
1106 src_root,
1107 dst_root,
1108 update_root,
1109 rel_path,
1110 src_path,
1111 dst_path,
1112 true, // treat as "created" so empty-dir cleanup can suppress the dry-run count
1113 is_fresh,
1114 None, // dry-run: no destination dir is opened or locked, so nothing to restore
1115 settings,
1116 base,
1117 )
1118 .await;
1119 }
1120 // real link: dst_parent is Some.
1121 let dst_parent = dst_parent.expect("destination parent must be open for a real link");
1122 let copy::DirSlot {
1123 dir: dst_dir,
1124 summary: base,
1125 is_fresh: child_is_fresh,
1126 we_created,
1127 reused_lock,
1128 } = match copy::resolve_dst_dir(
1129 prog_track,
1130 dst_parent,
1131 dst_name,
1132 dst_path,
1133 &settings.copy_settings,
1134 )
1135 .await
1136 .map_err(|err| {
1137 Error::new(
1138 err.source,
1139 Summary {
1140 copy_summary: err.summary,
1141 ..Default::default()
1142 },
1143 )
1144 })? {
1145 copy::DirResolution::Skip(summary) => {
1146 return Ok(Summary {
1147 copy_summary: summary,
1148 ..Default::default()
1149 });
1150 }
1151 copy::DirResolution::Proceed(slot) => slot,
1152 };
1153 link_dir_contents(
1154 prog_track,
1155 &src_dir,
1156 update_dir.as_ref(),
1157 Some(&dst_dir),
1158 Some(dst_parent),
1159 dst_name,
1160 src_root,
1161 dst_root,
1162 update_root,
1163 rel_path,
1164 src_path,
1165 dst_path,
1166 we_created,
1167 child_is_fresh,
1168 reused_lock,
1169 settings,
1170 Summary {
1171 copy_summary: base,
1172 ..Default::default()
1173 },
1174 )
1175 .await
1176}
1177
1178/// The dual-tree body of a directory link: enumerate the source entries (hard-linking unchanged
1179/// files, delegating copies, recursing into subdirectories), then enumerate the update entries and
1180/// copy those not present in the source, then run `--delete` pruning, empty-directory cleanup, and
1181/// finally apply the directory's own metadata.
1182///
1183/// `dst_dir == None` / `dst_parent == None` means dry-run (no destination mutation). `base` carries
1184/// the `directories_created`/`directories_unchanged` contribution from resolving this directory.
1185#[allow(clippy::too_many_arguments)]
1186async fn link_dir_contents(
1187 prog_track: &'static progress::Progress,
1188 src_dir: &Arc<Dir>,
1189 update_dir: Option<&Arc<Dir>>,
1190 dst_dir: Option<&Arc<Dir>>,
1191 dst_parent: Option<&Arc<Dir>>,
1192 dst_name: &std::ffi::OsStr,
1193 src_root: &std::path::Path,
1194 dst_root: &std::path::Path,
1195 update_root: Option<&std::path::Path>,
1196 rel_path: &std::path::Path,
1197 src_path: &std::path::Path,
1198 dst_path: &std::path::Path,
1199 we_created_this_dir: bool,
1200 is_fresh: bool,
1201 reused_lock: Option<crate::safedir::ReusedDirLock>,
1202 settings: &Settings,
1203 base: Summary,
1204) -> Result<Summary, Error> {
1205 tracing::debug!("process contents of 'src' directory");
1206 let src_entries = src_dir
1207 .read_entries()
1208 .await
1209 .with_context(|| format!("cannot open directory {src_path:?} for reading"))
1210 .map_err(|err| Error::new(err, base))?;
1211 let mut link_summary = base;
1212 let mut join_set = tokio::task::JoinSet::new();
1213 let errors = crate::error_collector::ErrorCollector::default();
1214 // create a set of all the files we already processed
1215 let mut processed_files = std::collections::HashSet::new();
1216 // Keep-set for --delete: names that will be materialized at the destination. See
1217 // `DeleteKeepSet` for the semantics of `record_src` / `record_update` /
1218 // `drop_src_when_update_filtered`. No-op when --delete is off, so the call sites stay
1219 // unconditional in the hot path.
1220 let mut keep_set = DeleteKeepSet::new(
1221 settings.copy_settings.delete.as_ref(),
1222 settings.update_exclusive,
1223 update_dir.is_some(),
1224 );
1225 // iterate through src entries and recursively call "link" on each one
1226 for (entry_name, hint) in src_entries {
1227 // classification for the special-skip, symlink-dispatch, and permit pre-acquire decisions
1228 // uses the cheap getdents hint; `link_internal` re-classifies authoritatively via fstat
1229 // before acting. an unknown hint (DT_UNKNOWN) is treated as a regular file for those, the
1230 // same default the old path-based walk used when `file_type()` was unavailable.
1231 let entry_kind = hint.unwrap_or(EntryKind::File);
1232 let entry_is_symlink = entry_kind == EntryKind::Symlink;
1233 let entry_rel = rel_path.join(&entry_name);
1234 let entry_path = src_path.join(&entry_name);
1235 // the dir-ness that drives the FILTER decision AND the dry-run recurse-vs-leaf branch
1236 // below must be AUTHORITATIVE: on a DT_UNKNOWN entry, defaulting to non-dir would wrongly
1237 // omit a real directory's whole subtree under an is_dir-dependent filter, or (in dry-run,
1238 // even with NO filter) preview it as a leaf and never descend into it. `filter_is_dir`
1239 // fstats when a filter is active OR — via force_authoritative — when dry-run needs the
1240 // type for control flow. One extra fstat only in those DT_UNKNOWN cases (never follows
1241 // symlinks).
1242 let entry_is_dir = walk::filter_is_dir(
1243 settings.filter.as_ref(),
1244 src_dir,
1245 &entry_name,
1246 hint,
1247 settings.dry_run.is_some(),
1248 )
1249 .await;
1250 // apply filter if configured (logical path == entry_rel, since link's filter_base is empty)
1251 if let Some(skip_result) =
1252 walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir)
1253 {
1254 if let Some(mode) = settings.dry_run {
1255 crate::dry_run::report_skip(&entry_path, &skip_result, mode, entry_kind.label());
1256 }
1257 tracing::debug!("skipping {:?} due to filter", &entry_path);
1258 link_summary = link_summary + skipped_summary_for(entry_kind);
1259 entry_kind.inc_skipped(prog_track);
1260 continue;
1261 }
1262 // keep-set: a source entry has a destination counterpart that must not be pruned, even
1263 // when --skip-specials skips copying it (computed before the skip-specials check below).
1264 keep_set.record_src(&entry_name);
1265 // skip special files (sockets, FIFOs, devices) when --skip-specials is set
1266 if settings.copy_settings.skip_specials && entry_kind == EntryKind::Special {
1267 tracing::debug!("skipping special file {:?}", &entry_path);
1268 if let Some(mode) = settings.dry_run {
1269 match mode {
1270 crate::config::DryRunMode::Brief => {}
1271 crate::config::DryRunMode::All => {
1272 println!("skip special {:?}", &entry_path)
1273 }
1274 crate::config::DryRunMode::Explain => {
1275 println!(
1276 "skip special {:?} (unsupported file type: {:?})",
1277 &entry_path, entry_kind
1278 );
1279 }
1280 }
1281 }
1282 link_summary.copy_summary.specials_skipped += 1;
1283 prog_track.specials_skipped.inc();
1284 continue;
1285 }
1286 processed_files.insert(entry_name.clone());
1287 // dry-run for non-directory entries: report the would-be action without recursing.
1288 if settings.dry_run.is_some() && !entry_is_dir {
1289 let dst_entry_path = dst_path.join(&entry_name);
1290 crate::dry_run::report_action(
1291 "link",
1292 &entry_path,
1293 Some(&dst_entry_path),
1294 entry_kind.label(),
1295 );
1296 if entry_is_symlink {
1297 // for symlinks in dry-run, count as symlink (in copy_summary)
1298 link_summary.copy_summary.symlinks_created += 1;
1299 } else {
1300 // for files in dry-run, count the "would be created" hard link.
1301 // N.B. when an update tree is present, link_internal decides file-vs-copy
1302 // per-entry; the dry-run hint here counts a hard link, matching the old walk which
1303 // likewise counted a hard link for a regular-file src entry in dry-run.
1304 link_summary.hard_links_created += 1;
1305 }
1306 continue;
1307 }
1308 // for regular-file entries, pre-acquire a leaf permit (open-files pool) BEFORE spawning so we
1309 // don't create unbounded tasks. `preacquire_leaf_permit` is the shared lifecycle primitive:
1310 // its `want` opts in only for a regular-file hint, so directories take none (they recurse and
1311 // would deadlock against a saturated pool), symlinks take none (they pass through to copy,
1312 // which handles permits internally), and a DT_UNKNOWN hint takes none for the same reason —
1313 // exactly the original `hint == Some(File)` policy. `link_internal` re-classifies
1314 // authoritatively and either uses the permit (changed-file copy) or drops it.
1315 //
1316 // Acquire-then-IMMEDIATELY-spawn (the permit is moved into `do_link` and spawned on the next
1317 // line, in the same loop step) is load-bearing: collecting a Vec of pre-acquired permits and
1318 // spawning later would hold N permits before any task runs and self-deadlock a saturated pool.
1319 // This mirrors the single-tree driver's incremental acquire-then-spawn loop
1320 // (`walk_driver::walk_dir_contents`, joined via `walk_driver::join_and_fold`).
1321 let permit = walk::preacquire_leaf_permit(PermitKind::OpenFile, hint, |h| {
1322 h == Some(EntryKind::File)
1323 })
1324 .await;
1325 let src_parent = Arc::clone(src_dir);
1326 let dst_parent = dst_dir.map(Arc::clone);
1327 let update_parent = update_dir.map(Arc::clone);
1328 let settings = settings.clone();
1329 let src_root = src_root.to_owned();
1330 let dst_root = dst_root.to_owned();
1331 let update_root = update_root.map(std::path::Path::to_path_buf);
1332 let do_link = move || async move {
1333 let update_ref = update_parent
1334 .as_ref()
1335 .map(|dir| (dir, entry_name.as_os_str()));
1336 link_internal(
1337 prog_track,
1338 &src_parent,
1339 update_ref,
1340 dst_parent.as_ref(),
1341 &entry_name,
1342 &src_root,
1343 &dst_root,
1344 update_root.as_deref(),
1345 &entry_rel,
1346 &settings,
1347 is_fresh,
1348 permit,
1349 )
1350 .await
1351 };
1352 join_set.spawn(do_link());
1353 }
1354 // only process update if the path was provided and the directory is present
1355 if let Some(update_dir) = update_dir {
1356 let update_root = update_root.expect("update_dir present implies update_root present");
1357 tracing::debug!("process contents of 'update' directory");
1358 let update_entries = update_dir
1359 .read_entries()
1360 .await
1361 .with_context(|| {
1362 format!(
1363 "cannot open directory {:?} for reading",
1364 update_path_dbg(update_root, rel_path)
1365 )
1366 })
1367 .map_err(|err| Error::new(err, link_summary))?;
1368 // Iterate through update entries and for each one that's not present in src, copy it.
1369 //
1370 // We deliberately do NOT pre-acquire any permit here. Two cycles rule out the
1371 // straightforward options:
1372 // * `open_file_permit`: copy_child → copy_internal re-acquires open-files for each file;
1373 // a saturated pool would deadlock the inner acquire if we held one across the call.
1374 // * `pending_meta_permit`: with --overwrite, copy_child → copy_file_fd → rm::rm drains
1375 // pending_meta for child entries (rm.rs spawn loop). N tasks here each holding a
1376 // pending_meta permit would deadlock waiting on each other's inner rm.
1377 //
1378 // The spawn count at this site is naturally bounded by the number of update-only entries
1379 // (user input — typically modest). Each spawned task's work is throttled by copy's own
1380 // internal open-files backpressure inside copy_internal's spawn loop.
1381 for (entry_name, hint) in update_entries {
1382 let entry_kind = hint.unwrap_or(EntryKind::File);
1383 let entry_rel = rel_path.join(&entry_name);
1384 // the FILTER `is_dir` decision must use the AUTHORITATIVE type: on a DT_UNKNOWN update
1385 // entry with an is_dir-dependent include filter, defaulting to non-dir would wrongly
1386 // omit a real directory's whole subtree. one extra fstat only in that DT_UNKNOWN+filter
1387 // case (never follows symlinks).
1388 let entry_is_dir = walk::filter_is_dir(
1389 settings.filter.as_ref(),
1390 update_dir,
1391 &entry_name,
1392 hint,
1393 // used only for the filter decision here — no dry-run recurse-vs-leaf shortcut
1394 // (delegate_copy reclassifies authoritatively) — so no need to force an fstat.
1395 false,
1396 )
1397 .await;
1398 // evaluate the filter for this update entry at its logical path. This MUST run
1399 // regardless of `--delete`: `copy_child` wraps `copy_internal`, which (unlike the old
1400 // path-based `copy_with_filter_base`) does NOT re-apply a top-level filter to the entry
1401 // it is handed, so without this skip an `--exclude`'d update-only entry would be copied.
1402 let skip_result = walk::should_skip_entry(&settings.filter, &entry_rel, entry_is_dir);
1403 let filtered_out = skip_result.is_some();
1404 // keep-set: every filter-passing update entry is materialized at the destination
1405 // (entries also in `src` are linked, update-only entries are copied). Computed before
1406 // the dedup `continue` so entries also present in `src` are covered — this is what
1407 // makes --update-exclusive mirror the update set exactly.
1408 //
1409 // A filtered-out update entry contributes NOTHING here and never drops an existing
1410 // src-side registration: when the src loop materialized a same-name entry it can only
1411 // be a TYPE MISMATCH (a type-independent pattern would have excluded the src too), and
1412 // the union semantics keep the src's version (`link_internal` falls back to source-only
1413 // handling), so its keep-set entry must survive — pruning it would delete what we just
1414 // materialized. Under --update-exclusive `record_src` was a no-op, so there is likewise
1415 // nothing to keep. Either way the filtered-out branch leaves the keep-set untouched.
1416 if settings.copy_settings.delete.is_some() && !filtered_out {
1417 keep_set.record_update(&entry_name);
1418 }
1419 if processed_files.contains(&entry_name) {
1420 // we already must have considered this file, skip it
1421 continue;
1422 }
1423 // filtered-out update-only entry: skip the delegation entirely (matching the old
1424 // `copy_with_filter_base`'s top-level filter), and record the skip in the summary /
1425 // counters exactly as the source loop does for a filtered src entry.
1426 if let Some(skip_result) = skip_result {
1427 let update_entry_path = update_root.join(&entry_rel);
1428 if let Some(mode) = settings.dry_run {
1429 crate::dry_run::report_skip(
1430 &update_entry_path,
1431 &skip_result,
1432 mode,
1433 entry_kind.label(),
1434 );
1435 }
1436 tracing::debug!(
1437 "skipping update entry {:?} due to filter",
1438 &update_entry_path
1439 );
1440 link_summary = link_summary + skipped_summary_for(entry_kind);
1441 entry_kind.inc_skipped(prog_track);
1442 continue;
1443 }
1444 tracing::debug!("found a new entry in the 'update' directory");
1445 let update_entry_path = update_root.join(&entry_rel);
1446 let dst_entry_path = dst_path.join(&entry_name);
1447 let update_parent = Arc::clone(update_dir);
1448 let dst_parent = dst_dir.map(Arc::clone);
1449 let settings = settings.clone();
1450 let do_copy = move || async move {
1451 // filter-base for the delegated copy: this update entry's path relative to the
1452 // source root, so any --delete pruning inside it matches the include/exclude filter
1453 // at the entry's true relative path (e.g. cache/*.log), not relative to the entry.
1454 delegate_copy(
1455 prog_track,
1456 &update_parent,
1457 dst_parent.as_ref(),
1458 &entry_name,
1459 &update_entry_path,
1460 &dst_entry_path,
1461 &entry_rel,
1462 &settings,
1463 is_fresh,
1464 None,
1465 )
1466 .await
1467 };
1468 join_set.spawn(do_copy());
1469 }
1470 }
1471 while let Some(res) = join_set.join_next().await {
1472 match res {
1473 Ok(result) => match result {
1474 Ok(summary) => link_summary = link_summary + summary,
1475 Err(error) => {
1476 tracing::error!(
1477 "link: {:?} -> {:?} failed with: {:#}",
1478 src_path,
1479 dst_path,
1480 &error
1481 );
1482 link_summary = link_summary + error.summary;
1483 if settings.copy_settings.fail_early {
1484 return Err(Error::new(error.source, link_summary));
1485 }
1486 errors.push(error.source);
1487 }
1488 },
1489 Err(error) => {
1490 if settings.copy_settings.fail_early {
1491 return Err(Error::new(error.into(), link_summary));
1492 }
1493 errors.push(error.into());
1494 }
1495 }
1496 }
1497 // rsync-style --delete for rlink: remove destination entries the link operation did not
1498 // materialize. `keep_set` holds exactly the materialized names: src ∪ update normally, or just
1499 // the update set under --update-exclusive (where source-only entries are not materialized and
1500 // so are pruned, matching `rsync --link-dest --delete`).
1501 if let Some(delete_settings) = &settings.copy_settings.delete {
1502 if errors.has_errors() {
1503 // rsync-style safety: skip pruning when this subtree's link/update pass reported errors
1504 // — deleting based on a run that did not fully succeed could remove data unexpectedly.
1505 tracing::warn!(
1506 "skipping --delete pruning of {:?} because the link/update pass reported errors",
1507 dst_path
1508 );
1509 } else {
1510 // the prune scan runs through the destination directory's own pinned fd. In a real link
1511 // we already hold it (`dst_dir`); in --dry-run the create-or-overwrite step is skipped,
1512 // so open it `O_NOFOLLOW|O_DIRECTORY` (dereference=false) — a symlink or non-directory
1513 // fails closed and prune is skipped, never following the symlink to delete a tree
1514 // OUTSIDE the destination. A missing dir likewise skips.
1515 // This reopen runs only in --dry-run (a real link holds `dst_dir`). It re-resolves the
1516 // destination directory BELOW the named root; under strict operand resolution the open
1517 // is openat2(RESOLVE_NO_SYMLINKS), so a symlink anywhere in `dst_path` — an intermediate
1518 // component the real run would replace, or a final symlink — fails with ELOOP and is
1519 // SKIPPED here (nothing to prune; the real run creates a fresh directory). The operand
1520 // prefix ABOVE the named root is validated up front, so this below-root skip does not
1521 // weaken the strict contract and keeps the preview consistent with the real run.
1522 let prune_dir: Option<Arc<Dir>> = match dst_dir {
1523 Some(dir) => Some(Arc::clone(dir)),
1524 None => {
1525 match Dir::open_root_dir(dst_path, false, congestion::Side::Destination).await {
1526 Ok(dir) => Some(Arc::new(dir)),
1527 Err(err)
1528 if matches!(
1529 err.kind(),
1530 std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
1531 ) || err.raw_os_error() == Some(libc::ELOOP)
1532 || err.raw_os_error() == Some(libc::ENOTDIR) =>
1533 {
1534 tracing::debug!(
1535 "skipping --delete pruning of {:?}: not a real directory",
1536 dst_path
1537 );
1538 None
1539 }
1540 Err(err) => {
1541 let err = anyhow::Error::new(err).context(format!(
1542 "cannot open destination {dst_path:?} for delete scan"
1543 ));
1544 if settings.copy_settings.fail_early {
1545 return Err(Error::new(err, link_summary));
1546 }
1547 errors.push(err);
1548 None
1549 }
1550 }
1551 }
1552 };
1553 if let Some(prune_dir) = prune_dir {
1554 match crate::delete::prune_extraneous(
1555 prog_track,
1556 &prune_dir,
1557 rel_path,
1558 keep_set
1559 .as_set()
1560 .expect("--delete is on, so DeleteKeepSet is active"),
1561 settings.filter.as_ref(),
1562 delete_settings,
1563 settings.copy_settings.fail_early,
1564 settings.dry_run,
1565 )
1566 .await
1567 {
1568 Ok(rm_summary) => {
1569 link_summary.copy_summary.rm_summary =
1570 link_summary.copy_summary.rm_summary + rm_summary;
1571 }
1572 Err(err) => {
1573 link_summary.copy_summary.rm_summary =
1574 link_summary.copy_summary.rm_summary + err.summary;
1575 if settings.copy_settings.fail_early {
1576 return Err(Error::new(err.source, link_summary));
1577 }
1578 errors.push(err.source);
1579 }
1580 }
1581 }
1582 }
1583 }
1584 // when filtering is active and we created this directory, check if anything was actually
1585 // linked/copied into it. if nothing was linked, we may need to clean up the empty directory.
1586 let this_dir_count = usize::from(we_created_this_dir);
1587 let child_dirs_created = link_summary
1588 .copy_summary
1589 .directories_created
1590 .saturating_sub(this_dir_count);
1591 let anything_linked = link_summary.hard_links_created > 0
1592 || link_summary.copy_summary.files_copied > 0
1593 || link_summary.copy_summary.symlinks_created > 0
1594 || child_dirs_created > 0;
1595 let is_root = rel_path.as_os_str().is_empty();
1596 match check_empty_dir_cleanup(
1597 settings.filter.as_ref(),
1598 we_created_this_dir,
1599 anything_linked,
1600 rel_path,
1601 is_root,
1602 settings.dry_run.is_some(),
1603 ) {
1604 EmptyDirAction::Keep => { /* proceed with metadata application */ }
1605 EmptyDirAction::DryRunSkip => {
1606 tracing::debug!(
1607 "dry-run: directory {:?} would not be created (nothing to link inside)",
1608 dst_path
1609 );
1610 link_summary.copy_summary.directories_created = 0;
1611 // a child error collected during the walk must still surface — otherwise a
1612 // traversal-only directory whose only child FAILED becomes "empty", is skipped here, and
1613 // the failed link is reported as success (mirrors copy::finalize_dir).
1614 if errors.has_errors() {
1615 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1616 }
1617 return Ok(link_summary);
1618 }
1619 EmptyDirAction::Remove => {
1620 tracing::debug!(
1621 "directory {:?} has nothing to link inside, removing empty directory",
1622 dst_path
1623 );
1624 // remove the empty directory fd-relative, through its parent dir handle: `rmdir_at`
1625 // operates on `dst_name` within the held `dst_parent` fd (never by path) and only
1626 // succeeds on an empty directory, so it is contained to `dst_parent`. `dst_parent` is
1627 // always Some here (None only in dry-run, where this arm is unreachable).
1628 let rmdir_result = match dst_parent {
1629 Some(dst_parent) => dst_parent.rmdir_at(dst_name).await,
1630 None => {
1631 crate::walk::run_metadata_probed(
1632 congestion::Side::Destination,
1633 congestion::MetadataOp::RmDir,
1634 tokio::fs::remove_dir(dst_path),
1635 )
1636 .await
1637 }
1638 };
1639 match rmdir_result {
1640 Ok(()) => {
1641 link_summary.copy_summary.directories_created = 0;
1642 // surface a collected child error even though the empty directory was removed,
1643 // so a failed child link is never reported as success (mirrors
1644 // copy::finalize_dir).
1645 if errors.has_errors() {
1646 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1647 }
1648 return Ok(link_summary);
1649 }
1650 Err(err) => {
1651 // removal failed (not empty, permission error, etc.) — keep directory
1652 tracing::debug!(
1653 "failed to remove empty directory {:?}: {:#}, keeping",
1654 dst_path,
1655 &err
1656 );
1657 // fall through to apply metadata
1658 }
1659 }
1660 }
1661 }
1662 // apply directory metadata regardless of whether all children linked successfully. the
1663 // directory itself was created/opened above. skipped in dry-run (no directory exists). prefer
1664 // the update directory's metadata when an update tree is present at this level (it is the
1665 // materialized version, matching the old `update_metadata_opt` preference), else the source
1666 // directory's. The metadata is read from the SAME fd whose contents were enumerated (read-side
1667 // fidelity, docs/tocttou.md), not the classify handles.
1668 tracing::debug!("set 'dst' directory metadata");
1669 let metadata_result = match dst_dir {
1670 Some(dst_dir) => {
1671 let meta_dir = update_dir.unwrap_or(src_dir);
1672 // for a reused directory locked down under strict mode, put back the ACLs the
1673 // lockdown stripped and restore the original owner component-wise, then apply source
1674 // metadata (see set_reused_dir_metadata_fd — no transient window hands the directory to
1675 // a hostile prior owner); None for fresh dirs.
1676 async {
1677 let preserve_meta = meta_dir.meta().await?;
1678 // the ACLs come from the same fd as the metadata, and only when `d:acl` was asked
1679 // for — rlink creates its directories fresh, so unlike files there is no shared
1680 // inode and applying them is ordinary destination work.
1681 let preserve_acls = if settings.preserve.dir.acl {
1682 Some(meta_dir.read_acls().await?)
1683 } else {
1684 None
1685 };
1686 crate::safedir::set_reused_dir_metadata_fd(
1687 &settings.preserve,
1688 &preserve_meta,
1689 preserve_acls.as_ref(),
1690 reused_lock,
1691 dst_dir,
1692 )
1693 .await
1694 }
1695 .await
1696 }
1697 None => Ok(()),
1698 };
1699 if errors.has_errors() {
1700 // child failures take precedence - log metadata error if it also failed
1701 if let Err(metadata_err) = metadata_result {
1702 tracing::error!(
1703 "link: {:?} -> {:?} failed to set directory metadata: {:#}",
1704 src_path,
1705 dst_path,
1706 &metadata_err
1707 );
1708 }
1709 // unwrap is safe: has_errors() guarantees into_error() returns Some
1710 return Err(Error::new(errors.into_error().unwrap(), link_summary));
1711 }
1712 // no child failures, so metadata error is the primary error
1713 metadata_result
1714 .with_context(|| format!("failed setting directory metadata on {:?}", dst_path))
1715 .map_err(|err| Error::new(err, link_summary))?;
1716 Ok(link_summary)
1717}
1718
1719/// Reconstruct an update entry's path purely for a diagnostic message.
1720fn update_path_dbg(
1721 update_root: &std::path::Path,
1722 rel_path: &std::path::Path,
1723) -> std::path::PathBuf {
1724 if rel_path.as_os_str().is_empty() {
1725 update_root.to_path_buf()
1726 } else {
1727 update_root.join(rel_path)
1728 }
1729}
1730
1731#[cfg(test)]
1732mod link_tests {
1733 use crate::rm;
1734 use crate::testutils;
1735 use std::os::unix::fs::PermissionsExt;
1736 use tracing_test::traced_test;
1737
1738 use super::*;
1739
1740 static PROGRESS: std::sync::LazyLock<progress::Progress> =
1741 std::sync::LazyLock::new(progress::Progress::new);
1742
1743 mod delete_keep_set_tests {
1744 //! Pure-logic unit tests for `DeleteKeepSet`. No filesystem needed — these pin the
1745 //! src-vs-update materialization rules so a future refactor can't silently break them.
1746
1747 use super::super::DeleteKeepSet;
1748 use crate::copy::DeleteSettings;
1749 use std::ffi::{OsStr, OsString};
1750
1751 fn delete_on() -> DeleteSettings {
1752 DeleteSettings {
1753 delete_excluded: false,
1754 }
1755 }
1756
1757 #[test]
1758 fn record_src_no_op_when_delete_off() {
1759 let mut k = DeleteKeepSet::new(None, false, false);
1760 k.record_src(OsStr::new("foo"));
1761 assert!(k.as_set().is_none());
1762 }
1763
1764 #[test]
1765 fn record_src_no_op_under_update_exclusive_with_update() {
1766 // `--update-exclusive` with an active update tree means the materialized set is
1767 // the update set; source-only entries must NOT be retained.
1768 let d = delete_on();
1769 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1770 k.record_src(OsStr::new("src_only"));
1771 assert!(!k.as_set().unwrap().contains(OsStr::new("src_only")));
1772 }
1773
1774 #[test]
1775 fn record_src_records_when_update_exclusive_without_update() {
1776 // `--update-exclusive` is a no-op (carve-out doesn't apply) when no `--update`
1777 // path is given.
1778 let d = delete_on();
1779 let mut k = DeleteKeepSet::new(Some(&d), true, false);
1780 k.record_src(OsStr::new("foo"));
1781 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1782 }
1783
1784 #[test]
1785 fn record_src_records_in_normal_delete_mode() {
1786 let d = delete_on();
1787 let mut k = DeleteKeepSet::new(Some(&d), false, false);
1788 k.record_src(OsStr::new("foo"));
1789 assert!(k.as_set().unwrap().contains(OsStr::new("foo")));
1790 }
1791
1792 #[test]
1793 fn record_update_always_records_when_delete_on() {
1794 // The update loop registers ALL filter-passing update entries, irrespective of
1795 // `--update-exclusive` — the update set IS the materialized set in that mode.
1796 let d = delete_on();
1797 let mut k = DeleteKeepSet::new(Some(&d), true, true);
1798 k.record_update(OsStr::new("from_update"));
1799 assert!(k.as_set().unwrap().contains(OsStr::new("from_update")));
1800 }
1801
1802 #[test]
1803 fn record_update_no_op_when_delete_off() {
1804 let mut k = DeleteKeepSet::new(None, false, false);
1805 k.record_update(OsStr::new("from_update"));
1806 assert!(k.as_set().is_none());
1807 }
1808
1809 #[test]
1810 fn filtered_out_update_keeps_materialized_src_entry_in_normal_mode() {
1811 // Type-mismatch under normal `--update` (union): src had a regular file at `node`,
1812 // update has a dir at `node/` excluded by the dir-only `node/` pattern. The src's
1813 // version of the name stands (`link_internal` falls back to source-only handling), so
1814 // the keep-set entry recorded by the source loop MUST survive — pruning it would
1815 // delete the file we just materialized. The filtered-out update branch is therefore a
1816 // no-op on the keep-set: `node` stays.
1817 let d = delete_on();
1818 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1819 k.record_src(OsStr::new("node"));
1820 // (the update loop's filtered-out branch records nothing and removes nothing)
1821 assert!(
1822 k.as_set().unwrap().contains(OsStr::new("node")),
1823 "src entry must stay in the keep-set when its update counterpart is filtered out"
1824 );
1825 }
1826
1827 #[test]
1828 fn filtered_out_update_keeps_skipped_special() {
1829 // The skip-special case: source loop ran `record_src` but never reached
1830 // `processed_files.insert` (it `continue`d on the skip-special branch). The dst
1831 // counterpart must be retained per --skip-specials semantics, and a filtered-out
1832 // update entry at the same name does not change that.
1833 let d = delete_on();
1834 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1835 k.record_src(OsStr::new("pipe"));
1836 assert!(k.as_set().unwrap().contains(OsStr::new("pipe")));
1837 }
1838
1839 #[test]
1840 fn full_directory_pass_keep_set_union_semantics() {
1841 // Models the union of src + update under plain `--delete --update` (no
1842 // --update-exclusive). Names: src has `keep`, `pipe` (special, skipped),
1843 // `node` (file). update has `from_upd`, `node` (a dir excluded by the dir-only
1844 // `node/` pattern). Under union semantics the excluded update `node/` does not
1845 // displace the src `node` file: `link_internal` materializes the src version, so
1846 // `node` STAYS in the keep-set (the filtered-out update branch records/removes
1847 // nothing). This is the corrected behavior versus the old type-mismatch bug, where
1848 // the excluded update dir was copied AND the src keep-set entry was dropped.
1849 let d = delete_on();
1850 let mut k = DeleteKeepSet::new(Some(&d), false, true);
1851
1852 // source loop
1853 k.record_src(OsStr::new("keep"));
1854 k.record_src(OsStr::new("pipe")); // --skip-specials: continues, processed_files NOT populated
1855 k.record_src(OsStr::new("node"));
1856
1857 // update loop: only filter-passing update entries are recorded; `node` is filtered out
1858 // and so contributes nothing (and does not drop the src `node`).
1859 k.record_update(OsStr::new("from_upd"));
1860
1861 let set: std::collections::HashSet<OsString> = k.as_set().unwrap().clone();
1862 let expected: std::collections::HashSet<OsString> =
1863 ["keep", "pipe", "node", "from_upd"]
1864 .into_iter()
1865 .map(OsString::from)
1866 .collect();
1867 assert_eq!(set, expected);
1868 }
1869 }
1870
1871 fn common_settings(dereference: bool, overwrite: bool) -> Settings {
1872 Settings {
1873 copy_settings: CopySettings {
1874 dereference,
1875 fail_early: false,
1876 overwrite,
1877 overwrite_compare: filecmp::MetadataCmpSettings {
1878 size: true,
1879 mtime: true,
1880 ..Default::default()
1881 },
1882 overwrite_filter: None,
1883 ignore_existing: false,
1884 chunk_size: 0,
1885 skip_specials: false,
1886 remote_copy_buffer_size: 0,
1887 filter: None,
1888 dry_run: None,
1889 delete: None,
1890 },
1891 update_compare: filecmp::MetadataCmpSettings {
1892 size: true,
1893 mtime: true,
1894 ..Default::default()
1895 },
1896 update_exclusive: false,
1897 filter: None,
1898 dry_run: None,
1899 preserve: preserve::preserve_all(),
1900 }
1901 }
1902
1903 #[tokio::test]
1904 #[traced_test]
1905 async fn test_basic_link() -> Result<(), anyhow::Error> {
1906 let tmp_dir = testutils::setup_test_dir().await?;
1907 let test_path = tmp_dir.as_path();
1908 let summary = link(
1909 &PROGRESS,
1910 test_path,
1911 &test_path.join("foo"),
1912 &test_path.join("bar"),
1913 &None,
1914 &common_settings(false, false),
1915 false,
1916 )
1917 .await?;
1918 assert_eq!(summary.hard_links_created, 5);
1919 assert_eq!(summary.copy_summary.files_copied, 0);
1920 assert_eq!(summary.copy_summary.symlinks_created, 2);
1921 assert_eq!(summary.copy_summary.directories_created, 3);
1922 testutils::check_dirs_identical(
1923 &test_path.join("foo"),
1924 &test_path.join("bar"),
1925 testutils::FileEqualityCheck::Timestamp,
1926 )
1927 .await?;
1928 Ok(())
1929 }
1930
1931 // Regression: a source operand whose final component is `.`/`..` (e.g. `rlink tree/.. dst`)
1932 // must be linked, not rejected — `split_root_operand` canonicalizes it. Uses `tree/sub/..`
1933 // (== `tree`) rather than `.` to avoid touching the process-wide cwd.
1934 #[tokio::test]
1935 async fn links_dot_dot_source_operand() -> Result<(), anyhow::Error> {
1936 use std::os::unix::fs::MetadataExt;
1937 let tmp = testutils::create_temp_dir().await?;
1938 let tree = tmp.join("tree");
1939 tokio::fs::create_dir(&tree).await?;
1940 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1941 tokio::fs::create_dir(tree.join("sub")).await?;
1942 let src = tree.join("sub").join(".."); // == tree
1943 let dst = tmp.join("dst");
1944 let summary = link(
1945 &PROGRESS,
1946 &tmp,
1947 &src,
1948 &dst,
1949 &None,
1950 &common_settings(false, false),
1951 false,
1952 )
1953 .await?;
1954 assert_eq!(
1955 summary.hard_links_created, 1,
1956 "the dot-dot source's file must be hard-linked"
1957 );
1958 assert!(
1959 dst.join("sub").is_dir(),
1960 "the dot-dot source's subdir must be created"
1961 );
1962 // the dst file shares the src inode (a hard link, not a copy).
1963 let src_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
1964 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
1965 assert_eq!(src_ino, dst_ino, "dst must be a hard link to the src inode");
1966 Ok(())
1967 }
1968
1969 // Regression: an `--update` operand whose final component is `.`/`..` (e.g.
1970 // `rlink --update tree/.. src dst`) must be accepted, not rejected — the update tree is a READ
1971 // tree, so `split_root_operand` canonicalizes it the same as the source. Uses `tree/sub/..`
1972 // (== `tree`) rather than `.` to avoid touching the process-wide cwd; src == update == tree so
1973 // the file links deterministically from the update tree.
1974 #[tokio::test]
1975 async fn links_dot_dot_update_operand() -> Result<(), anyhow::Error> {
1976 use std::os::unix::fs::MetadataExt;
1977 let tmp = testutils::create_temp_dir().await?;
1978 let tree = tmp.join("tree");
1979 tokio::fs::create_dir(&tree).await?;
1980 tokio::fs::write(tree.join("a.txt"), "hello").await?;
1981 tokio::fs::create_dir(tree.join("sub")).await?;
1982 let dst = tmp.join("dst");
1983 // the --update operand spelled with a trailing `..` (== tree); it must be canonicalized and
1984 // used, not rejected with "has no parent directory or file name".
1985 let update_operand = tree.join("sub").join(".."); // == tree
1986 let summary = link(
1987 &PROGRESS,
1988 &tmp,
1989 &tree,
1990 &dst,
1991 &Some(update_operand),
1992 &common_settings(false, false),
1993 false,
1994 )
1995 .await?;
1996 assert_eq!(
1997 summary.hard_links_created, 1,
1998 "the file must be hard-linked from the dot-dot update tree"
1999 );
2000 // the dst file shares the update tree's inode (linked from it, not copied).
2001 let update_ino = std::fs::metadata(tree.join("a.txt"))?.ino();
2002 let dst_ino = std::fs::metadata(dst.join("a.txt"))?.ino();
2003 assert_eq!(
2004 update_ino, dst_ino,
2005 "dst must be hard-linked from the update tree inode"
2006 );
2007 Ok(())
2008 }
2009
2010 #[tokio::test]
2011 #[traced_test]
2012 async fn test_basic_link_update() -> Result<(), anyhow::Error> {
2013 let tmp_dir = testutils::setup_test_dir().await?;
2014 let test_path = tmp_dir.as_path();
2015 let summary = link(
2016 &PROGRESS,
2017 test_path,
2018 &test_path.join("foo"),
2019 &test_path.join("bar"),
2020 &Some(test_path.join("foo")),
2021 &common_settings(false, false),
2022 false,
2023 )
2024 .await?;
2025 assert_eq!(summary.hard_links_created, 5);
2026 assert_eq!(summary.copy_summary.files_copied, 0);
2027 assert_eq!(summary.copy_summary.symlinks_created, 2);
2028 assert_eq!(summary.copy_summary.directories_created, 3);
2029 testutils::check_dirs_identical(
2030 &test_path.join("foo"),
2031 &test_path.join("bar"),
2032 testutils::FileEqualityCheck::Timestamp,
2033 )
2034 .await?;
2035 Ok(())
2036 }
2037
2038 #[tokio::test]
2039 #[traced_test]
2040 async fn test_basic_link_empty_src() -> Result<(), anyhow::Error> {
2041 let tmp_dir = testutils::setup_test_dir().await?;
2042 tokio::fs::create_dir(tmp_dir.join("baz")).await?;
2043 let test_path = tmp_dir.as_path();
2044 let summary = link(
2045 &PROGRESS,
2046 test_path,
2047 &test_path.join("baz"), // empty source
2048 &test_path.join("bar"),
2049 &Some(test_path.join("foo")),
2050 &common_settings(false, false),
2051 false,
2052 )
2053 .await?;
2054 assert_eq!(summary.hard_links_created, 0);
2055 assert_eq!(summary.copy_summary.files_copied, 5);
2056 assert_eq!(summary.copy_summary.symlinks_created, 2);
2057 assert_eq!(summary.copy_summary.directories_created, 3);
2058 testutils::check_dirs_identical(
2059 &test_path.join("foo"),
2060 &test_path.join("bar"),
2061 testutils::FileEqualityCheck::Timestamp,
2062 )
2063 .await?;
2064 Ok(())
2065 }
2066
2067 #[tokio::test]
2068 #[traced_test]
2069 async fn test_link_destination_permission_error_includes_root_cause()
2070 -> Result<(), anyhow::Error> {
2071 let tmp_dir = testutils::setup_test_dir().await?;
2072 let test_path = tmp_dir.as_path();
2073 let readonly_parent = test_path.join("readonly_dest");
2074 tokio::fs::create_dir(&readonly_parent).await?;
2075 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2076 .await?;
2077
2078 let mut settings = common_settings(false, false);
2079 settings.copy_settings.fail_early = true;
2080
2081 let result = link(
2082 &PROGRESS,
2083 test_path,
2084 &test_path.join("foo"),
2085 &readonly_parent.join("bar"),
2086 &None,
2087 &settings,
2088 false,
2089 )
2090 .await;
2091
2092 // restore permissions to allow temporary directory cleanup
2093 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2094 .await?;
2095
2096 assert!(result.is_err(), "link into read-only parent should fail");
2097 let err = result.unwrap_err();
2098 let err_msg = format!("{:#}", err.source);
2099 assert!(
2100 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2101 "Error message must include permission denied text. Got: {}",
2102 err_msg
2103 );
2104 Ok(())
2105 }
2106
2107 #[tokio::test]
2108 #[traced_test]
2109 async fn hard_link_file_into_readonly_parent_returns_error() -> Result<(), anyhow::Error> {
2110 // regression: hard_link_helper used to silently ignore non-AlreadyExists errors
2111 // and report hard_links_created=1 when the underlying hard_link call had failed
2112 let tmp_dir = testutils::setup_test_dir().await?;
2113 let src = tmp_dir.join("src.txt");
2114 tokio::fs::write(&src, "content").await?;
2115 let readonly_parent = tmp_dir.join("readonly_parent");
2116 tokio::fs::create_dir(&readonly_parent).await?;
2117 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o555))
2118 .await?;
2119 let dst = readonly_parent.join("dst.txt");
2120 let settings = common_settings(false, false);
2121 let result = link(&PROGRESS, &tmp_dir, &src, &dst, &None, &settings, false).await;
2122 tokio::fs::set_permissions(&readonly_parent, std::fs::Permissions::from_mode(0o755))
2123 .await?;
2124 let err = result.expect_err("link into read-only parent should fail");
2125 assert_eq!(err.summary.hard_links_created, 0);
2126 let err_msg = format!("{:#}", err.source);
2127 assert!(
2128 err_msg.to_lowercase().contains("permission denied") || err_msg.contains("EACCES"),
2129 "error should include root cause, got: {err_msg}"
2130 );
2131 Ok(())
2132 }
2133
2134 pub async fn setup_update_dir(tmp_dir: &std::path::Path) -> Result<(), anyhow::Error> {
2135 // update
2136 // |- 0.txt
2137 // |- bar
2138 // |- 1.txt
2139 // |- 2.txt -> ../0.txt
2140 let foo_path = tmp_dir.join("update");
2141 tokio::fs::create_dir(&foo_path).await.unwrap();
2142 tokio::fs::write(foo_path.join("0.txt"), "0-new")
2143 .await
2144 .unwrap();
2145 let bar_path = foo_path.join("bar");
2146 tokio::fs::create_dir(&bar_path).await.unwrap();
2147 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2148 .await
2149 .unwrap();
2150 tokio::fs::symlink("../1.txt", bar_path.join("2.txt"))
2151 .await
2152 .unwrap();
2153 tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
2154 Ok(())
2155 }
2156
2157 #[tokio::test]
2158 #[traced_test]
2159 async fn test_link_update() -> Result<(), anyhow::Error> {
2160 let tmp_dir = testutils::setup_test_dir().await?;
2161 setup_update_dir(&tmp_dir).await?;
2162 let test_path = tmp_dir.as_path();
2163 let summary = link(
2164 &PROGRESS,
2165 test_path,
2166 &test_path.join("foo"),
2167 &test_path.join("bar"),
2168 &Some(test_path.join("update")),
2169 &common_settings(false, false),
2170 false,
2171 )
2172 .await?;
2173 assert_eq!(summary.hard_links_created, 2);
2174 assert_eq!(summary.copy_summary.files_copied, 2);
2175 assert_eq!(summary.copy_summary.symlinks_created, 3);
2176 assert_eq!(summary.copy_summary.directories_created, 3);
2177 // compare subset of src and dst
2178 testutils::check_dirs_identical(
2179 &test_path.join("foo").join("baz"),
2180 &test_path.join("bar").join("baz"),
2181 testutils::FileEqualityCheck::HardLink,
2182 )
2183 .await?;
2184 // compare update and dst
2185 testutils::check_dirs_identical(
2186 &test_path.join("update"),
2187 &test_path.join("bar"),
2188 testutils::FileEqualityCheck::Timestamp,
2189 )
2190 .await?;
2191 Ok(())
2192 }
2193
2194 #[tokio::test]
2195 #[traced_test]
2196 async fn test_link_update_exclusive() -> Result<(), anyhow::Error> {
2197 let tmp_dir = testutils::setup_test_dir().await?;
2198 setup_update_dir(&tmp_dir).await?;
2199 let test_path = tmp_dir.as_path();
2200 let mut settings = common_settings(false, false);
2201 settings.update_exclusive = true;
2202 let summary = link(
2203 &PROGRESS,
2204 test_path,
2205 &test_path.join("foo"),
2206 &test_path.join("bar"),
2207 &Some(test_path.join("update")),
2208 &settings,
2209 false,
2210 )
2211 .await?;
2212 // we should end up with same directory as the update
2213 // |- 0.txt
2214 // |- bar
2215 // |- 1.txt
2216 // |- 2.txt -> ../0.txt
2217 assert_eq!(summary.hard_links_created, 0);
2218 assert_eq!(summary.copy_summary.files_copied, 2);
2219 assert_eq!(summary.copy_summary.symlinks_created, 1);
2220 assert_eq!(summary.copy_summary.directories_created, 2);
2221 // compare update and dst
2222 testutils::check_dirs_identical(
2223 &test_path.join("update"),
2224 &test_path.join("bar"),
2225 testutils::FileEqualityCheck::Timestamp,
2226 )
2227 .await?;
2228 Ok(())
2229 }
2230
2231 async fn setup_test_dir_and_link() -> Result<std::path::PathBuf, anyhow::Error> {
2232 let tmp_dir = testutils::setup_test_dir().await?;
2233 let test_path = tmp_dir.as_path();
2234 let summary = link(
2235 &PROGRESS,
2236 test_path,
2237 &test_path.join("foo"),
2238 &test_path.join("bar"),
2239 &None,
2240 &common_settings(false, false),
2241 false,
2242 )
2243 .await?;
2244 assert_eq!(summary.hard_links_created, 5);
2245 assert_eq!(summary.copy_summary.symlinks_created, 2);
2246 assert_eq!(summary.copy_summary.directories_created, 3);
2247 Ok(tmp_dir)
2248 }
2249
2250 #[tokio::test]
2251 #[traced_test]
2252 async fn test_link_overwrite_basic() -> Result<(), anyhow::Error> {
2253 let tmp_dir = setup_test_dir_and_link().await?;
2254 let output_path = &tmp_dir.join("bar");
2255 {
2256 // bar
2257 // |- 0.txt
2258 // |- bar <---------------------------------------- REMOVE
2259 // |- 1.txt <----------------------------------- REMOVE
2260 // |- 2.txt <----------------------------------- REMOVE
2261 // |- 3.txt <----------------------------------- REMOVE
2262 // |- baz
2263 // |- 4.txt
2264 // |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
2265 // |- 6.txt -> (absolute path) .../foo/bar/3.txt
2266 let summary = rm::rm(
2267 &PROGRESS,
2268 &output_path.join("bar"),
2269 &rm::Settings {
2270 fail_early: false,
2271 filter: None,
2272 dry_run: None,
2273 time_filter: None,
2274 },
2275 )
2276 .await?
2277 + rm::rm(
2278 &PROGRESS,
2279 &output_path.join("baz").join("5.txt"),
2280 &rm::Settings {
2281 fail_early: false,
2282 filter: None,
2283 dry_run: None,
2284 time_filter: None,
2285 },
2286 )
2287 .await?;
2288 assert_eq!(summary.files_removed, 3);
2289 assert_eq!(summary.symlinks_removed, 1);
2290 assert_eq!(summary.directories_removed, 1);
2291 }
2292 let summary = link(
2293 &PROGRESS,
2294 &tmp_dir,
2295 &tmp_dir.join("foo"),
2296 output_path,
2297 &None,
2298 &common_settings(false, true), // overwrite!
2299 false,
2300 )
2301 .await?;
2302 assert_eq!(summary.hard_links_created, 3);
2303 assert_eq!(summary.copy_summary.symlinks_created, 1);
2304 assert_eq!(summary.copy_summary.directories_created, 1);
2305 testutils::check_dirs_identical(
2306 &tmp_dir.join("foo"),
2307 output_path,
2308 testutils::FileEqualityCheck::Timestamp,
2309 )
2310 .await?;
2311 Ok(())
2312 }
2313
2314 #[tokio::test]
2315 #[traced_test]
2316 async fn test_link_update_overwrite_basic() -> Result<(), anyhow::Error> {
2317 let tmp_dir = setup_test_dir_and_link().await?;
2318 let output_path = &tmp_dir.join("bar");
2319 {
2320 // bar
2321 // |- 0.txt
2322 // |- bar <---------------------------------------- REMOVE
2323 // |- 1.txt <----------------------------------- REMOVE
2324 // |- 2.txt <----------------------------------- REMOVE
2325 // |- 3.txt <----------------------------------- REMOVE
2326 // |- baz
2327 // |- 4.txt
2328 // |- 5.txt -> ../bar/2.txt <-------------------- REMOVE
2329 // |- 6.txt -> (absolute path) .../foo/bar/3.txt
2330 let summary = rm::rm(
2331 &PROGRESS,
2332 &output_path.join("bar"),
2333 &rm::Settings {
2334 fail_early: false,
2335 filter: None,
2336 dry_run: None,
2337 time_filter: None,
2338 },
2339 )
2340 .await?
2341 + rm::rm(
2342 &PROGRESS,
2343 &output_path.join("baz").join("5.txt"),
2344 &rm::Settings {
2345 fail_early: false,
2346 filter: None,
2347 dry_run: None,
2348 time_filter: None,
2349 },
2350 )
2351 .await?;
2352 assert_eq!(summary.files_removed, 3);
2353 assert_eq!(summary.symlinks_removed, 1);
2354 assert_eq!(summary.directories_removed, 1);
2355 }
2356 setup_update_dir(&tmp_dir).await?;
2357 // update
2358 // |- 0.txt
2359 // |- bar
2360 // |- 1.txt
2361 // |- 2.txt -> ../0.txt
2362 let summary = link(
2363 &PROGRESS,
2364 &tmp_dir,
2365 &tmp_dir.join("foo"),
2366 output_path,
2367 &Some(tmp_dir.join("update")),
2368 &common_settings(false, true), // overwrite!
2369 false,
2370 )
2371 .await?;
2372 assert_eq!(summary.hard_links_created, 1); // 3.txt
2373 assert_eq!(summary.copy_summary.files_copied, 2); // 0.txt, 1.txt
2374 assert_eq!(summary.copy_summary.symlinks_created, 2); // 2.txt, 5.txt
2375 assert_eq!(summary.copy_summary.directories_created, 1);
2376 // compare subset of src and dst
2377 testutils::check_dirs_identical(
2378 &tmp_dir.join("foo").join("baz"),
2379 &tmp_dir.join("bar").join("baz"),
2380 testutils::FileEqualityCheck::HardLink,
2381 )
2382 .await?;
2383 // compare update and dst
2384 testutils::check_dirs_identical(
2385 &tmp_dir.join("update"),
2386 &tmp_dir.join("bar"),
2387 testutils::FileEqualityCheck::Timestamp,
2388 )
2389 .await?;
2390 Ok(())
2391 }
2392
2393 #[tokio::test]
2394 #[traced_test]
2395 async fn test_link_overwrite_hardlink_file() -> Result<(), anyhow::Error> {
2396 let tmp_dir = setup_test_dir_and_link().await?;
2397 let output_path = &tmp_dir.join("bar");
2398 {
2399 // bar
2400 // |- 0.txt
2401 // |- bar
2402 // |- 1.txt <----------------------------------- REPLACE W/ FILE
2403 // |- 2.txt <----------------------------------- REPLACE W/ SYMLINK
2404 // |- 3.txt <----------------------------------- REPLACE W/ DIRECTORY
2405 // |- baz <-------------------------------------- REPLACE W/ FILE
2406 // |- ...
2407 let bar_path = output_path.join("bar");
2408 let summary = rm::rm(
2409 &PROGRESS,
2410 &bar_path.join("1.txt"),
2411 &rm::Settings {
2412 fail_early: false,
2413 filter: None,
2414 dry_run: None,
2415 time_filter: None,
2416 },
2417 )
2418 .await?
2419 + rm::rm(
2420 &PROGRESS,
2421 &bar_path.join("2.txt"),
2422 &rm::Settings {
2423 fail_early: false,
2424 filter: None,
2425 dry_run: None,
2426 time_filter: None,
2427 },
2428 )
2429 .await?
2430 + rm::rm(
2431 &PROGRESS,
2432 &bar_path.join("3.txt"),
2433 &rm::Settings {
2434 fail_early: false,
2435 filter: None,
2436 dry_run: None,
2437 time_filter: None,
2438 },
2439 )
2440 .await?
2441 + rm::rm(
2442 &PROGRESS,
2443 &output_path.join("baz"),
2444 &rm::Settings {
2445 fail_early: false,
2446 filter: None,
2447 dry_run: None,
2448 time_filter: None,
2449 },
2450 )
2451 .await?;
2452 assert_eq!(summary.files_removed, 4);
2453 assert_eq!(summary.symlinks_removed, 2);
2454 assert_eq!(summary.directories_removed, 1);
2455 // REPLACE with a file, a symlink, a directory and a file
2456 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2457 .await
2458 .unwrap();
2459 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2460 .await
2461 .unwrap();
2462 tokio::fs::create_dir(&bar_path.join("3.txt"))
2463 .await
2464 .unwrap();
2465 tokio::fs::write(&output_path.join("baz"), "baz")
2466 .await
2467 .unwrap();
2468 }
2469 let summary = link(
2470 &PROGRESS,
2471 &tmp_dir,
2472 &tmp_dir.join("foo"),
2473 output_path,
2474 &None,
2475 &common_settings(false, true), // overwrite!
2476 false,
2477 )
2478 .await?;
2479 assert_eq!(summary.hard_links_created, 4);
2480 assert_eq!(summary.copy_summary.files_copied, 0);
2481 assert_eq!(summary.copy_summary.symlinks_created, 2);
2482 assert_eq!(summary.copy_summary.directories_created, 1);
2483 testutils::check_dirs_identical(
2484 &tmp_dir.join("foo"),
2485 &tmp_dir.join("bar"),
2486 testutils::FileEqualityCheck::HardLink,
2487 )
2488 .await?;
2489 Ok(())
2490 }
2491
2492 #[tokio::test]
2493 #[traced_test]
2494 async fn test_link_overwrite_error() -> Result<(), anyhow::Error> {
2495 let tmp_dir = setup_test_dir_and_link().await?;
2496 let output_path = &tmp_dir.join("bar");
2497 {
2498 // bar
2499 // |- 0.txt
2500 // |- bar
2501 // |- 1.txt <----------------------------------- REPLACE W/ FILE
2502 // |- 2.txt <----------------------------------- REPLACE W/ SYMLINK
2503 // |- 3.txt <----------------------------------- REPLACE W/ DIRECTORY
2504 // |- baz <-------------------------------------- REPLACE W/ FILE
2505 // |- ...
2506 let bar_path = output_path.join("bar");
2507 let summary = rm::rm(
2508 &PROGRESS,
2509 &bar_path.join("1.txt"),
2510 &rm::Settings {
2511 fail_early: false,
2512 filter: None,
2513 dry_run: None,
2514 time_filter: None,
2515 },
2516 )
2517 .await?
2518 + rm::rm(
2519 &PROGRESS,
2520 &bar_path.join("2.txt"),
2521 &rm::Settings {
2522 fail_early: false,
2523 filter: None,
2524 dry_run: None,
2525 time_filter: None,
2526 },
2527 )
2528 .await?
2529 + rm::rm(
2530 &PROGRESS,
2531 &bar_path.join("3.txt"),
2532 &rm::Settings {
2533 fail_early: false,
2534 filter: None,
2535 dry_run: None,
2536 time_filter: None,
2537 },
2538 )
2539 .await?
2540 + rm::rm(
2541 &PROGRESS,
2542 &output_path.join("baz"),
2543 &rm::Settings {
2544 fail_early: false,
2545 filter: None,
2546 dry_run: None,
2547 time_filter: None,
2548 },
2549 )
2550 .await?;
2551 assert_eq!(summary.files_removed, 4);
2552 assert_eq!(summary.symlinks_removed, 2);
2553 assert_eq!(summary.directories_removed, 1);
2554 // REPLACE with a file, a symlink, a directory and a file
2555 tokio::fs::write(bar_path.join("1.txt"), "1-new")
2556 .await
2557 .unwrap();
2558 tokio::fs::symlink("../0.txt", bar_path.join("2.txt"))
2559 .await
2560 .unwrap();
2561 tokio::fs::create_dir(&bar_path.join("3.txt"))
2562 .await
2563 .unwrap();
2564 tokio::fs::write(&output_path.join("baz"), "baz")
2565 .await
2566 .unwrap();
2567 }
2568 let source_path = &tmp_dir.join("foo");
2569 // unreadable
2570 tokio::fs::set_permissions(
2571 &source_path.join("baz"),
2572 std::fs::Permissions::from_mode(0o000),
2573 )
2574 .await?;
2575 // bar
2576 // |- ...
2577 // |- baz <- NON READABLE
2578 match link(
2579 &PROGRESS,
2580 &tmp_dir,
2581 &tmp_dir.join("foo"),
2582 output_path,
2583 &None,
2584 &common_settings(false, true), // overwrite!
2585 false,
2586 )
2587 .await
2588 {
2589 Ok(_) => panic!("Expected the link to error!"),
2590 Err(error) => {
2591 tracing::info!("{:#}", &error);
2592 assert_eq!(error.summary.hard_links_created, 3);
2593 assert_eq!(error.summary.copy_summary.files_copied, 0);
2594 assert_eq!(error.summary.copy_summary.symlinks_created, 0);
2595 assert_eq!(error.summary.copy_summary.directories_created, 0);
2596 assert_eq!(error.summary.copy_summary.rm_summary.files_removed, 1);
2597 assert_eq!(error.summary.copy_summary.rm_summary.directories_removed, 1);
2598 assert_eq!(error.summary.copy_summary.rm_summary.symlinks_removed, 1);
2599 }
2600 }
2601 Ok(())
2602 }
2603
2604 /// Verify that directory metadata is applied even when child link operations fail.
2605 /// This is a regression test for a bug where directory permissions were not preserved
2606 /// when linking with fail_early=false and some children failed to link.
2607 #[tokio::test]
2608 #[traced_test]
2609 async fn test_link_directory_metadata_applied_on_child_error() -> Result<(), anyhow::Error> {
2610 let tmp_dir = testutils::create_temp_dir().await?;
2611 let test_path = tmp_dir.as_path();
2612 // create source directory with specific permissions
2613 let src_dir = test_path.join("src");
2614 tokio::fs::create_dir(&src_dir).await?;
2615 tokio::fs::set_permissions(&src_dir, std::fs::Permissions::from_mode(0o750)).await?;
2616 // create a readable file (will be linked successfully)
2617 tokio::fs::write(src_dir.join("readable.txt"), "content").await?;
2618 // create a subdirectory with a file, then make the subdirectory unreadable
2619 // this will cause the recursive walk to fail when trying to read subdirectory contents
2620 let unreadable_subdir = src_dir.join("unreadable_subdir");
2621 tokio::fs::create_dir(&unreadable_subdir).await?;
2622 tokio::fs::write(unreadable_subdir.join("hidden.txt"), "secret").await?;
2623 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o000))
2624 .await?;
2625 let dst_dir = test_path.join("dst");
2626 // link with fail_early=false
2627 let result = link(
2628 &PROGRESS,
2629 test_path,
2630 &src_dir,
2631 &dst_dir,
2632 &None,
2633 &common_settings(false, false),
2634 false,
2635 )
2636 .await;
2637 // restore permissions so cleanup can succeed
2638 tokio::fs::set_permissions(&unreadable_subdir, std::fs::Permissions::from_mode(0o755))
2639 .await?;
2640 // verify the operation returned an error (unreadable subdirectory should fail)
2641 assert!(
2642 result.is_err(),
2643 "link should fail due to unreadable subdirectory"
2644 );
2645 let error = result.unwrap_err();
2646 // verify the readable file was linked successfully
2647 assert_eq!(error.summary.hard_links_created, 1);
2648 // verify the destination directory exists and has the correct permissions
2649 let dst_metadata = tokio::fs::metadata(&dst_dir).await?;
2650 assert!(dst_metadata.is_dir());
2651 let actual_mode = dst_metadata.permissions().mode() & 0o7777;
2652 assert_eq!(
2653 actual_mode, 0o750,
2654 "directory should have preserved source permissions (0o750), got {:o}",
2655 actual_mode
2656 );
2657 Ok(())
2658 }
2659 mod filter_tests {
2660 use super::*;
2661 use crate::filter::FilterSettings;
2662 /// Test that path-based patterns (with /) work correctly with nested paths.
2663 #[tokio::test]
2664 #[traced_test]
2665 async fn test_path_pattern_matches_nested_files() -> Result<(), anyhow::Error> {
2666 let tmp_dir = testutils::setup_test_dir().await?;
2667 let test_path = tmp_dir.as_path();
2668 // create filter that should only link files in bar/ directory
2669 let mut filter = FilterSettings::new();
2670 filter.add_include("bar/*.txt").unwrap();
2671 let summary = link(
2672 &PROGRESS,
2673 test_path,
2674 &test_path.join("foo"),
2675 &test_path.join("dst"),
2676 &None,
2677 &Settings {
2678 copy_settings: CopySettings {
2679 dereference: false,
2680 fail_early: false,
2681 overwrite: false,
2682 overwrite_compare: Default::default(),
2683 overwrite_filter: None,
2684 ignore_existing: false,
2685 chunk_size: 0,
2686 skip_specials: false,
2687 remote_copy_buffer_size: 0,
2688 filter: None,
2689 dry_run: None,
2690 delete: None,
2691 },
2692 update_compare: Default::default(),
2693 update_exclusive: false,
2694 filter: Some(filter),
2695 dry_run: None,
2696 preserve: preserve::preserve_all(),
2697 },
2698 false,
2699 )
2700 .await?;
2701 // should only link files matching bar/*.txt pattern (bar/1.txt, bar/2.txt, bar/3.txt)
2702 assert_eq!(
2703 summary.hard_links_created, 3,
2704 "should link 3 files matching bar/*.txt"
2705 );
2706 // verify the right files were linked
2707 assert!(
2708 test_path.join("dst/bar/1.txt").exists(),
2709 "bar/1.txt should be linked"
2710 );
2711 assert!(
2712 test_path.join("dst/bar/2.txt").exists(),
2713 "bar/2.txt should be linked"
2714 );
2715 assert!(
2716 test_path.join("dst/bar/3.txt").exists(),
2717 "bar/3.txt should be linked"
2718 );
2719 // verify files outside the pattern don't exist
2720 assert!(
2721 !test_path.join("dst/0.txt").exists(),
2722 "0.txt should not be linked"
2723 );
2724 Ok(())
2725 }
2726 /// Regression: with a filter active and `fail_early = false`, a directory whose only
2727 /// traversed child FAILS becomes "empty" and is pruned by the empty-dir cleanup — the child
2728 /// failure must still surface, not be masked as success. copy.rs's `finalize_dir` guards
2729 /// this in its DryRunSkip/Remove arms; `link_dir_contents` must do the same.
2730 #[tokio::test]
2731 #[traced_test]
2732 async fn test_filter_pruned_empty_dir_surfaces_child_error() -> Result<(), anyhow::Error> {
2733 let tmp_dir = testutils::create_temp_dir().await?;
2734 let test_path = tmp_dir.as_path();
2735 // src/ is the root; src/sub/ is traversal-only under the filter (it does not directly
2736 // match), and its sole child `unreadable/` (mode 0o000) fails to open during the walk.
2737 // nothing links into sub/, so the empty-dir cleanup prunes it.
2738 let src_dir = test_path.join("src");
2739 let unreadable = src_dir.join("sub").join("unreadable");
2740 tokio::fs::create_dir_all(&unreadable).await?;
2741 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2742 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2743 // an include pattern that matches nothing present forces traversal of sub/ and
2744 // unreadable/ without directly matching sub/, so sub/ is "traversal-only".
2745 let mut filter = FilterSettings::new();
2746 filter.add_include("*.match").unwrap();
2747 let mut settings = common_settings(false, false);
2748 settings.filter = Some(filter);
2749 let result = link(
2750 &PROGRESS,
2751 test_path,
2752 &src_dir,
2753 &test_path.join("dst"),
2754 &None,
2755 &settings,
2756 false,
2757 )
2758 .await;
2759 // restore perms so the temp dir can be cleaned up
2760 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2761 assert!(
2762 result.is_err(),
2763 "a child link failure inside a filter-pruned empty directory must surface as an \
2764 error, not be masked as success"
2765 );
2766 Ok(())
2767 }
2768 /// As above but in dry-run mode, which hits the `DryRunSkip` arm instead of `Remove`: a
2769 /// collected child error must still surface rather than being reported as a clean dry run.
2770 #[tokio::test]
2771 #[traced_test]
2772 async fn test_filter_pruned_empty_dir_surfaces_child_error_dry_run()
2773 -> Result<(), anyhow::Error> {
2774 let tmp_dir = testutils::create_temp_dir().await?;
2775 let test_path = tmp_dir.as_path();
2776 let src_dir = test_path.join("src");
2777 let unreadable = src_dir.join("sub").join("unreadable");
2778 tokio::fs::create_dir_all(&unreadable).await?;
2779 tokio::fs::write(unreadable.join("x.txt"), "secret").await?;
2780 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
2781 let mut filter = FilterSettings::new();
2782 filter.add_include("*.match").unwrap();
2783 let mut settings = common_settings(false, false);
2784 settings.filter = Some(filter);
2785 settings.dry_run = Some(crate::config::DryRunMode::Brief);
2786 settings.copy_settings.dry_run = Some(crate::config::DryRunMode::Brief);
2787 let result = link(
2788 &PROGRESS,
2789 test_path,
2790 &src_dir,
2791 &test_path.join("dst"),
2792 &None,
2793 &settings,
2794 false,
2795 )
2796 .await;
2797 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o755)).await?;
2798 assert!(
2799 result.is_err(),
2800 "dry-run must also surface the child error, not report a clean run"
2801 );
2802 Ok(())
2803 }
2804 /// Test that filters are applied to top-level file arguments.
2805 #[tokio::test]
2806 #[traced_test]
2807 async fn test_filter_applies_to_single_file_source() -> Result<(), anyhow::Error> {
2808 let tmp_dir = testutils::setup_test_dir().await?;
2809 let test_path = tmp_dir.as_path();
2810 // create filter that excludes .txt files
2811 let mut filter = FilterSettings::new();
2812 filter.add_exclude("*.txt").unwrap();
2813 let summary = link(
2814 &PROGRESS,
2815 test_path,
2816 &test_path.join("foo/0.txt"), // single file source
2817 &test_path.join("dst/0.txt"),
2818 &None,
2819 &Settings {
2820 copy_settings: CopySettings {
2821 dereference: false,
2822 fail_early: false,
2823 overwrite: false,
2824 overwrite_compare: Default::default(),
2825 overwrite_filter: None,
2826 ignore_existing: false,
2827 chunk_size: 0,
2828 skip_specials: false,
2829 remote_copy_buffer_size: 0,
2830 filter: None,
2831 dry_run: None,
2832 delete: None,
2833 },
2834 update_compare: Default::default(),
2835 update_exclusive: false,
2836 filter: Some(filter),
2837 dry_run: None,
2838 preserve: preserve::preserve_all(),
2839 },
2840 false,
2841 )
2842 .await?;
2843 // the file should NOT be linked because it matches the exclude pattern
2844 assert_eq!(
2845 summary.hard_links_created, 0,
2846 "file matching exclude pattern should not be linked"
2847 );
2848 assert!(
2849 !test_path.join("dst/0.txt").exists(),
2850 "excluded file should not exist at destination"
2851 );
2852 Ok(())
2853 }
2854 /// Test that filters apply to root directories with simple exclude patterns.
2855 #[tokio::test]
2856 #[traced_test]
2857 async fn test_filter_applies_to_root_directory() -> Result<(), anyhow::Error> {
2858 let test_path = testutils::create_temp_dir().await?;
2859 // create a directory that should be excluded
2860 tokio::fs::create_dir_all(test_path.join("excluded_dir")).await?;
2861 tokio::fs::write(test_path.join("excluded_dir/file.txt"), "content").await?;
2862 // create filter that excludes *_dir/ directories
2863 let mut filter = FilterSettings::new();
2864 filter.add_exclude("*_dir/").unwrap();
2865 let result = link(
2866 &PROGRESS,
2867 &test_path,
2868 &test_path.join("excluded_dir"),
2869 &test_path.join("dst"),
2870 &None,
2871 &Settings {
2872 copy_settings: CopySettings {
2873 dereference: false,
2874 fail_early: false,
2875 overwrite: false,
2876 overwrite_compare: Default::default(),
2877 overwrite_filter: None,
2878 ignore_existing: false,
2879 chunk_size: 0,
2880 skip_specials: false,
2881 remote_copy_buffer_size: 0,
2882 filter: None,
2883 dry_run: None,
2884 delete: None,
2885 },
2886 update_compare: Default::default(),
2887 update_exclusive: false,
2888 filter: Some(filter),
2889 dry_run: None,
2890 preserve: preserve::preserve_all(),
2891 },
2892 false,
2893 )
2894 .await?;
2895 // directory should NOT be linked because it matches exclude pattern
2896 assert_eq!(
2897 result.copy_summary.directories_created, 0,
2898 "root directory matching exclude should not be created"
2899 );
2900 assert!(
2901 !test_path.join("dst").exists(),
2902 "excluded root directory should not exist at destination"
2903 );
2904 Ok(())
2905 }
2906 /// Test that filters apply to root symlinks with simple exclude patterns.
2907 #[tokio::test]
2908 #[traced_test]
2909 async fn test_filter_applies_to_root_symlink() -> Result<(), anyhow::Error> {
2910 let test_path = testutils::create_temp_dir().await?;
2911 // create a target file and a symlink to it
2912 tokio::fs::write(test_path.join("target.txt"), "content").await?;
2913 tokio::fs::symlink(
2914 test_path.join("target.txt"),
2915 test_path.join("excluded_link"),
2916 )
2917 .await?;
2918 // create filter that excludes *_link
2919 let mut filter = FilterSettings::new();
2920 filter.add_exclude("*_link").unwrap();
2921 let result = link(
2922 &PROGRESS,
2923 &test_path,
2924 &test_path.join("excluded_link"),
2925 &test_path.join("dst"),
2926 &None,
2927 &Settings {
2928 copy_settings: CopySettings {
2929 dereference: false,
2930 fail_early: false,
2931 overwrite: false,
2932 overwrite_compare: Default::default(),
2933 overwrite_filter: None,
2934 ignore_existing: false,
2935 chunk_size: 0,
2936 skip_specials: false,
2937 remote_copy_buffer_size: 0,
2938 filter: None,
2939 dry_run: None,
2940 delete: None,
2941 },
2942 update_compare: Default::default(),
2943 update_exclusive: false,
2944 filter: Some(filter),
2945 dry_run: None,
2946 preserve: preserve::preserve_all(),
2947 },
2948 false,
2949 )
2950 .await?;
2951 // symlink should NOT be copied because it matches exclude pattern
2952 assert_eq!(
2953 result.copy_summary.symlinks_created, 0,
2954 "root symlink matching exclude should not be created"
2955 );
2956 assert!(
2957 !test_path.join("dst").exists(),
2958 "excluded root symlink should not exist at destination"
2959 );
2960 Ok(())
2961 }
2962 /// Test combined include and exclude patterns (exclude takes precedence).
2963 #[tokio::test]
2964 #[traced_test]
2965 async fn test_combined_include_exclude_patterns() -> Result<(), anyhow::Error> {
2966 let tmp_dir = testutils::setup_test_dir().await?;
2967 let test_path = tmp_dir.as_path();
2968 // test structure from setup_test_dir:
2969 // foo/
2970 // 0.txt
2971 // bar/ (1.txt, 2.txt, 3.txt)
2972 // baz/ (4.txt, 5.txt symlink, 6.txt symlink)
2973 // include all .txt files in bar/, but exclude 2.txt specifically
2974 let mut filter = FilterSettings::new();
2975 filter.add_include("bar/*.txt").unwrap();
2976 filter.add_exclude("bar/2.txt").unwrap();
2977 let summary = link(
2978 &PROGRESS,
2979 test_path,
2980 &test_path.join("foo"),
2981 &test_path.join("dst"),
2982 &None,
2983 &Settings {
2984 copy_settings: CopySettings {
2985 dereference: false,
2986 fail_early: false,
2987 overwrite: false,
2988 overwrite_compare: Default::default(),
2989 overwrite_filter: None,
2990 ignore_existing: false,
2991 chunk_size: 0,
2992 skip_specials: false,
2993 remote_copy_buffer_size: 0,
2994 filter: None,
2995 dry_run: None,
2996 delete: None,
2997 },
2998 update_compare: Default::default(),
2999 update_exclusive: false,
3000 filter: Some(filter),
3001 dry_run: None,
3002 preserve: preserve::preserve_all(),
3003 },
3004 false,
3005 )
3006 .await?;
3007 // should link: bar/1.txt, bar/3.txt = 2 hard links
3008 // should skip: bar/2.txt (excluded by pattern), 0.txt (excluded by default - no match) = 2 files
3009 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
3010 assert_eq!(
3011 summary.copy_summary.files_skipped, 2,
3012 "should skip 2 files (bar/2.txt excluded, 0.txt no match)"
3013 );
3014 // verify
3015 assert!(
3016 test_path.join("dst/bar/1.txt").exists(),
3017 "bar/1.txt should be linked"
3018 );
3019 assert!(
3020 !test_path.join("dst/bar/2.txt").exists(),
3021 "bar/2.txt should be excluded"
3022 );
3023 assert!(
3024 test_path.join("dst/bar/3.txt").exists(),
3025 "bar/3.txt should be linked"
3026 );
3027 Ok(())
3028 }
3029 /// Test that skipped counts accurately reflect what was filtered.
3030 #[tokio::test]
3031 #[traced_test]
3032 async fn test_skipped_counts_comprehensive() -> Result<(), anyhow::Error> {
3033 let tmp_dir = testutils::setup_test_dir().await?;
3034 let test_path = tmp_dir.as_path();
3035 // test structure from setup_test_dir:
3036 // foo/
3037 // 0.txt
3038 // bar/ (1.txt, 2.txt, 3.txt)
3039 // baz/ (4.txt, 5.txt symlink, 6.txt symlink)
3040 // exclude bar/ directory entirely
3041 let mut filter = FilterSettings::new();
3042 filter.add_exclude("bar/").unwrap();
3043 let summary = link(
3044 &PROGRESS,
3045 test_path,
3046 &test_path.join("foo"),
3047 &test_path.join("dst"),
3048 &None,
3049 &Settings {
3050 copy_settings: CopySettings {
3051 dereference: false,
3052 fail_early: false,
3053 overwrite: false,
3054 overwrite_compare: Default::default(),
3055 overwrite_filter: None,
3056 ignore_existing: false,
3057 chunk_size: 0,
3058 skip_specials: false,
3059 remote_copy_buffer_size: 0,
3060 filter: None,
3061 dry_run: None,
3062 delete: None,
3063 },
3064 update_compare: Default::default(),
3065 update_exclusive: false,
3066 filter: Some(filter),
3067 dry_run: None,
3068 preserve: preserve::preserve_all(),
3069 },
3070 false,
3071 )
3072 .await?;
3073 // linked: 0.txt (1 hard link), baz/4.txt (1 hard link)
3074 // symlinks copied: 5.txt, 6.txt
3075 // skipped: bar directory (1 dir)
3076 assert_eq!(summary.hard_links_created, 2, "should create 2 hard links");
3077 assert_eq!(
3078 summary.copy_summary.symlinks_created, 2,
3079 "should copy 2 symlinks"
3080 );
3081 assert_eq!(
3082 summary.copy_summary.directories_skipped, 1,
3083 "should skip 1 directory (bar)"
3084 );
3085 // bar should not exist in dst
3086 assert!(
3087 !test_path.join("dst/bar").exists(),
3088 "bar directory should not be linked"
3089 );
3090 Ok(())
3091 }
3092 /// Test that empty directories are not created when they were only traversed to look
3093 /// for matches (regression test for bug where --include='foo' would create empty dir baz).
3094 #[tokio::test]
3095 #[traced_test]
3096 async fn test_empty_dir_not_created_when_only_traversed() -> Result<(), anyhow::Error> {
3097 let test_path = testutils::create_temp_dir().await?;
3098 // create structure:
3099 // src/
3100 // foo (file)
3101 // bar (file)
3102 // baz/ (empty directory)
3103 let src_path = test_path.join("src");
3104 tokio::fs::create_dir(&src_path).await?;
3105 tokio::fs::write(src_path.join("foo"), "content").await?;
3106 tokio::fs::write(src_path.join("bar"), "content").await?;
3107 tokio::fs::create_dir(src_path.join("baz")).await?;
3108 // include only 'foo' file
3109 let mut filter = FilterSettings::new();
3110 filter.add_include("foo").unwrap();
3111 let summary = link(
3112 &PROGRESS,
3113 &test_path,
3114 &src_path,
3115 &test_path.join("dst"),
3116 &None,
3117 &Settings {
3118 copy_settings: copy::Settings {
3119 dereference: false,
3120 fail_early: false,
3121 overwrite: false,
3122 overwrite_compare: Default::default(),
3123 overwrite_filter: None,
3124 ignore_existing: false,
3125 chunk_size: 0,
3126 skip_specials: false,
3127 remote_copy_buffer_size: 0,
3128 filter: None,
3129 dry_run: None,
3130 delete: None,
3131 },
3132 update_compare: Default::default(),
3133 update_exclusive: false,
3134 filter: Some(filter),
3135 dry_run: None,
3136 preserve: preserve::preserve_all(),
3137 },
3138 false,
3139 )
3140 .await?;
3141 // only 'foo' should be linked
3142 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3143 assert_eq!(
3144 summary.copy_summary.directories_created, 1,
3145 "should create only root directory (not empty 'baz')"
3146 );
3147 // verify foo was linked
3148 assert!(
3149 test_path.join("dst").join("foo").exists(),
3150 "foo should be linked"
3151 );
3152 // verify bar was not linked (not matching include pattern)
3153 assert!(
3154 !test_path.join("dst").join("bar").exists(),
3155 "bar should not be linked"
3156 );
3157 // verify empty baz directory was NOT created
3158 assert!(
3159 !test_path.join("dst").join("baz").exists(),
3160 "empty baz directory should NOT be created"
3161 );
3162 Ok(())
3163 }
3164 /// Test that directories with only non-matching content are not created at destination.
3165 /// This is different from empty directories - the source dir has content but none matches.
3166 #[tokio::test]
3167 #[traced_test]
3168 async fn test_dir_with_nonmatching_content_not_created() -> Result<(), anyhow::Error> {
3169 let test_path = testutils::create_temp_dir().await?;
3170 // create structure:
3171 // src/
3172 // foo (file)
3173 // baz/
3174 // qux (file - doesn't match 'foo')
3175 // quux (file - doesn't match 'foo')
3176 let src_path = test_path.join("src");
3177 tokio::fs::create_dir(&src_path).await?;
3178 tokio::fs::write(src_path.join("foo"), "content").await?;
3179 tokio::fs::create_dir(src_path.join("baz")).await?;
3180 tokio::fs::write(src_path.join("baz").join("qux"), "content").await?;
3181 tokio::fs::write(src_path.join("baz").join("quux"), "content").await?;
3182 // include only 'foo' file
3183 let mut filter = FilterSettings::new();
3184 filter.add_include("foo").unwrap();
3185 let summary = link(
3186 &PROGRESS,
3187 &test_path,
3188 &src_path,
3189 &test_path.join("dst"),
3190 &None,
3191 &Settings {
3192 copy_settings: copy::Settings {
3193 dereference: false,
3194 fail_early: false,
3195 overwrite: false,
3196 overwrite_compare: Default::default(),
3197 overwrite_filter: None,
3198 ignore_existing: false,
3199 chunk_size: 0,
3200 skip_specials: false,
3201 remote_copy_buffer_size: 0,
3202 filter: None,
3203 dry_run: None,
3204 delete: None,
3205 },
3206 update_compare: Default::default(),
3207 update_exclusive: false,
3208 filter: Some(filter),
3209 dry_run: None,
3210 preserve: preserve::preserve_all(),
3211 },
3212 false,
3213 )
3214 .await?;
3215 // only 'foo' should be linked
3216 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3217 assert_eq!(
3218 summary.copy_summary.files_skipped, 2,
3219 "should skip 2 files (qux and quux)"
3220 );
3221 assert_eq!(
3222 summary.copy_summary.directories_created, 1,
3223 "should create only root directory (not 'baz' with non-matching content)"
3224 );
3225 // verify foo was linked
3226 assert!(
3227 test_path.join("dst").join("foo").exists(),
3228 "foo should be linked"
3229 );
3230 // verify baz directory was NOT created (even though source baz has content)
3231 assert!(
3232 !test_path.join("dst").join("baz").exists(),
3233 "baz directory should NOT be created (no matching content inside)"
3234 );
3235 Ok(())
3236 }
3237 /// Test that empty directories are not reported as created in dry-run mode
3238 /// when they were only traversed.
3239 #[tokio::test]
3240 #[traced_test]
3241 async fn test_dry_run_empty_dir_not_reported_as_created() -> Result<(), anyhow::Error> {
3242 let test_path = testutils::create_temp_dir().await?;
3243 // create structure:
3244 // src/
3245 // foo (file)
3246 // bar (file)
3247 // baz/ (empty directory)
3248 let src_path = test_path.join("src");
3249 tokio::fs::create_dir(&src_path).await?;
3250 tokio::fs::write(src_path.join("foo"), "content").await?;
3251 tokio::fs::write(src_path.join("bar"), "content").await?;
3252 tokio::fs::create_dir(src_path.join("baz")).await?;
3253 // include only 'foo' file
3254 let mut filter = FilterSettings::new();
3255 filter.add_include("foo").unwrap();
3256 let summary = link(
3257 &PROGRESS,
3258 &test_path,
3259 &src_path,
3260 &test_path.join("dst"),
3261 &None,
3262 &Settings {
3263 copy_settings: copy::Settings {
3264 dereference: false,
3265 fail_early: false,
3266 overwrite: false,
3267 overwrite_compare: Default::default(),
3268 overwrite_filter: None,
3269 ignore_existing: false,
3270 chunk_size: 0,
3271 skip_specials: false,
3272 remote_copy_buffer_size: 0,
3273 filter: None,
3274 dry_run: None,
3275 delete: None,
3276 },
3277 update_compare: Default::default(),
3278 update_exclusive: false,
3279 filter: Some(filter),
3280 dry_run: Some(crate::config::DryRunMode::Explain),
3281 preserve: preserve::preserve_all(),
3282 },
3283 false,
3284 )
3285 .await?;
3286 // only 'foo' should be reported as would-be-linked
3287 assert_eq!(
3288 summary.hard_links_created, 1,
3289 "should report only 'foo' would be linked"
3290 );
3291 assert_eq!(
3292 summary.copy_summary.directories_created, 1,
3293 "should report only root directory would be created (not empty 'baz')"
3294 );
3295 // verify nothing was actually created (dry-run mode)
3296 assert!(
3297 !test_path.join("dst").exists(),
3298 "dst should not exist in dry-run"
3299 );
3300 Ok(())
3301 }
3302 /// Test that existing directories are NOT removed when using --overwrite,
3303 /// even if nothing is linked into them due to filters.
3304 #[tokio::test]
3305 #[traced_test]
3306 async fn test_existing_dir_not_removed_with_overwrite() -> Result<(), anyhow::Error> {
3307 let test_path = testutils::create_temp_dir().await?;
3308 // create source structure:
3309 // src/
3310 // foo (file)
3311 // bar (file)
3312 // baz/ (empty directory)
3313 let src_path = test_path.join("src");
3314 tokio::fs::create_dir(&src_path).await?;
3315 tokio::fs::write(src_path.join("foo"), "content").await?;
3316 tokio::fs::write(src_path.join("bar"), "content").await?;
3317 tokio::fs::create_dir(src_path.join("baz")).await?;
3318 // create destination with baz directory already existing
3319 let dst_path = test_path.join("dst");
3320 tokio::fs::create_dir(&dst_path).await?;
3321 tokio::fs::create_dir(dst_path.join("baz")).await?;
3322 // add a marker file inside dst/baz to verify we don't touch it
3323 tokio::fs::write(dst_path.join("baz").join("marker.txt"), "existing").await?;
3324 // include only 'foo' file - baz should not match
3325 let mut filter = FilterSettings::new();
3326 filter.add_include("foo").unwrap();
3327 let summary = link(
3328 &PROGRESS,
3329 &test_path,
3330 &src_path,
3331 &dst_path,
3332 &None,
3333 &Settings {
3334 copy_settings: copy::Settings {
3335 dereference: false,
3336 fail_early: false,
3337 overwrite: true, // enable overwrite mode
3338 overwrite_compare: Default::default(),
3339 overwrite_filter: None,
3340 ignore_existing: false,
3341 chunk_size: 0,
3342 skip_specials: false,
3343 remote_copy_buffer_size: 0,
3344 filter: None,
3345 dry_run: None,
3346 delete: None,
3347 },
3348 update_compare: Default::default(),
3349 update_exclusive: false,
3350 filter: Some(filter),
3351 dry_run: None,
3352 preserve: preserve::preserve_all(),
3353 },
3354 false,
3355 )
3356 .await?;
3357 // foo should be linked
3358 assert_eq!(summary.hard_links_created, 1, "should link only 'foo' file");
3359 // dst and baz should be unchanged (both already existed)
3360 assert_eq!(
3361 summary.copy_summary.directories_unchanged, 2,
3362 "root dst and baz directories should be unchanged"
3363 );
3364 assert_eq!(
3365 summary.copy_summary.directories_created, 0,
3366 "should not create any directories"
3367 );
3368 // verify foo was linked
3369 assert!(dst_path.join("foo").exists(), "foo should be linked");
3370 // verify bar was NOT linked
3371 assert!(!dst_path.join("bar").exists(), "bar should not be linked");
3372 // verify existing baz directory still exists with its content
3373 assert!(
3374 dst_path.join("baz").exists(),
3375 "existing baz directory should still exist"
3376 );
3377 assert!(
3378 dst_path.join("baz").join("marker.txt").exists(),
3379 "existing content in baz should still exist"
3380 );
3381 Ok(())
3382 }
3383
3384 /// Regression: an update-only entry matching an `--exclude` pattern must NOT be copied to
3385 /// the destination when `--delete` is OFF. The fd-based link delegates update-only entries
3386 /// to `copy::copy_child` (which wraps `copy_internal` and does not re-apply a top-level
3387 /// filter), so the update loop must evaluate the filter itself — independently of `--delete`
3388 /// — and skip the delegation, matching the old path-based `copy_with_filter_base`.
3389 #[tokio::test]
3390 #[traced_test]
3391 async fn update_only_excluded_entry_not_copied_without_delete() -> Result<(), anyhow::Error>
3392 {
3393 let test_path = testutils::create_temp_dir().await?;
3394 // src has `keep.txt`; update has `keep.txt` (also in src) plus update-only `extra.txt`
3395 // and `wanted.txt`. With `--exclude extra.txt` and NO `--delete`, `extra.txt` must be
3396 // skipped while `wanted.txt` is copied.
3397 let src = test_path.join("src");
3398 let update = test_path.join("update");
3399 let dst = test_path.join("dst");
3400 tokio::fs::create_dir(&src).await?;
3401 tokio::fs::create_dir(&update).await?;
3402 tokio::fs::write(src.join("keep.txt"), "keep").await?;
3403 tokio::fs::write(update.join("keep.txt"), "keep").await?;
3404 tokio::fs::write(update.join("extra.txt"), "EXCLUDED").await?;
3405 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3406
3407 let mut filter = FilterSettings::new();
3408 filter.add_exclude("extra.txt").unwrap();
3409 let mut settings = common_settings(false, false);
3410 settings.filter = Some(filter);
3411 // --delete is OFF (the bug only manifests with delete off).
3412 assert!(settings.copy_settings.delete.is_none());
3413
3414 let summary = link(
3415 &PROGRESS,
3416 &test_path,
3417 &src,
3418 &dst,
3419 &Some(update.clone()),
3420 &settings,
3421 false,
3422 )
3423 .await?;
3424
3425 assert!(
3426 !dst.join("extra.txt").exists(),
3427 "update-only entry matching --exclude must NOT be copied when --delete is off"
3428 );
3429 assert!(
3430 dst.join("wanted.txt").exists(),
3431 "non-excluded update-only entry should be copied"
3432 );
3433 assert!(dst.join("keep.txt").exists(), "shared entry should exist");
3434 assert_eq!(
3435 summary.copy_summary.files_skipped, 1,
3436 "the excluded update-only file should be counted skipped"
3437 );
3438 Ok(())
3439 }
3440
3441 /// Verify a hard-link relationship between two paths by inode + device identity.
3442 fn are_hardlinked(a: &std::path::Path, b: &std::path::Path) -> bool {
3443 use std::os::unix::fs::MetadataExt;
3444 match (std::fs::symlink_metadata(a), std::fs::symlink_metadata(b)) {
3445 (Ok(ma), Ok(mb)) => ma.ino() == mb.ino() && ma.dev() == mb.dev(),
3446 _ => false,
3447 }
3448 }
3449
3450 /// The chatgpt-codex re-review scenario (PR #247): in rlink's dual-tree walk the source
3451 /// loop evaluates the filter against the SOURCE entry's type. When `src/cache` is a FILE
3452 /// and `update/cache` is a DIRECTORY, a dir-only exclude `cache/` passes the src file (a
3453 /// dir-only pattern doesn't match a file), so `link_internal` runs and hits its
3454 /// type-mismatch branch. Before the fix that branch unconditionally delegated a copy of the
3455 /// UPDATE entry — and `copy_child` does not re-apply the top-level filter to the delegated
3456 /// root — so the excluded `cache/` directory was copied. The fix re-checks the filter using
3457 /// the UPDATE entry's type; the excluded update is dropped and, under union (`--update`)
3458 /// semantics, the src `cache` FILE is materialized instead.
3459 ///
3460 /// This test FAILS without the fix: `dst/cache` is created as the excluded update directory
3461 /// (and the src file is not materialized).
3462 #[tokio::test]
3463 #[traced_test]
3464 async fn type_mismatch_excluded_update_dir_not_copied_src_file_kept()
3465 -> Result<(), anyhow::Error> {
3466 let test_path = testutils::create_temp_dir().await?;
3467 let src = test_path.join("src");
3468 let update = test_path.join("update");
3469 let dst = test_path.join("dst");
3470 tokio::fs::create_dir(&src).await?;
3471 tokio::fs::create_dir(&update).await?;
3472 // src `cache` is a FILE; update `cache` is a DIRECTORY (the type mismatch).
3473 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3474 tokio::fs::create_dir(update.join("cache")).await?;
3475 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3476 // a non-conflicting shared file to confirm normal linking still happens.
3477 tokio::fs::write(src.join("keep.txt"), "keep").await?;
3478 tokio::fs::write(update.join("keep.txt"), "keep").await?;
3479 // pin an identical mtime (incl. nsec) on both `keep.txt` copies so the
3480 // size+mtime `update_compare` deterministically treats them as unchanged and
3481 // hard-links from src. Two separate writes can otherwise land on different
3482 // nanoseconds, flakily comparing as changed and copying instead (the bytes are
3483 // identical either way) — see PR #247 CI flake on test-musl-debug.
3484 let keep_mtime = filetime::FileTime::from_unix_time(1_700_000_000, 0);
3485 filetime::set_file_mtime(src.join("keep.txt"), keep_mtime)?;
3486 filetime::set_file_mtime(update.join("keep.txt"), keep_mtime)?;
3487
3488 let mut filter = FilterSettings::new();
3489 filter.add_exclude("cache/").unwrap(); // dir-only: matches a dir `cache`, not a file
3490 let mut settings = common_settings(false, false);
3491 settings.filter = Some(filter);
3492 assert!(settings.copy_settings.delete.is_none());
3493
3494 let summary = link(
3495 &PROGRESS,
3496 &test_path,
3497 &src,
3498 &dst,
3499 &Some(update.clone()),
3500 &settings,
3501 false,
3502 )
3503 .await?;
3504
3505 // the excluded update directory must NOT be copied.
3506 assert!(
3507 !dst.join("cache").join("inner.dat").exists(),
3508 "excluded update directory `cache/` must not be copied"
3509 );
3510 assert!(
3511 !dst.join("cache").is_dir(),
3512 "dst/cache must not be the excluded update directory"
3513 );
3514 // the src `cache` FILE stands (union semantics) and is hard-linked from src.
3515 assert!(
3516 dst.join("cache").is_file(),
3517 "src `cache` file must be materialized when the update dir is excluded"
3518 );
3519 assert_eq!(
3520 tokio::fs::read_to_string(dst.join("cache")).await?,
3521 "SRC-FILE"
3522 );
3523 assert!(
3524 are_hardlinked(&src.join("cache"), &dst.join("cache")),
3525 "the src `cache` file must be hard-linked into the destination"
3526 );
3527 assert!(
3528 dst.join("keep.txt").exists(),
3529 "shared entry should still link"
3530 );
3531 // `cache` (src file, union) and `keep.txt` (unchanged) are both hard-linked from src;
3532 // nothing is copied. Exactly one directory is created — the `dst` root — proving the
3533 // excluded `cache/` subtree added no directory.
3534 assert_eq!(summary.hard_links_created, 2);
3535 assert_eq!(summary.copy_summary.files_copied, 0);
3536 assert_eq!(
3537 summary.copy_summary.directories_created, 1,
3538 "only the dst root is created; the excluded `cache/` dir must not be"
3539 );
3540 Ok(())
3541 }
3542
3543 /// The REVERSE type mismatch (the symmetric code path): `src/data` is a DIRECTORY and
3544 /// `update/data` is a FILE. The dir-only include `data/` matches the directory form of the
3545 /// name but not the file form, and `data/**` includes the directory's contents. So the src
3546 /// `data` directory (and its `inner.txt`) passes the filter and the src loop spawns the
3547 /// worker, while the update `data` FILE is `ExcludedByDefault` (no include matches a file
3548 /// named `data`). The type-mismatch branch re-checks the filter using the update FILE's
3549 /// type, finds it excluded, and (union semantics) materializes the src DIRECTORY instead of
3550 /// copying the excluded update file. Without the fix the excluded update file would replace
3551 /// the src directory at the destination.
3552 #[tokio::test]
3553 #[traced_test]
3554 async fn reverse_type_mismatch_excluded_update_file_not_copied_src_dir_kept()
3555 -> Result<(), anyhow::Error> {
3556 let test_path = testutils::create_temp_dir().await?;
3557 let src = test_path.join("src");
3558 let update = test_path.join("update");
3559 let dst = test_path.join("dst");
3560 tokio::fs::create_dir(&src).await?;
3561 tokio::fs::create_dir(&update).await?;
3562 // src `data` is a DIRECTORY (with a file inside); update `data` is a FILE.
3563 tokio::fs::create_dir(src.join("data")).await?;
3564 tokio::fs::write(src.join("data").join("inner.txt"), "SRC-DIR-CONTENT").await?;
3565 tokio::fs::write(update.join("data"), "UPDATE-FILE-EXCLUDED").await?;
3566
3567 // `data/` (dir-only) includes the directory form of the name; `data/**` includes its
3568 // contents. The update FILE `data` matches neither and is excluded by type — the
3569 // symmetric form of the bot's scenario.
3570 let mut filter = FilterSettings::new();
3571 filter.add_include("data/").unwrap();
3572 filter.add_include("data/**").unwrap();
3573 let mut settings = common_settings(false, false);
3574 settings.filter = Some(filter);
3575 assert!(settings.copy_settings.delete.is_none());
3576
3577 link(
3578 &PROGRESS,
3579 &test_path,
3580 &src,
3581 &dst,
3582 &Some(update.clone()),
3583 &settings,
3584 false,
3585 )
3586 .await?;
3587
3588 // the excluded update FILE must NOT overwrite/replace the src directory.
3589 assert!(
3590 dst.join("data").is_dir(),
3591 "src `data` directory must be materialized when the update file is excluded"
3592 );
3593 assert!(
3594 dst.join("data").join("inner.txt").exists(),
3595 "src directory contents must be linked through"
3596 );
3597 assert!(
3598 are_hardlinked(
3599 &src.join("data").join("inner.txt"),
3600 &dst.join("data").join("inner.txt")
3601 ),
3602 "src directory's file must be hard-linked into the destination"
3603 );
3604 Ok(())
3605 }
3606
3607 /// `--update-exclusive` + the type-mismatch scenario: src `cache` is a FILE, update `cache`
3608 /// is a DIRECTORY excluded by `cache/`. Under exclusive mode only the (filter-passing)
3609 /// update set materializes, so an EXCLUDED update entry materializes NOTHING — the src is
3610 /// not materialized (it is not a fallback under exclusivity), and no stale src copy is left.
3611 /// This mirrors the NotFound-under-exclusive case (`return Ok(Default::default())`).
3612 #[tokio::test]
3613 #[traced_test]
3614 async fn type_mismatch_excluded_update_dir_update_exclusive_materializes_nothing()
3615 -> Result<(), anyhow::Error> {
3616 let test_path = testutils::create_temp_dir().await?;
3617 let src = test_path.join("src");
3618 let update = test_path.join("update");
3619 let dst = test_path.join("dst");
3620 tokio::fs::create_dir(&src).await?;
3621 tokio::fs::create_dir(&update).await?;
3622 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3623 tokio::fs::create_dir(update.join("cache")).await?;
3624 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3625 // a filter-passing update-only file proves the rest of the exclusive copy still works.
3626 tokio::fs::write(update.join("wanted.txt"), "wanted").await?;
3627
3628 let mut filter = FilterSettings::new();
3629 filter.add_exclude("cache/").unwrap();
3630 let mut settings = common_settings(false, false);
3631 settings.update_exclusive = true;
3632 settings.filter = Some(filter);
3633
3634 link(
3635 &PROGRESS,
3636 &test_path,
3637 &src,
3638 &dst,
3639 &Some(update.clone()),
3640 &settings,
3641 false,
3642 )
3643 .await?;
3644
3645 assert!(
3646 !dst.join("cache").exists(),
3647 "under --update-exclusive an excluded-update type-mismatch must materialize nothing \
3648 (no excluded dir, no stale src file)"
3649 );
3650 assert!(
3651 dst.join("wanted.txt").exists(),
3652 "filter-passing update-only entries are still copied under --update-exclusive"
3653 );
3654 Ok(())
3655 }
3656
3657 /// `--delete` + the type-mismatch scenario under normal `--update`: the src `cache` FILE is
3658 /// materialized (union) and MUST be retained by the keep-set — never materialized-then-pruned
3659 /// — while a pre-existing extraneous dst entry is removed. Also confirms the excluded update
3660 /// directory leaves no leftover. `prune_extraneous` would otherwise prune the dst `cache`
3661 /// file (a dir-only `cache/` exclude does not protect a file), so correctness depends on
3662 /// `cache` staying in the keep-set.
3663 #[tokio::test]
3664 #[traced_test]
3665 async fn type_mismatch_excluded_update_dir_delete_keeps_src_file()
3666 -> Result<(), anyhow::Error> {
3667 let test_path = testutils::create_temp_dir().await?;
3668 let src = test_path.join("src");
3669 let update = test_path.join("update");
3670 let dst = test_path.join("dst");
3671 tokio::fs::create_dir(&src).await?;
3672 tokio::fs::create_dir(&update).await?;
3673 tokio::fs::create_dir(&dst).await?;
3674 tokio::fs::write(src.join("cache"), "SRC-FILE").await?;
3675 tokio::fs::create_dir(update.join("cache")).await?;
3676 tokio::fs::write(update.join("cache").join("inner.dat"), "EXCLUDED").await?;
3677 // pre-existing extraneous dst entry that --delete should prune.
3678 tokio::fs::write(dst.join("stale.txt"), "stale").await?;
3679
3680 let mut filter = FilterSettings::new();
3681 filter.add_exclude("cache/").unwrap();
3682 let mut settings = common_settings(false, true); // --delete implies --overwrite
3683 settings.filter = Some(filter);
3684 settings.copy_settings.delete = Some(copy::DeleteSettings {
3685 delete_excluded: false,
3686 });
3687
3688 link(
3689 &PROGRESS,
3690 &test_path,
3691 &src,
3692 &dst,
3693 &Some(update.clone()),
3694 &settings,
3695 false,
3696 )
3697 .await?;
3698
3699 assert!(
3700 dst.join("cache").is_file(),
3701 "src `cache` file must survive --delete (kept in the keep-set, not pruned)"
3702 );
3703 assert_eq!(
3704 tokio::fs::read_to_string(dst.join("cache")).await?,
3705 "SRC-FILE"
3706 );
3707 assert!(
3708 !dst.join("cache").is_dir(),
3709 "the excluded update directory must leave no leftover"
3710 );
3711 assert!(
3712 !dst.join("stale.txt").exists(),
3713 "extraneous dst entry must be pruned by --delete"
3714 );
3715 Ok(())
3716 }
3717 }
3718 mod dry_run_tests {
3719 use super::*;
3720 /// Test that dry-run mode for files doesn't create hard links.
3721 #[tokio::test]
3722 #[traced_test]
3723 async fn test_dry_run_file_does_not_create_link() -> Result<(), anyhow::Error> {
3724 let tmp_dir = testutils::setup_test_dir().await?;
3725 let test_path = tmp_dir.as_path();
3726 let src_file = test_path.join("foo/0.txt");
3727 let dst_file = test_path.join("dst_link.txt");
3728 // verify destination doesn't exist
3729 assert!(
3730 !dst_file.exists(),
3731 "destination should not exist before dry-run"
3732 );
3733 let summary = link(
3734 &PROGRESS,
3735 test_path,
3736 &src_file,
3737 &dst_file,
3738 &None,
3739 &Settings {
3740 copy_settings: CopySettings {
3741 dereference: false,
3742 fail_early: false,
3743 overwrite: false,
3744 overwrite_compare: Default::default(),
3745 overwrite_filter: None,
3746 ignore_existing: false,
3747 chunk_size: 0,
3748 skip_specials: false,
3749 remote_copy_buffer_size: 0,
3750 filter: None,
3751 dry_run: None,
3752 delete: None,
3753 },
3754 update_compare: Default::default(),
3755 update_exclusive: false,
3756 filter: None,
3757 dry_run: Some(crate::config::DryRunMode::Brief),
3758 preserve: preserve::preserve_all(),
3759 },
3760 false,
3761 )
3762 .await?;
3763 // verify destination still doesn't exist
3764 assert!(!dst_file.exists(), "dry-run should not create hard link");
3765 // verify summary reports what would be created
3766 assert_eq!(
3767 summary.hard_links_created, 1,
3768 "dry-run should report 1 hard link that would be created"
3769 );
3770 Ok(())
3771 }
3772 /// Test that dry-run mode for directories doesn't create the destination directory.
3773 #[tokio::test]
3774 #[traced_test]
3775 async fn test_dry_run_directory_does_not_create_destination() -> Result<(), anyhow::Error> {
3776 let tmp_dir = testutils::setup_test_dir().await?;
3777 let test_path = tmp_dir.as_path();
3778 let dst_path = test_path.join("nonexistent_dst");
3779 // verify destination doesn't exist
3780 assert!(
3781 !dst_path.exists(),
3782 "destination should not exist before dry-run"
3783 );
3784 let summary = link(
3785 &PROGRESS,
3786 test_path,
3787 &test_path.join("foo"),
3788 &dst_path,
3789 &None,
3790 &Settings {
3791 copy_settings: CopySettings {
3792 dereference: false,
3793 fail_early: false,
3794 overwrite: false,
3795 overwrite_compare: Default::default(),
3796 overwrite_filter: None,
3797 ignore_existing: false,
3798 chunk_size: 0,
3799 skip_specials: false,
3800 remote_copy_buffer_size: 0,
3801 filter: None,
3802 dry_run: None,
3803 delete: None,
3804 },
3805 update_compare: Default::default(),
3806 update_exclusive: false,
3807 filter: None,
3808 dry_run: Some(crate::config::DryRunMode::Brief),
3809 preserve: preserve::preserve_all(),
3810 },
3811 false,
3812 )
3813 .await?;
3814 // verify destination still doesn't exist
3815 assert!(
3816 !dst_path.exists(),
3817 "dry-run should not create destination directory"
3818 );
3819 // verify summary reports what would be created
3820 assert!(
3821 summary.hard_links_created > 0,
3822 "dry-run should report hard links that would be created"
3823 );
3824 Ok(())
3825 }
3826 /// Test that dry-run mode correctly reports symlinks (not as hard links).
3827 #[tokio::test]
3828 #[traced_test]
3829 async fn test_dry_run_symlinks_counted_correctly() -> Result<(), anyhow::Error> {
3830 let tmp_dir = testutils::setup_test_dir().await?;
3831 let test_path = tmp_dir.as_path();
3832 // baz contains: 4.txt (file), 5.txt (symlink), 6.txt (symlink)
3833 let src_path = test_path.join("foo/baz");
3834 let dst_path = test_path.join("dst_baz");
3835 // verify destination doesn't exist
3836 assert!(
3837 !dst_path.exists(),
3838 "destination should not exist before dry-run"
3839 );
3840 let summary = link(
3841 &PROGRESS,
3842 test_path,
3843 &src_path,
3844 &dst_path,
3845 &None,
3846 &Settings {
3847 copy_settings: CopySettings {
3848 dereference: false,
3849 fail_early: false,
3850 overwrite: false,
3851 overwrite_compare: Default::default(),
3852 overwrite_filter: None,
3853 ignore_existing: false,
3854 chunk_size: 0,
3855 skip_specials: false,
3856 remote_copy_buffer_size: 0,
3857 filter: None,
3858 dry_run: None,
3859 delete: None,
3860 },
3861 update_compare: Default::default(),
3862 update_exclusive: false,
3863 filter: None,
3864 dry_run: Some(crate::config::DryRunMode::Brief),
3865 preserve: preserve::preserve_all(),
3866 },
3867 false,
3868 )
3869 .await?;
3870 // verify destination still doesn't exist
3871 assert!(!dst_path.exists(), "dry-run should not create destination");
3872 // baz contains 1 regular file (4.txt) and 2 symlinks (5.txt, 6.txt)
3873 assert_eq!(
3874 summary.hard_links_created, 1,
3875 "dry-run should report 1 hard link (for 4.txt)"
3876 );
3877 assert_eq!(
3878 summary.copy_summary.symlinks_created, 2,
3879 "dry-run should report 2 symlinks (5.txt and 6.txt)"
3880 );
3881 Ok(())
3882 }
3883 }
3884
3885 /// Verify that fail-early preserves the summary from the failing subtree.
3886 ///
3887 /// Regression test: the fail-early return path in the join loop must
3888 /// accumulate error.summary from the failing child into the parent's
3889 /// link_summary. Without this, directories_created from the child subtree
3890 /// would be lost.
3891 #[tokio::test]
3892 #[traced_test]
3893 async fn test_fail_early_preserves_summary_from_failing_subtree() -> Result<(), anyhow::Error> {
3894 let tmp_dir = testutils::create_temp_dir().await?;
3895 let test_path = tmp_dir.as_path();
3896 // src/sub/ has a file and an unreadable subdirectory:
3897 // src/sub/good.txt <-- links successfully
3898 // src/sub/unreadable_dir/ <-- mode 000, can't be traversed
3899 // src/sub/unreadable_dir/f.txt
3900 let src_dir = test_path.join("src");
3901 let sub_dir = src_dir.join("sub");
3902 let bad_dir = sub_dir.join("unreadable_dir");
3903 tokio::fs::create_dir_all(&bad_dir).await?;
3904 tokio::fs::write(sub_dir.join("good.txt"), "content").await?;
3905 tokio::fs::write(bad_dir.join("f.txt"), "data").await?;
3906 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o000)).await?;
3907 let dst_dir = test_path.join("dst");
3908 let result = link(
3909 &PROGRESS,
3910 test_path,
3911 &src_dir,
3912 &dst_dir,
3913 &None,
3914 &Settings {
3915 copy_settings: CopySettings {
3916 fail_early: true,
3917 ..common_settings(false, false).copy_settings
3918 },
3919 ..common_settings(false, false)
3920 },
3921 false,
3922 )
3923 .await;
3924 // restore permissions for cleanup
3925 tokio::fs::set_permissions(&bad_dir, std::fs::Permissions::from_mode(0o755)).await?;
3926 let error = result.expect_err("link should fail due to unreadable directory");
3927 // sub/'s link_internal created dst/sub/ (directories_created=1) before
3928 // its join loop encountered the unreadable_dir error. that directory
3929 // creation must be reflected in the error summary propagated up to the
3930 // top-level caller.
3931 assert!(
3932 error.summary.copy_summary.directories_created >= 2,
3933 "fail-early summary should include directories from the failing subtree, \
3934 got directories_created={} (expected >= 2: dst/ and dst/sub/)",
3935 error.summary.copy_summary.directories_created
3936 );
3937 Ok(())
3938 }
3939
3940 #[tokio::test]
3941 #[traced_test]
3942 async fn skip_specials_skips_socket_in_link() -> Result<(), anyhow::Error> {
3943 let tmp_dir = testutils::setup_test_dir().await?;
3944 let test_path = tmp_dir.as_path();
3945 let src = test_path.join("src_dir");
3946 let dst = test_path.join("dst_dir");
3947 tokio::fs::create_dir(&src).await?;
3948 tokio::fs::write(src.join("file.txt"), "hello").await?;
3949 let _listener = std::os::unix::net::UnixListener::bind(src.join("test.sock"))?;
3950 let mut settings = common_settings(false, false);
3951 settings.copy_settings.skip_specials = true;
3952 let summary = link(&PROGRESS, test_path, &src, &dst, &None, &settings, false).await?;
3953 assert_eq!(summary.hard_links_created, 1);
3954 assert_eq!(summary.copy_summary.specials_skipped, 1);
3955 assert!(dst.join("file.txt").exists());
3956 assert!(!dst.join("test.sock").exists());
3957 Ok(())
3958 }
3959
3960 #[tokio::test]
3961 #[traced_test]
3962 async fn delete_skips_pruning_when_link_has_errors() -> Result<(), anyhow::Error> {
3963 let tmp_dir = testutils::setup_test_dir().await?;
3964 let test_path = tmp_dir.as_path();
3965 let src = test_path.join("foo");
3966 let dst = test_path.join("bar");
3967 // baseline link establishes the destination (no delete)
3968 link(
3969 &PROGRESS,
3970 test_path,
3971 &src,
3972 &dst,
3973 &None,
3974 &common_settings(false, false),
3975 false,
3976 )
3977 .await?;
3978 // an extraneous file that --delete would normally prune
3979 tokio::fs::write(dst.join("extraneous.txt"), b"junk").await?;
3980 // make a source sub-directory unreadable so traversal fails (fail_early is false).
3981 // a directory is used because --overwrite with mtime-equal files skips copying
3982 // identical files; a directory's read_dir fails unconditionally when mode is 0o000.
3983 let unreadable = src.join("baz");
3984 let original = tokio::fs::metadata(&unreadable).await?.permissions();
3985 tokio::fs::set_permissions(&unreadable, std::fs::Permissions::from_mode(0o000)).await?;
3986
3987 let delete_settings = Settings {
3988 copy_settings: CopySettings {
3989 overwrite: true,
3990 fail_early: false,
3991 delete: Some(copy::DeleteSettings {
3992 delete_excluded: false,
3993 }),
3994 ..common_settings(false, true).copy_settings
3995 },
3996 ..common_settings(false, true)
3997 };
3998 let result = link(
3999 &PROGRESS,
4000 test_path,
4001 &src,
4002 &dst,
4003 &None,
4004 &delete_settings,
4005 false,
4006 )
4007 .await;
4008
4009 tokio::fs::set_permissions(&unreadable, original).await?;
4010
4011 assert!(
4012 result.is_err(),
4013 "link of the unreadable directory should fail"
4014 );
4015 assert!(
4016 dst.join("extraneous.txt").exists(),
4017 "pruning must be skipped when the link/update pass reported errors"
4018 );
4019 Ok(())
4020 }
4021
4022 #[tokio::test]
4023 #[traced_test]
4024 async fn skip_specials_top_level_socket_in_link() -> Result<(), anyhow::Error> {
4025 let tmp_dir = testutils::setup_test_dir().await?;
4026 let test_path = tmp_dir.as_path();
4027 let src_socket = test_path.join("test.sock");
4028 let dst = test_path.join("dst.sock");
4029 let _listener = std::os::unix::net::UnixListener::bind(&src_socket)?;
4030 let mut settings = common_settings(false, false);
4031 settings.copy_settings.skip_specials = true;
4032 let summary = link(
4033 &PROGRESS,
4034 test_path,
4035 &src_socket,
4036 &dst,
4037 &None,
4038 &settings,
4039 false,
4040 )
4041 .await?;
4042 assert_eq!(summary.copy_summary.specials_skipped, 1);
4043 assert_eq!(summary.hard_links_created, 0);
4044 assert!(!dst.exists());
4045 Ok(())
4046 }
4047
4048 /// Stress tests exercising max-open-files saturation during link.
4049 mod max_open_files_tests {
4050 use super::*;
4051
4052 /// deep + wide link: directory tree deeper than the open-files limit, with files
4053 /// at every level. verifies no deadlock occurs (directories don't consume permits).
4054 #[tokio::test]
4055 #[traced_test]
4056 async fn deep_tree_no_deadlock_under_open_files_saturation() -> Result<(), anyhow::Error> {
4057 let tmp_dir = testutils::create_temp_dir().await?;
4058 let src = tmp_dir.join("src");
4059 let dst = tmp_dir.join("dst");
4060 let depth = 20;
4061 let files_per_level = 5;
4062 let limit = 4;
4063 // create a directory chain deeper than the permit limit, with files at each level
4064 let mut dir = src.clone();
4065 for level in 0..depth {
4066 tokio::fs::create_dir_all(&dir).await?;
4067 for f in 0..files_per_level {
4068 tokio::fs::write(
4069 dir.join(format!("f{}_{}.txt", level, f)),
4070 format!("L{}F{}", level, f),
4071 )
4072 .await?;
4073 }
4074 dir = dir.join(format!("d{}", level));
4075 }
4076 throttle::set_max_open_files(limit);
4077 let summary = tokio::time::timeout(
4078 std::time::Duration::from_secs(30),
4079 link(
4080 &PROGRESS,
4081 tmp_dir.as_path(),
4082 &src,
4083 &dst,
4084 &None,
4085 &common_settings(false, false),
4086 false,
4087 ),
4088 )
4089 .await
4090 .context("link timed out — possible deadlock")?
4091 .context("link failed")?;
4092 assert_eq!(summary.hard_links_created, depth * files_per_level);
4093 assert_eq!(summary.copy_summary.directories_created, depth);
4094 // spot-check that hard links work by reading content at a few levels
4095 let mut check_dir = dst.clone();
4096 for level in 0..depth {
4097 let content =
4098 tokio::fs::read_to_string(check_dir.join(format!("f{}_0.txt", level))).await?;
4099 assert_eq!(content, format!("L{}F0", level));
4100 check_dir = check_dir.join(format!("d{}", level));
4101 }
4102 Ok(())
4103 }
4104
4105 /// Regression: link_internal's spawn-time guard must be released before
4106 /// delegating to copy::copy on the file-type-changed path.
4107 ///
4108 /// Scenario: many src entries are regular files (so the spawn loop
4109 /// pre-acquires open-files permits for them), but the corresponding
4110 /// `update` entries are directories (file types differ). link_internal
4111 /// then calls copy::copy on the update directory, which enters
4112 /// copy_internal. If the spawn-time permit were still held while
4113 /// copy::copy ran, copy_internal's own open-files acquire for any
4114 /// inner file would deadlock against a saturated pool.
4115 #[tokio::test]
4116 #[traced_test]
4117 async fn parallel_update_filetype_change_no_deadlock() -> Result<(), anyhow::Error> {
4118 let tmp_dir = testutils::create_temp_dir().await?;
4119 let src = tmp_dir.join("src");
4120 let update = tmp_dir.join("update");
4121 let dst = tmp_dir.join("dst");
4122 tokio::fs::create_dir(&src).await?;
4123 tokio::fs::create_dir(&update).await?;
4124 let n = 8;
4125 // src/eN: regular files. update/eN: directories with inner files.
4126 // file types differ -> link takes the !is_file_type_same branch
4127 // -> calls copy::copy(update/eN, dst/eN).
4128 for i in 0..n {
4129 tokio::fs::write(src.join(format!("e{}", i)), format!("src-{}", i)).await?;
4130 let upd_subdir = update.join(format!("e{}", i));
4131 tokio::fs::create_dir(&upd_subdir).await?;
4132 for j in 0..3 {
4133 tokio::fs::write(
4134 upd_subdir.join(format!("inner_{}.txt", j)),
4135 format!("upd-{}-{}", i, j),
4136 )
4137 .await?;
4138 }
4139 }
4140 // saturate the open-files pool: spawn-time permits held by every
4141 // outer link task would block copy::copy's inner permit acquires.
4142 throttle::set_max_open_files(2);
4143 let summary = tokio::time::timeout(
4144 std::time::Duration::from_secs(30),
4145 link(
4146 &PROGRESS,
4147 tmp_dir.as_path(),
4148 &src,
4149 &dst,
4150 &Some(update.clone()),
4151 &common_settings(false, false),
4152 false,
4153 ),
4154 )
4155 .await
4156 .context(
4157 "link timed out — caller-supplied open-files guard not released before copy::copy",
4158 )?
4159 .context("link failed")?;
4160 // every entry was a type-mismatch -> copied from update.
4161 // copy::copy on a directory creates the dir and copies inner files.
4162 assert_eq!(summary.copy_summary.directories_created, n + 1); // +1 for dst itself
4163 assert_eq!(summary.copy_summary.files_copied, n * 3);
4164 // verify content came from update, not src
4165 for i in 0..n {
4166 for j in 0..3 {
4167 let content =
4168 tokio::fs::read_to_string(dst.join(format!("e{}/inner_{}.txt", i, j)))
4169 .await?;
4170 assert_eq!(content, format!("upd-{}-{}", i, j));
4171 }
4172 }
4173 Ok(())
4174 }
4175
4176 /// Regression: the "update-only entries" spawn loop must not deadlock
4177 /// against copy::copy's open-files OR against rm::rm's pending-meta.
4178 ///
4179 /// Scenario: update has many regular files that don't exist in src.
4180 /// The loop at site 3 spawns a copy::copy task per entry under a
4181 /// saturated open-files pool. copy::copy's internal acquires must
4182 /// proceed normally — site 3 must not be holding open-files.
4183 #[tokio::test]
4184 #[traced_test]
4185 async fn update_only_entries_bounded_no_deadlock() -> Result<(), anyhow::Error> {
4186 let tmp_dir = testutils::create_temp_dir().await?;
4187 let src = tmp_dir.join("src");
4188 let update = tmp_dir.join("update");
4189 let dst = tmp_dir.join("dst");
4190 tokio::fs::create_dir(&src).await?;
4191 tokio::fs::create_dir(&update).await?;
4192 // src is empty; update has many regular files. Every update entry
4193 // is "missing in src" -> hits the site-3 spawn loop.
4194 let n = 50;
4195 for i in 0..n {
4196 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4197 }
4198 throttle::set_max_open_files(2);
4199 let summary = tokio::time::timeout(
4200 std::time::Duration::from_secs(30),
4201 link(
4202 &PROGRESS,
4203 tmp_dir.as_path(),
4204 &src,
4205 &dst,
4206 &Some(update.clone()),
4207 &common_settings(false, false),
4208 false,
4209 ),
4210 )
4211 .await
4212 .context("link timed out — site-3 spawn loop deadlock")?
4213 .context("link failed")?;
4214 // dst gets the src directory plus a copy of every update file
4215 assert_eq!(summary.copy_summary.directories_created, 1);
4216 assert_eq!(summary.copy_summary.files_copied, n);
4217 for i in 0..n {
4218 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4219 assert_eq!(content, format!("upd-{}", i));
4220 }
4221 Ok(())
4222 }
4223
4224 /// Regression for the link site-3 ↔ rm pending-meta self-deadlock.
4225 ///
4226 /// Scenario: update has many entries not in src; dst already has
4227 /// directories at those same names; the user passes --overwrite. Each
4228 /// site-3 task runs copy::copy → copy_file → rm::rm to remove the
4229 /// preexisting dst directory before placing the regular-file copy.
4230 /// rm::rm draws from the pending-meta pool. If site 3 also held
4231 /// pending-meta across copy::copy, every running task would hold a
4232 /// permit while waiting on inner rm to acquire one — classic
4233 /// self-deadlock once the pool is saturated.
4234 #[tokio::test]
4235 #[traced_test]
4236 async fn update_only_overwrite_preexisting_dirs_no_deadlock() -> Result<(), anyhow::Error> {
4237 let tmp_dir = testutils::create_temp_dir().await?;
4238 let src = tmp_dir.join("src");
4239 let update = tmp_dir.join("update");
4240 let dst = tmp_dir.join("dst");
4241 tokio::fs::create_dir(&src).await?;
4242 tokio::fs::create_dir(&update).await?;
4243 tokio::fs::create_dir(&dst).await?;
4244 let n = 12;
4245 for i in 0..n {
4246 // update/uN is a regular file (site 3 will copy it).
4247 tokio::fs::write(update.join(format!("u{}", i)), format!("upd-{}", i)).await?;
4248 // dst/uN is a preexisting directory with inner files. With
4249 // --overwrite, copy_file calls rm::rm to wipe it, which
4250 // recurses into pending-meta.
4251 let dst_subdir = dst.join(format!("u{}", i));
4252 tokio::fs::create_dir(&dst_subdir).await?;
4253 for j in 0..3 {
4254 tokio::fs::write(
4255 dst_subdir.join(format!("inner_{}.txt", j)),
4256 format!("old-{}-{}", i, j),
4257 )
4258 .await?;
4259 }
4260 }
4261 // saturate both pools to force the deadlock if the cycle existed.
4262 throttle::set_max_open_files(2);
4263 let summary = tokio::time::timeout(
4264 std::time::Duration::from_secs(30),
4265 link(
4266 &PROGRESS,
4267 tmp_dir.as_path(),
4268 &src,
4269 &dst,
4270 &Some(update.clone()),
4271 &common_settings(false, true), // overwrite=true
4272 false,
4273 ),
4274 )
4275 .await
4276 .context("link timed out — pending-meta self-deadlock between site 3 and inner rm")?
4277 .context("link failed")?;
4278 // each preexisting dst/uN directory gets removed and replaced
4279 // with a regular-file copy from update/uN.
4280 assert_eq!(summary.copy_summary.files_copied, n);
4281 assert_eq!(summary.copy_summary.rm_summary.files_removed, n * 3);
4282 assert_eq!(summary.copy_summary.rm_summary.directories_removed, n);
4283 // verify content came from update
4284 for i in 0..n {
4285 let content = tokio::fs::read_to_string(dst.join(format!("u{}", i))).await?;
4286 assert_eq!(content, format!("upd-{}", i));
4287 }
4288 Ok(())
4289 }
4290 }
4291
4292 /// TOCTOU hardening: a source entry being hard-linked is concurrently swapped between a real
4293 /// regular file and a symlink to a sentinel OUTSIDE the source tree. rlink classifies the entry
4294 /// via `child` (fstat) before acting and links the pinned inode inode-exactly
4295 /// (`hard_link_handle_at`), so a swap is either caught (the entry is linked/copied as a symlink,
4296 /// or the op fails closed) or the real file is hard-linked. The sentinel's secret content must
4297 /// NEVER appear at the destination as a regular file, and the sentinel inode must never gain a
4298 /// new hard link.
4299 mod race_tests {
4300 use super::*;
4301
4302 // Repeatedly swap `dir/entry_name` between a real regular file (content `REAL_CONTENT`) and
4303 // a symlink pointing at `sentinel`, using rename so each individual state is atomic. Runs on
4304 // a dedicated OS thread so it makes progress regardless of the tokio runtime's scheduling.
4305 fn spawn_file_symlink_swapper(
4306 dir: std::path::PathBuf,
4307 entry_name: &'static str,
4308 sentinel: std::path::PathBuf,
4309 stop: std::sync::Arc<std::sync::atomic::AtomicBool>,
4310 ) -> std::thread::JoinHandle<()> {
4311 std::thread::spawn(move || {
4312 let entry = dir.join(entry_name);
4313 let staged_real = dir.join("__staged_real");
4314 let staged_link = dir.join("__staged_link");
4315 while !stop.load(std::sync::atomic::Ordering::Relaxed) {
4316 let _ = std::fs::remove_file(&staged_real);
4317 if std::fs::write(&staged_real, b"REAL_CONTENT").is_err() {
4318 continue;
4319 }
4320 let _ = std::fs::rename(&staged_real, &entry);
4321 let _ = std::fs::remove_file(&staged_link);
4322 let _ = std::os::unix::fs::symlink(&sentinel, &staged_link);
4323 let _ = std::fs::rename(&staged_link, &entry);
4324 }
4325 })
4326 }
4327
4328 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4329 #[traced_test]
4330 async fn hard_link_entry_swap_never_leaks_sentinel() -> Result<(), anyhow::Error> {
4331 let tmp_dir = testutils::create_temp_dir().await?;
4332 let test_path = tmp_dir.as_path();
4333 // sentinel lives OUTSIDE the source tree with distinctive content; we also track its
4334 // hard-link count to prove `linkat(flags=0)` never gives it a new hard link.
4335 let sentinel = test_path.join("sentinel_secret");
4336 tokio::fs::write(&sentinel, "SENTINEL_SECRET_CONTENT").await?;
4337 let sentinel_links_before = {
4338 use std::os::unix::fs::MetadataExt;
4339 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4340 };
4341 let src = test_path.join("src");
4342 let sub = src.join("sub");
4343 tokio::fs::create_dir(&src).await?;
4344 tokio::fs::create_dir(&sub).await?;
4345 tokio::fs::write(sub.join("entry"), "REAL_CONTENT").await?;
4346
4347 let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
4348 let swapper =
4349 spawn_file_symlink_swapper(sub.clone(), "entry", sentinel.clone(), stop.clone());
4350
4351 // overwrite=true so each iteration's destination need not be empty; no update tree, so
4352 // `src/sub/entry` takes the hard-link path (or copy-as-symlink when caught mid-swap).
4353 let settings = common_settings(false, true);
4354 let mut caught_swaps = 0usize;
4355 let mut linked_real = 0usize;
4356 for i in 0..200 {
4357 let dst = test_path.join(format!("dst_{i}"));
4358 let result = tokio::time::timeout(
4359 std::time::Duration::from_secs(30),
4360 link(&PROGRESS, test_path, &src, &dst, &None, &settings, false),
4361 )
4362 .await
4363 .expect("link must not hang under concurrent swapping");
4364 match result {
4365 Ok(_) => {}
4366 Err(_) => caught_swaps += 1, // a swap was caught mid-link (failed closed)
4367 }
4368 // CORE ASSERTION: if a regular file landed at the destination it holds the REAL
4369 // content — never the sentinel's secret. The entry may instead be a symlink
4370 // (linkat made a hard link to the symlink inode, or copy reproduced the symlink) or
4371 // be absent. A symlink that resolves to the sentinel is fine: it is a link, not a
4372 // copy of the secret bytes, and it did not give the sentinel a new hard link.
4373 let entry_dst = dst.join("sub").join("entry");
4374 if let Ok(md) = tokio::fs::symlink_metadata(&entry_dst).await
4375 && md.file_type().is_file()
4376 {
4377 let content = tokio::fs::read_to_string(&entry_dst).await?;
4378 assert_ne!(
4379 content, "SENTINEL_SECRET_CONTENT",
4380 "iteration {i}: sentinel content leaked into the destination as a regular file"
4381 );
4382 assert_eq!(
4383 content, "REAL_CONTENT",
4384 "iteration {i}: a regular destination file must hold the real content"
4385 );
4386 linked_real += 1;
4387 }
4388 let _ = tokio::fs::remove_dir_all(&dst).await;
4389 }
4390
4391 stop.store(true, std::sync::atomic::Ordering::Relaxed);
4392 swapper.join().expect("swapper thread panicked");
4393
4394 // the sentinel must never have gained a hard link from a `linkat` that followed the
4395 // swapped-in symlink (flags=0 links the symlink inode itself, not its target).
4396 let sentinel_links_after = {
4397 use std::os::unix::fs::MetadataExt;
4398 tokio::fs::symlink_metadata(&sentinel).await?.nlink()
4399 };
4400 assert_eq!(
4401 sentinel_links_after, sentinel_links_before,
4402 "the sentinel file must never gain a hard link (linkat must not follow the symlink)"
4403 );
4404 // sanity: the run did observable work (this is not the safety assertion — the safety
4405 // assertions above hold on every iteration regardless of timing).
4406 tracing::info!(
4407 "link file/symlink swap: caught_swaps={caught_swaps}, linked_real={linked_real}"
4408 );
4409 assert!(
4410 caught_swaps + linked_real > 0,
4411 "expected at least one observable outcome across 200 iterations"
4412 );
4413 Ok(())
4414 }
4415 }
4416}