Coverage Report

Created: 2026-07-21 15:28

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-util/src/fs_util.rs
Line
Count
Source
1
// Copyright 2024 The NativeLink Authors. All rights reserved.
2
//
3
// Licensed under the Apache License, Version 2.0 (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    http://www.apache.org/licenses/LICENSE-2.0
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
use core::future::Future;
16
use core::pin::Pin;
17
use std::fs::Metadata;
18
use std::path::{Path, PathBuf};
19
20
use nativelink_error::{Code, Error, ResultExt, error_if, make_err};
21
use tokio::fs;
22
#[cfg(target_os = "macos")]
23
use tracing::debug;
24
25
/// Which kernel mechanism actually materialized the destination tree.
26
/// Returned by [`hardlink_directory_tree`] so callers can record per-hit
27
/// telemetry and detect when the fast path silently degrades (e.g., a
28
/// cross-volume cache layout that forces clonefile to fall through to
29
/// per-file hardlinks).
30
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31
pub enum CloneMethod {
32
    /// APFS `clonefile(2)` succeeded — O(1) regardless of tree size.
33
    /// macOS only.
34
    Clonefile,
35
    /// Per-file `fs::hard_link` walk — O(N) in file count.
36
    /// Used on Linux/Windows always, and on macOS when clonefile fell through.
37
    Hardlink,
38
}
39
40
/// Materializes an entire directory tree from source to destination using the
41
/// fastest method the host filesystem supports.
42
///
43
/// # Arguments
44
/// * `src_dir` - Source directory path (must exist)
45
/// * `dst_dir` - Destination directory path (must NOT exist; parent will be created)
46
///
47
/// # Returns
48
/// * `Ok(CloneMethod)` indicating which kernel mechanism was used
49
/// * `Err` if materialization fails (e.g., cross-filesystem, unsupported filesystem)
50
///
51
/// # Platform Support
52
/// - macOS: Tries APFS `clonefile(2)` first (O(1), copy-on-write). On failure
53
///   (e.g., cross-volume EXDEV, or any unexpected errno) falls back to per-file
54
///   `fs::hard_link`. `clonefile(2)` copies the source's modes verbatim, so
55
///   the destination's directory/file modes mirror the source. For a directory
56
///   cache entry locked down by [`set_readonly_recursive`], that means
57
///   directories are writable (0o755) and files are read-only (0o555): the
58
///   worker can create the action's declared outputs at any nested path, but
59
///   the hardlinked input files stay immutable. This matches the hermeticity
60
///   contract enforced by Bazel's local sandbox and the REAPI
61
///   `Action.output_files` semantics: actions can only write to declared
62
///   outputs, never mutate inputs. The COW semantics of `clonefile(2)` mean
63
///   any writes the worker does make to the destination do not affect the
64
///   source. The destination root is additionally chmod'd to 0o755 as a
65
///   defensive guarantee for callers that did not pre-mark the source.
66
/// - Linux: Per-file `fs::hard_link` (directory hardlinks are not supported on
67
///   ext4/btrfs without root). Directories at the destination are created
68
///   fresh by this walk and chmod'd to a stable, umask-independent 0o755, so
69
///   they are writable regardless of the source's directory modes and of the
70
///   process umask; files are hardlinked and keep the source inode's mode.
71
///   Always returns `CloneMethod::Hardlink`.
72
/// - Windows: Per-file `fs::hard_link` (requires NTFS). Always returns
73
///   `CloneMethod::Hardlink`.
74
///
75
/// # Errors
76
/// - Source directory doesn't exist
77
/// - Destination already exists
78
/// - Cross-filesystem materialization attempted and fallback also fails
79
/// - Filesystem doesn't support hardlinks (Linux/Windows fallback)
80
/// - Permission denied
81
147
pub async fn hardlink_directory_tree(src_dir: &Path, dst_dir: &Path) -> Result<CloneMethod, Error>4
{
82
143
    error_if!(
83
147
        !src_dir.exists(),
84
        "Source directory does not exist: {}",
85
4
        src_dir.display()
86
    );
87
88
1
    error_if!(
89
143
        dst_dir.exists(),
90
        "Destination directory already exists: {}",
91
1
        dst_dir.display()
92
    );
93
94
    #[cfg(target_os = "macos")]
95
    {
96
        // clonefile(2) requires dst's parent to exist but dst itself must NOT
97
        // exist. Make sure the parent is present without creating dst. The
98
        // non-macOS fallback path below creates dst (and any missing parents)
99
        // itself via `fs::create_dir_all(dst_dir)`, so this pre-step is only
100
        // needed for the clonefile case.
101
        if let Some(parent) = dst_dir.parent() {
102
            fs::create_dir_all(parent).await.err_tip(|| {
103
                format!(
104
                    "Failed to create parent of destination: {}",
105
                    parent.display()
106
                )
107
            })?;
108
        }
109
110
        match try_clonefile(src_dir, dst_dir).await {
111
            Ok(()) => {
112
                // `clonefile(2)` copies the source's modes verbatim. A
113
                // directory cache entry locked down by
114
                // `set_readonly_recursive` already has writable directories
115
                // (0o755) and read-only files (0o555), so the clone is
116
                // immediately usable: the worker can create declared outputs
117
                // at any nested path and the hardlinked inputs stay
118
                // immutable. No per-directory chmod walk is needed. The root
119
                // is still chmod'd here as a defensive guarantee for callers
120
                // that pass a source whose root was not pre-marked writable.
121
                chmod_dir_writable(dst_dir)
122
                    .await
123
                    .err_tip(|| "Failed to chmod cloned tree root")?;
124
                return Ok(CloneMethod::Clonefile);
125
            }
126
            Err(e) => {
127
                debug!(
128
                    src = %src_dir.display(),
129
                    dst = %dst_dir.display(),
130
                    error = %e,
131
                    "clonefile failed, falling back to per-file hardlinks"
132
                );
133
                // clonefile(2) is atomic — on failure dst should not exist —
134
                // but be defensive in case a partial tree was left behind.
135
                let _cleanup = fs::remove_dir_all(dst_dir).await;
136
            }
137
        }
138
    }
139
140
    // Create the root destination directory
141
142
    fs::create_dir_all(dst_dir).await.err_tip(|| 
{0
142
0
        format!(
143
            "Failed to create destination directory: {}",
144
0
            dst_dir.display()
145
        )
146
0
    })?;
147
142
    chmod_dir_0o755(dst_dir).await
?0
;
148
149
    // Recursively hardlink the directory tree
150
142
    hardlink_directory_tree_recursive(src_dir, dst_dir).await
?0
;
151
142
    Ok(CloneMethod::Hardlink)
152
147
}
153
154
/// Sets `dir` to mode 0o755 on unix; no-op elsewhere. The per-file hardlink
155
/// walk creates destination directories fresh with `create_dir`, whose mode
156
/// is `0o777 & !umask` — under a restrictive umask (027/077) combined with
157
/// run-as-different-uid sandboxing that yields intermittent `EACCES` on the
158
/// materialized tree. An explicit chmod keeps the documented "directories
159
/// are 0o755" invariant umask-independent. (The macOS `clonefile` path is
160
/// unaffected: it copies the source's modes verbatim, and cache-entry
161
/// sources are built at 0o755.)
162
791
async fn chmod_dir_0o755(dir: &Path) -> Result<(), Error> {
163
    #[cfg(unix)]
164
    {
165
        use std::os::unix::fs::PermissionsExt;
166
791
        fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755))
167
791
            .await
168
791
            .err_tip(|| 
format!0
("Failed to set directory mode: {}",
dir0
.
display0
()))
?0
;
169
    }
170
    #[cfg(not(unix))]
171
    let _ = dir;
172
791
    Ok(())
173
791
}
174
175
/// Recursively clones a directory tree using APFS `clonefile(2)`. On success
176
/// the destination shares data blocks with the source via copy-on-write; the
177
/// operation is O(1) in tree size regardless of file count.
178
///
179
/// Returns `Err` on EXDEV (cross-volume), ENOTSUP (filesystem doesn't support
180
/// clones), or any other errno; callers are expected to fall back to per-file
181
/// hardlinks.
182
#[cfg(target_os = "macos")]
183
async fn try_clonefile(src: &Path, dst: &Path) -> std::io::Result<()> {
184
    use std::ffi::CString;
185
    use std::os::unix::ffi::OsStrExt;
186
187
    // From <sys/clonefile.h>: don't follow symlinks at the top level. Symlinks
188
    // *within* the cloned tree are cloned as symlinks regardless. The `libc`
189
    // crate exposes `clonefile` but not this flag constant.
190
    const CLONE_NOFOLLOW: u32 = 0x0001;
191
192
    let src_c = CString::new(src.as_os_str().as_bytes()).map_err(|_| {
193
        std::io::Error::new(
194
            std::io::ErrorKind::InvalidInput,
195
            "src path contains interior NUL byte",
196
        )
197
    })?;
198
    let dst_c = CString::new(dst.as_os_str().as_bytes()).map_err(|_| {
199
        std::io::Error::new(
200
            std::io::ErrorKind::InvalidInput,
201
            "dst path contains interior NUL byte",
202
        )
203
    })?;
204
205
    crate::spawn_blocking!("clonefile", move || {
206
        // SAFETY: clonefile(2) takes two NUL-terminated C strings and a flag
207
        // word. Both CStrings are owned by this closure for the duration of
208
        // the call, so the pointers stay valid.
209
        let res = unsafe { libc::clonefile(src_c.as_ptr(), dst_c.as_ptr(), CLONE_NOFOLLOW) };
210
        if res == 0 {
211
            Ok(())
212
        } else {
213
            Err(std::io::Error::last_os_error())
214
        }
215
    })
216
    .await
217
    .map_err(std::io::Error::other)?
218
}
219
220
/// Sets the directory `dir`'s mode to 0o755 so callers can create new
221
/// entries inside it. Used after `clonefile(2)` on the materialized
222
/// destination root as a defensive guarantee: a directory cache entry locked
223
/// down by [`set_readonly_recursive`] already has writable directories, so
224
/// for those callers this is a no-op, but it keeps `hardlink_directory_tree`
225
/// correct for any source whose root was not pre-marked writable. Existing
226
/// entries inside `dir` are intentionally left at their cloned perms — files
227
/// stay read-only (the hermeticity contract), directories stay writable.
228
#[cfg(target_os = "macos")]
229
async fn chmod_dir_writable(dir: &Path) -> Result<(), Error> {
230
    use std::os::unix::fs::PermissionsExt;
231
    fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755))
232
        .await
233
        .err_tip(|| format!("Failed to chmod {} to 0o755", dir.display()))
234
}
235
236
/// Internal recursive function to hardlink directory contents
237
791
fn hardlink_directory_tree_recursive<'a>(
238
791
    src: &'a Path,
239
791
    dst: &'a Path,
240
791
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>> {
241
791
    Box::pin(async move {
242
791
        let mut entries = fs::read_dir(src)
243
791
            .await
244
791
            .err_tip(|| 
format!0
("Failed to read directory: {}",
src0
.
display0
()))
?0
;
245
246
10.4k
        while let Some(
entry9.65k
) = entries
247
10.4k
            .next_entry()
248
10.4k
            .await
249
10.4k
            .err_tip(|| 
format!0
("Failed to get next entry in: {}",
src0
.
display0
()))
?0
250
        {
251
9.65k
            let entry_path = entry.path();
252
9.65k
            let file_name = entry.file_name().into_string().map_err(|os_str| 
{0
253
0
                make_err!(
254
0
                    Code::InvalidArgument,
255
                    "Invalid UTF-8 in filename: {:?}",
256
                    os_str
257
                )
258
0
            })?;
259
260
9.65k
            let dst_path = dst.join(&file_name);
261
            // `DirEntry::metadata` does NOT traverse symlinks (it has
262
            // `symlink_metadata`/lstat semantics), so `is_symlink()` below
263
            // correctly identifies symlink entries and the symlink branch
264
            // recreates them as symlinks rather than dereferencing them.
265
9.65k
            let metadata = entry
266
9.65k
                .metadata()
267
9.65k
                .await
268
9.65k
                .err_tip(|| 
format!0
("Failed to get metadata for: {}",
entry_path.display()0
))
?0
;
269
270
9.65k
            if metadata.is_symlink() {
271
                // Recreate the symlink as a symlink. Checked BEFORE `is_dir()`
272
                // / `is_file()` so a symlink that resolves to a directory is
273
                // never treated as a real directory and recursed *through*
274
                // (which would dereference the link and potentially escape
275
                // the tree).
276
542
                let target = fs::read_link(&entry_path)
277
542
                    .await
278
542
                    .err_tip(|| 
format!0
("Failed to read symlink: {}",
entry_path.display()0
))
?0
;
279
280
                #[cfg(unix)]
281
542
                fs::symlink(&target, &dst_path)
282
542
                    .await
283
542
                    .err_tip(|| 
format!0
("Failed to create symlink: {}",
dst_path.display()0
))
?0
;
284
285
                #[cfg(windows)]
286
                {
287
                    if target.is_dir() {
288
                        fs::symlink_dir(&target, &dst_path).await.err_tip(|| {
289
                            format!("Failed to create directory symlink: {}", dst_path.display())
290
                        })?;
291
                    } else {
292
                        fs::symlink_file(&target, &dst_path).await.err_tip(|| {
293
                            format!("Failed to create file symlink: {}", dst_path.display())
294
                        })?;
295
                    }
296
                }
297
9.11k
            } else if metadata.is_dir() {
298
                // Create subdirectory and recurse
299
649
                fs::create_dir(&dst_path)
300
649
                    .await
301
649
                    .err_tip(|| 
format!0
("Failed to create directory: {}",
dst_path.display()0
))
?0
;
302
649
                chmod_dir_0o755(&dst_path).await
?0
;
303
304
649
                hardlink_directory_tree_recursive(&entry_path, &dst_path).await
?0
;
305
8.46k
            } else if metadata.is_file() {
306
                // Hardlink the file
307
8.46k
                fs::hard_link(&entry_path, &dst_path)
308
8.46k
                    .await
309
8.46k
                    .err_tip(|| 
{0
310
0
                        format!(
311
                            "Failed to hardlink {} to {}. This may occur if the source and destination are on different filesystems",
312
0
                            entry_path.display(),
313
0
                            dst_path.display()
314
                        )
315
0
                    })?;
316
0
            }
317
        }
318
319
791
        Ok(())
320
791
    })
321
791
}
322
323
/// Locks down a directory tree as an immutable cache entry: every **file** is
324
/// made read-only, every **directory** is left writable.
325
///
326
/// This is used by the worker's directory cache after it constructs a cache
327
/// entry. Files must be read-only because they are hardlinked into the CAS
328
/// (`FilesystemStore`) — keeping them immutable preserves the hermeticity
329
/// contract (actions cannot mutate inputs) and avoids mutating the shared
330
/// inode's mode for other in-flight actions.
331
///
332
/// Directories are deliberately left writable (0o755). Directories are *not*
333
/// hardlink-shared between cache entries — only file content inodes are — so a
334
/// writable directory mode is safe. Keeping cache-entry directories writable
335
/// means the materialized destination tree (an APFS `clonefile(2)` clone,
336
/// which copies modes verbatim, or a per-file hardlink walk, which creates
337
/// fresh directories) already has writable directories. Bazel actions declare
338
/// outputs at paths nested inside input subdirectories, so every directory in
339
/// the materialized tree must be writable for the worker to create those
340
/// outputs; doing it here, once per cache entry, removes the need for a
341
/// separate per-materialization recursive chmod walk.
342
///
343
/// # Arguments
344
/// * `dir` - Directory tree to lock down
345
///
346
/// # Platform Notes
347
/// - Unix: files get 0o555 (r-xr-xr-x); directories get 0o755 (rwxr-xr-x).
348
/// - Windows: files get `FILE_ATTRIBUTE_READONLY`; directories are left
349
///   writable.
350
///
351
/// Symlink entries in the tree are skipped (their own mode is not meaningful
352
/// and `chmod` would follow the link) - see `set_perms_recursive_impl`.
353
6
pub async fn set_readonly_recursive(dir: &Path) -> Result<(), Error>5
{
354
6
    error_if!(!dir.exists(), "Directory does not exist: {}", 
dir0
.
display0
());
355
356
6
    set_perms_recursive_impl(dir.to_path_buf(), set_readonly_one_path).await
357
6
}
358
359
/// Sets only the **directories** in a tree to writable for the current user,
360
/// leaving files untouched. This is the safe variant for cleanup paths that
361
/// need to delete a tree containing CAS-hardlinked files.
362
///
363
/// On unix, write permission on the parent directory is sufficient to unlink
364
/// files inside it — the files' own modes are irrelevant for unlinking. Chmoding
365
/// a CAS-hardlinked file would silently mutate the shared inode's permissions
366
/// for every other in-flight action that has hardlinked the same blob, leading
367
/// to EACCES on exec or EPERM on open in unrelated actions.
368
///
369
/// # Arguments
370
/// * `dir` - Directory whose directories should be made writable
371
///
372
/// # Platform Notes
373
/// - Unix: Sets directory permissions to 0o755 (rwxr-xr-x); files are NOT touched.
374
/// - Windows: Clears `FILE_ATTRIBUTE_READONLY` on directories only; files are NOT touched.
375
///
376
/// Symlink entries in the tree are skipped (their own mode is not meaningful
377
/// and `chmod` would follow the link) - see `set_perms_recursive_impl`.
378
30
pub async fn set_dir_writable_recursive(dir: &Path) -> Result<(), Error> {
379
30
    error_if!(!dir.exists(), "Directory does not exist: {}", 
dir0
.
display0
());
380
381
30
    set_perms_recursive_impl(dir.to_path_buf(), set_dir_writable_one_path).await
382
29
}
383
384
23
fn set_readonly_one_path(
385
23
    path: PathBuf,
386
23
    metadata: Metadata,
387
23
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send>> {
388
23
    Box::pin(async move {
389
        // Directories are left writable on purpose. They are not
390
        // hardlink-shared between cache entries — only file content inodes
391
        // are — so a writable directory mode cannot corrupt anything. Keeping
392
        // them writable means the materialized destination tree already
393
        // accepts the nested output files Bazel actions declare, with no
394
        // separate per-materialization chmod walk.
395
23
        if metadata.is_dir() {
396
            #[cfg(unix)]
397
            {
398
                use std::os::unix::fs::PermissionsExt;
399
12
                let mut perms = metadata.permissions();
400
12
                perms.set_mode(0o755);
401
402
12
                fs::set_permissions(&path, perms)
403
12
                    .await
404
12
                    .err_tip(|| 
format!0
("Failed to set permissions for: {}",
path.display()0
))
?0
;
405
            }
406
407
            // On Windows directories are already writable; clearing the
408
            // read-only attribute here would be a no-op, so leave them alone.
409
410
12
            return Ok(());
411
11
        }
412
413
        // Set the file to read-only.
414
        #[cfg(unix)]
415
        {
416
            use std::os::unix::fs::PermissionsExt;
417
11
            let mut perms = metadata.permissions();
418
419
            // Files get r-xr-xr-x (0o555): read and execute for everyone,
420
            // write for no one. Files use 0o555 rather than 0o444 so the
421
            // execute bit survives on cached executables — a stripped +x bit
422
            // makes an action's interpreter or wrapper script fail with
423
            // EACCES once the tree is materialized into a workspace. The
424
            // write bit stays cleared, so the hermeticity contract (inputs
425
            // are immutable) is unchanged.
426
11
            perms.set_mode(0o555);
427
428
11
            fs::set_permissions(&path, perms)
429
11
                .await
430
11
                .err_tip(|| 
format!0
("Failed to set permissions for: {}",
path.display()0
))
?0
;
431
        }
432
433
        #[cfg(windows)]
434
        {
435
            let mut perms = metadata.permissions();
436
            perms.set_readonly(true);
437
438
            fs::set_permissions(&path, perms)
439
                .await
440
                .err_tip(|| format!("Failed to set permissions for: {}", path.display()))?;
441
        }
442
443
11
        Ok(())
444
23
    })
445
23
}
446
447
185
fn set_dir_writable_one_path(
448
185
    path: PathBuf,
449
185
    metadata: Metadata,
450
185
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send>> {
451
185
    Box::pin(async move {
452
        // Files are intentionally skipped here. They may be hardlinked into
453
        // the CAS (FilesystemStore); chmoding them would corrupt the shared
454
        // inode's mode for every other in-flight action.
455
185
        if !metadata.is_dir() {
456
135
            return Ok(());
457
50
        }
458
459
        #[cfg(unix)]
460
        {
461
            use std::os::unix::fs::PermissionsExt;
462
50
            let mut perms = metadata.permissions();
463
50
            perms.set_mode(0o755);
464
465
50
            fs::set_permissions(&path, perms)
466
50
                .await
467
49
                .err_tip(|| 
format!0
("Failed to set permissions for: {}",
path.display()0
))
?0
;
468
        }
469
470
        #[cfg(windows)]
471
        {
472
            let mut perms = metadata.permissions();
473
            perms.set_readonly(false);
474
475
            fs::set_permissions(&path, perms)
476
                .await
477
                .err_tip(|| format!("Failed to set permissions for: {}", path.display()))?;
478
        }
479
480
49
        Ok(())
481
184
    })
482
185
}
483
484
253
fn set_perms_recursive_impl<'a, F>(
485
253
    path: PathBuf,
486
253
    perms_fn: F,
487
253
) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send + 'a>>
488
253
where
489
253
    F: Fn(PathBuf, Metadata) -> Pin<Box<dyn Future<Output = Result<(), Error>> + Send>>
490
253
        + Send
491
253
        + Copy
492
253
        + 'a,
493
{
494
253
    Box::pin(async move {
495
        // Use `symlink_metadata` (lstat) rather than `metadata` (stat) so the
496
        // walk inspects the entry *itself*, never the target a symlink points
497
        // at. This matters for input trees containing symlinks - e.g.
498
        // `.venv/bin/python3` created by rules_python / rules_apple venv
499
        // tooling. With plain `stat`, a symlink to a directory reports
500
        // `is_dir() == true` and the walk would recurse *through* the link
501
        // (escaping the tree, or descending into an unrelated directory), and
502
        // a symlink to a file would have `chmod` applied to it - and `chmod`
503
        // follows symlinks, so it mutates the target. A symlink whose target
504
        // does not exist (a dangling link, common when a venv points outside
505
        // the action's input set) then fails the whole walk with ENOENT -
506
        // the cause of directory-cache actions falling back to the slow
507
        // download path.
508
253
        let metadata = fs::symlink_metadata(&path)
509
253
            .await
510
253
            .err_tip(|| 
format!0
("Failed to get metadata for: {}",
path.display()0
))
?0
;
511
512
        // Symlinks are skipped entirely: their own mode is not meaningful, a
513
        // `chmod` on the link path would follow it and touch the target, and
514
        // descending into a symlinked directory would walk outside the tree.
515
        // The symlink entry itself is left exactly as created.
516
253
        if metadata.is_symlink() {
517
45
            return Ok(());
518
208
        }
519
520
208
        if metadata.is_dir() {
521
62
            let mut entries = fs::read_dir(&path)
522
62
                .await
523
62
                .err_tip(|| 
format!0
("Failed to read directory: {}",
path.display()0
))
?0
;
524
525
279
            while let Some(
entry217
) = entries
526
279
                .next_entry()
527
279
                .await
528
279
                .err_tip(|| 
format!0
("Failed to get next entry in: {}",
path.display()0
))
?0
529
            {
530
217
                set_perms_recursive_impl(entry.path(), perms_fn).await
?0
;
531
            }
532
146
        }
533
208
        perms_fn(path, metadata).await
534
252
    })
535
253
}
536
537
/// Calculates the total size of a directory tree in bytes.
538
/// Used for cache size tracking and LRU eviction.
539
///
540
/// # Arguments
541
/// * `dir` - Directory to calculate size for
542
///
543
/// # Returns
544
/// Total size in bytes, or Error if directory cannot be read
545
1
pub async fn calculate_directory_size(dir: &Path) -> Result<u64, Error> {
546
1
    error_if!(!dir.exists(), "Directory does not exist: {}", 
dir0
.
display0
());
547
548
1
    calculate_directory_size_impl(dir).await
549
1
}
550
551
4
fn calculate_directory_size_impl<'a>(
552
4
    path: &'a Path,
553
4
) -> Pin<Box<dyn Future<Output = Result<u64, Error>> + Send + 'a>> {
554
4
    Box::pin(async move {
555
4
        let metadata = fs::metadata(path)
556
4
            .await
557
4
            .err_tip(|| 
format!0
("Failed to get metadata for: {}",
path0
.
display0
()))
?0
;
558
559
4
        if metadata.is_file() {
560
2
            return Ok(metadata.len());
561
2
        }
562
563
2
        if !metadata.is_dir() {
564
0
            return Ok(0);
565
2
        }
566
567
2
        let mut total_size = 0u64;
568
2
        let mut entries = fs::read_dir(path)
569
2
            .await
570
2
            .err_tip(|| 
format!0
("Failed to read directory: {}",
path0
.
display0
()))
?0
;
571
572
5
        while let Some(
entry3
) = entries
573
5
            .next_entry()
574
5
            .await
575
5
            .err_tip(|| 
format!0
("Failed to get next entry in: {}",
path0
.
display0
()))
?0
576
        {
577
3
            total_size += calculate_directory_size_impl(&entry.path()).await
?0
;
578
        }
579
580
2
        Ok(total_size)
581
4
    })
582
4
}
583
584
#[cfg(test)]
585
mod tests {
586
    use std::path::PathBuf;
587
588
    use nativelink_macro::nativelink_test;
589
    use tempfile::TempDir;
590
    use tokio::io::AsyncWriteExt;
591
592
    use super::*;
593
594
8
    async fn create_test_directory() -> Result<(TempDir, PathBuf), Error> {
595
8
        let temp_dir = TempDir::new().err_tip(|| "Failed to create temp directory")
?0
;
596
8
        let test_dir = temp_dir.path().join("test_src");
597
598
8
        fs::create_dir(&test_dir).await
?0
;
599
600
        // Create a file
601
8
        let file1 = test_dir.join("file1.txt");
602
8
        let mut f = fs::File::create(&file1).await
?0
;
603
8
        f.write_all(b"Hello, World!").await
?0
;
604
8
        f.sync_all().await
?0
;
605
8
        drop(f);
606
607
        // Create a subdirectory with a file
608
8
        let subdir = test_dir.join("subdir");
609
8
        fs::create_dir(&subdir).await
?0
;
610
611
8
        let file2 = subdir.join("file2.txt");
612
8
        let mut f = fs::File::create(&file2).await
?0
;
613
8
        f.write_all(b"Nested file").await
?0
;
614
8
        f.sync_all().await
?0
;
615
8
        drop(f);
616
617
8
        Ok((temp_dir, test_dir))
618
8
    }
619
620
    #[nativelink_test("crate")]
621
    async fn test_hardlink_directory_tree() -> Result<(), Error> {
622
        let (temp_dir, src_dir) = create_test_directory().await?;
623
        let dst_dir = temp_dir.path().join("test_dst");
624
625
        // Hardlink the directory
626
        let method = hardlink_directory_tree(&src_dir, &dst_dir).await?;
627
628
        #[cfg(target_os = "macos")]
629
        assert_eq!(method, CloneMethod::Clonefile, "macOS should use clonefile");
630
        #[cfg(not(target_os = "macos"))]
631
        assert_eq!(
632
            method,
633
            CloneMethod::Hardlink,
634
            "non-macOS should use per-file hardlinks"
635
        );
636
637
        // Verify structure
638
        assert!(dst_dir.join("file1.txt").exists());
639
        assert!(dst_dir.join("subdir").is_dir());
640
        assert!(dst_dir.join("subdir/file2.txt").exists());
641
642
        // Verify contents
643
        let content1 = fs::read_to_string(dst_dir.join("file1.txt")).await?;
644
        assert_eq!(content1, "Hello, World!");
645
646
        let content2 = fs::read_to_string(dst_dir.join("subdir/file2.txt")).await?;
647
        assert_eq!(content2, "Nested file");
648
649
        // Linux: per-file hardlinks share inodes with the source.
650
        #[cfg(all(unix, not(target_os = "macos")))]
651
        {
652
            use std::os::unix::fs::MetadataExt;
653
            let src_meta = fs::metadata(src_dir.join("file1.txt")).await?;
654
            let dst_meta = fs::metadata(dst_dir.join("file1.txt")).await?;
655
            assert_eq!(
656
                src_meta.ino(),
657
                dst_meta.ino(),
658
                "Files should have same inode (hardlinked)"
659
            );
660
        }
661
662
        // macOS: clonefile(2) creates distinct inodes that share data via COW.
663
        #[cfg(target_os = "macos")]
664
        {
665
            use std::os::unix::fs::MetadataExt;
666
            let src_meta = fs::metadata(src_dir.join("file1.txt")).await?;
667
            let dst_meta = fs::metadata(dst_dir.join("file1.txt")).await?;
668
            assert_ne!(
669
                src_meta.ino(),
670
                dst_meta.ino(),
671
                "clonefile should create distinct inodes from source"
672
            );
673
        }
674
675
        Ok(())
676
    }
677
678
    #[cfg(target_os = "macos")]
679
    #[nativelink_test("crate")]
680
    async fn test_clonefile_dirs_writable_files_readonly() -> Result<(), Error> {
681
        use std::os::unix::fs::PermissionsExt;
682
683
        let (temp_dir, src_dir) = create_test_directory().await?;
684
        // Source mimics a directory cache entry: writable dirs (0o755),
685
        // read-only files (0o555).
686
        set_readonly_recursive(&src_dir).await?;
687
688
        let dst_dir = temp_dir.path().join("clone_dst");
689
        hardlink_directory_tree(&src_dir, &dst_dir).await?;
690
691
        // Root: writable, so the worker can drop the action's declared
692
        // outputs inside it.
693
        let root_mode = fs::metadata(&dst_dir).await?.permissions().mode() & 0o777;
694
        assert_eq!(root_mode, 0o755, "destination root must be writable");
695
696
        // Nested subdir: writable too. `clonefile(2)` copies the source's
697
        // modes verbatim and the source's directories were left writable by
698
        // `set_readonly_recursive`. Bazel actions declare outputs at paths
699
        // nested inside input subdirectories, so every directory in the
700
        // materialized tree must be writable — no separate chmod walk needed.
701
        let dst_subdir_mode = fs::metadata(dst_dir.join("subdir"))
702
            .await?
703
            .permissions()
704
            .mode()
705
            & 0o777;
706
        assert_eq!(
707
            dst_subdir_mode, 0o755,
708
            "cloned subdirs must be writable so nested outputs can be created"
709
        );
710
711
        // Existing file: stays read-only. Hermeticity contract — inputs are
712
        // not writable. Matches Bazel's local-sandbox model and REAPI
713
        // Action.output_files semantics: actions can only write to declared
714
        // outputs, not mutate inputs.
715
        let dst_file_mode = fs::metadata(dst_dir.join("file1.txt"))
716
            .await?
717
            .permissions()
718
            .mode()
719
            & 0o777;
720
        assert_eq!(
721
            dst_file_mode, 0o555,
722
            "cloned files must inherit source read-only mode"
723
        );
724
725
        // Source untouched: dirs writable, files read-only.
726
        let src_subdir_mode = fs::metadata(src_dir.join("subdir"))
727
            .await?
728
            .permissions()
729
            .mode()
730
            & 0o777;
731
        assert_eq!(
732
            src_subdir_mode, 0o755,
733
            "source dir should still be writable after clone"
734
        );
735
        let src_file_mode = fs::metadata(src_dir.join("file1.txt"))
736
            .await?
737
            .permissions()
738
            .mode()
739
            & 0o777;
740
        assert_eq!(
741
            src_file_mode, 0o555,
742
            "source file should still be read-only after clone"
743
        );
744
745
        Ok(())
746
    }
747
748
    #[cfg(target_os = "macos")]
749
    #[nativelink_test("crate")]
750
    async fn test_clonefile_root_accepts_new_files() -> Result<(), Error> {
751
        let (temp_dir, src_dir) = create_test_directory().await?;
752
        set_readonly_recursive(&src_dir).await?;
753
754
        let dst_dir = temp_dir.path().join("clone_dst");
755
        hardlink_directory_tree(&src_dir, &dst_dir).await?;
756
757
        // The worker creates declared output files at the action's
758
        // working directory root. Verify a new file can be created there
759
        // even though everything inside the clone is read-only (0o555).
760
        let new_output = dst_dir.join("new_output.bin");
761
        fs::write(&new_output, b"action output").await?;
762
        assert_eq!(fs::read(&new_output).await?, b"action output");
763
764
        Ok(())
765
    }
766
767
    #[cfg(target_os = "macos")]
768
    #[nativelink_test("crate")]
769
    async fn test_clonefile_input_mutation_fails() -> Result<(), Error> {
770
        let (temp_dir, src_dir) = create_test_directory().await?;
771
        set_readonly_recursive(&src_dir).await?;
772
773
        let dst_dir = temp_dir.path().join("clone_dst");
774
        hardlink_directory_tree(&src_dir, &dst_dir).await?;
775
776
        // Hermeticity: actions cannot mutate inputs. A write to an input
777
        // file in the cloned tree must fail with EACCES, mirroring what
778
        // Bazel's linux-sandbox / darwin-sandbox would do.
779
        let input_file = dst_dir.join("file1.txt");
780
        let err = fs::write(&input_file, b"mutated")
781
            .await
782
            .expect_err("input file write should fail (file is 0o555, no write bit)");
783
        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
784
785
        // Source must be untouched.
786
        let src_content = fs::read_to_string(src_dir.join("file1.txt")).await?;
787
        assert_eq!(src_content, "Hello, World!");
788
789
        Ok(())
790
    }
791
792
    #[cfg(target_os = "macos")]
793
    #[nativelink_test("crate")]
794
    async fn test_clonefile_cow_isolation() -> Result<(), Error> {
795
        let (temp_dir, src_dir) = create_test_directory().await?;
796
        let dst_dir = temp_dir.path().join("clone_dst");
797
798
        hardlink_directory_tree(&src_dir, &dst_dir).await?;
799
800
        // Mutate the clone and confirm the source is unaffected.
801
        let dst_file = dst_dir.join("file1.txt");
802
        fs::write(&dst_file, b"mutated by clone").await?;
803
804
        let src_content = fs::read_to_string(src_dir.join("file1.txt")).await?;
805
        assert_eq!(
806
            src_content, "Hello, World!",
807
            "source must be untouched after writing to clone (COW)"
808
        );
809
810
        let dst_content = fs::read_to_string(&dst_file).await?;
811
        assert_eq!(dst_content, "mutated by clone");
812
813
        Ok(())
814
    }
815
816
    /// Bazel actions declare outputs at paths nested inside input
817
    /// subdirectories. Because `set_readonly_recursive` leaves directories
818
    /// writable and `clonefile(2)` copies modes verbatim, the materialized
819
    /// tree already accepts a nested output file with NO separate
820
    /// `set_dir_writable_recursive` walk — that is the redundant work this
821
    /// change removes from `prepare_action_inputs`.
822
    #[cfg(target_os = "macos")]
823
    #[nativelink_test("crate")]
824
    async fn test_clonefile_nested_output_without_dir_writable_walk() -> Result<(), Error> {
825
        use std::os::unix::fs::PermissionsExt;
826
827
        let (temp_dir, src_dir) = create_test_directory().await?;
828
        // Lock the source down the way the directory cache does after
829
        // constructing a cache entry: writable dirs, read-only files.
830
        set_readonly_recursive(&src_dir).await?;
831
832
        let dst_dir = temp_dir.path().join("clone_dst");
833
        hardlink_directory_tree(&src_dir, &dst_dir).await?;
834
835
        // Creating an output nested inside a cloned subdir succeeds straight
836
        // away — no recursive chmod walk. This is the post-condition that
837
        // lets `prepare_action_inputs` drop its `set_dir_writable_recursive`
838
        // call.
839
        let nested_output = dst_dir.join("subdir").join("nested_output.o");
840
        fs::write(&nested_output, b"action output").await?;
841
        assert_eq!(fs::read(&nested_output).await?, b"action output");
842
843
        // Files inside the tree stay read-only — hermeticity holds, and the
844
        // CAS-hardlink inode invariant is preserved.
845
        let file_mode = fs::metadata(dst_dir.join("subdir").join("file2.txt"))
846
            .await?
847
            .permissions()
848
            .mode()
849
            & 0o777;
850
        assert_eq!(file_mode, 0o555, "input files must remain read-only");
851
852
        // A write to an input file still fails — actions cannot mutate inputs.
853
        let err = fs::write(dst_dir.join("subdir").join("file2.txt"), b"mutated")
854
            .await
855
            .expect_err("input file write must fail (file is 0o555)");
856
        assert_eq!(err.kind(), std::io::ErrorKind::PermissionDenied);
857
858
        Ok(())
859
    }
860
861
    /// `set_readonly_recursive` locks a tree down as a cache entry: every
862
    /// file is made read-only, every directory is left writable. Directories
863
    /// stay writable because they are not hardlink-shared between cache
864
    /// entries, and a writable directory mode lets the materialized
865
    /// destination tree accept nested action outputs without a separate
866
    /// chmod walk.
867
    #[nativelink_test("crate")]
868
    async fn test_set_readonly_recursive() -> Result<(), Error> {
869
        let (_temp_dir, test_dir) = create_test_directory().await?;
870
871
        set_readonly_recursive(&test_dir).await?;
872
873
        // Files are read-only.
874
        let metadata = fs::metadata(test_dir.join("file1.txt")).await?;
875
        assert!(metadata.permissions().readonly());
876
877
        let metadata = fs::metadata(test_dir.join("subdir/file2.txt")).await?;
878
        assert!(metadata.permissions().readonly());
879
880
        // Directories are left writable — root and every nested subdir.
881
        #[cfg(unix)]
882
        {
883
            use std::os::unix::fs::PermissionsExt;
884
            for dir in [test_dir.clone(), test_dir.join("subdir")] {
885
                let mode = fs::metadata(&dir).await?.permissions().mode() & 0o777;
886
                assert_eq!(mode, 0o755, "{} must stay writable", dir.display());
887
            }
888
        }
889
        #[cfg(windows)]
890
        {
891
            // On Windows directories carry no read-only attribute that would
892
            // block creating children; assert they are not marked read-only.
893
            for dir in [test_dir.clone(), test_dir.join("subdir")] {
894
                assert!(
895
                    !fs::metadata(&dir).await?.permissions().readonly(),
896
                    "{} must stay writable",
897
                    dir.display()
898
                );
899
            }
900
        }
901
902
        Ok(())
903
    }
904
905
    /// `set_dir_writable_recursive` must make *every* directory in a tree
906
    /// writable — including nested subdirs — so the eviction cleanup path can
907
    /// `remove_dir_all` a cache entry. Files are left read-only because they
908
    /// may share a CAS inode via hardlink. This walk runs on already-read-only
909
    /// directory trees too, so the test first sets every file read-only with
910
    /// `set_readonly_recursive`.
911
    #[cfg(unix)]
912
    #[nativelink_test("crate")]
913
    async fn test_set_dir_writable_recursive_walks_nested_dirs() -> Result<(), Error> {
914
        use std::os::unix::fs::PermissionsExt;
915
916
        let (_temp_dir, test_dir) = create_test_directory().await?;
917
        // Lock files down, then explicitly force every directory read-only so
918
        // the walk has real work to do (the directory cache leaves dirs
919
        // writable, but the eviction path must cope with any mode).
920
        set_readonly_recursive(&test_dir).await?;
921
        for dir in [test_dir.clone(), test_dir.join("subdir")] {
922
            fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o555)).await?;
923
        }
924
925
        set_dir_writable_recursive(&test_dir).await?;
926
927
        // Every directory — the root and the nested subdir — must be writable.
928
        for dir in [test_dir.clone(), test_dir.join("subdir")] {
929
            let mode = fs::metadata(&dir).await?.permissions().mode() & 0o777;
930
            assert_eq!(mode, 0o755, "{} must be writable", dir.display());
931
        }
932
933
        // Files stay read-only — chmoding them would corrupt a shared CAS inode.
934
        let file_mode = fs::metadata(test_dir.join("subdir/file2.txt"))
935
            .await?
936
            .permissions()
937
            .mode()
938
            & 0o777;
939
        assert_eq!(file_mode, 0o555, "files must remain read-only");
940
941
        Ok(())
942
    }
943
944
    /// Regression test for the directory-cache fallback bug: input trees
945
    /// produced by `rules_python` / `rules_apple` venv tooling contain
946
    /// symlinks (e.g. `.venv/bin/python3`). `set_readonly_recursive` walks the
947
    /// materialized tree with `chmod`; `chmod` follows symlinks, so a symlink
948
    /// to a file would mutate the target and a *dangling* symlink (target
949
    /// outside the action's input set) would fail the whole walk with ENOENT
950
    /// — pushing the action onto the slow `download_to_directory` fallback.
951
    /// The walk must `lstat` and skip the symlink, leaving it intact.
952
    #[cfg(unix)]
953
    #[nativelink_test("crate")]
954
    async fn test_set_readonly_recursive_skips_symlinks() -> Result<(), Error> {
955
        let (_temp_dir, test_dir) = create_test_directory().await?;
956
957
        // A symlink to a path *inside* the same tree (the realistic
958
        // `.venv/bin/python3 -> ../../file1.txt` shape).
959
        let internal_link = test_dir.join("link_to_file1");
960
        fs::symlink("file1.txt", &internal_link).await?;
961
962
        // A symlink with a *relative* target that does not resolve (dangling).
963
        // This is the case that previously failed the walk with ENOENT.
964
        let dangling_link = test_dir.join("dangling_link");
965
        fs::symlink("../does/not/exist", &dangling_link).await?;
966
967
        // A symlink that points at a directory inside the tree. With `stat`
968
        // the walk would recurse *through* this link; with `lstat` it must
969
        // not.
970
        let dir_link = test_dir.join("link_to_subdir");
971
        fs::symlink("subdir", &dir_link).await?;
972
973
        // The walk must succeed despite the symlinks.
974
        set_readonly_recursive(&test_dir).await?;
975
976
        // Every symlink is preserved as a symlink with its target intact.
977
        for (link, expected_target) in [
978
            (&internal_link, "file1.txt"),
979
            (&dangling_link, "../does/not/exist"),
980
            (&dir_link, "subdir"),
981
        ] {
982
            let link_meta = fs::symlink_metadata(link).await?;
983
            assert!(
984
                link_meta.is_symlink(),
985
                "{} must still be a symlink after the walk",
986
                link.display()
987
            );
988
            assert_eq!(
989
                fs::read_link(link).await?,
990
                PathBuf::from(expected_target),
991
                "{} target must be unchanged",
992
                link.display()
993
            );
994
        }
995
996
        // The real files were still made read-only.
997
        assert!(
998
            fs::metadata(test_dir.join("file1.txt"))
999
                .await?
1000
                .permissions()
1001
                .readonly()
1002
        );
1003
1004
        Ok(())
1005
    }
1006
1007
    /// Companion to the read-only test: `set_dir_writable_recursive` must also
1008
    /// be symlink-safe. It must not `chmod` a symlink (which would follow the
1009
    /// link) and must not recurse through a symlinked directory.
1010
    #[cfg(unix)]
1011
    #[nativelink_test("crate")]
1012
    async fn test_set_dir_writable_recursive_skips_symlinks() -> Result<(), Error> {
1013
        use std::os::unix::fs::PermissionsExt;
1014
1015
        let (_temp_dir, test_dir) = create_test_directory().await?;
1016
1017
        // Symlink to a file inside the tree, a dangling relative symlink, and
1018
        // a symlink pointing at a directory inside the tree.
1019
        fs::symlink("file1.txt", test_dir.join("link_to_file1")).await?;
1020
        fs::symlink("../does/not/exist", test_dir.join("dangling_link")).await?;
1021
        fs::symlink("subdir", test_dir.join("link_to_subdir")).await?;
1022
1023
        // Mirror the directory cache's post-construction sequence.
1024
        set_readonly_recursive(&test_dir).await?;
1025
        set_dir_writable_recursive(&test_dir).await?;
1026
1027
        // Symlinks survive both walks untouched.
1028
        for (link, expected_target) in [
1029
            ("link_to_file1", "file1.txt"),
1030
            ("dangling_link", "../does/not/exist"),
1031
            ("link_to_subdir", "subdir"),
1032
        ] {
1033
            let link_path = test_dir.join(link);
1034
            assert!(
1035
                fs::symlink_metadata(&link_path).await?.is_symlink(),
1036
                "{} must still be a symlink",
1037
                link_path.display()
1038
            );
1039
            assert_eq!(
1040
                fs::read_link(&link_path).await?,
1041
                PathBuf::from(expected_target),
1042
                "{} target must be unchanged",
1043
                link_path.display()
1044
            );
1045
        }
1046
1047
        // Real directories were made writable; real files stayed read-only.
1048
        let dir_mode = fs::metadata(test_dir.join("subdir"))
1049
            .await?
1050
            .permissions()
1051
            .mode()
1052
            & 0o777;
1053
        assert_eq!(dir_mode, 0o755, "real subdir must be writable");
1054
        let file_mode = fs::metadata(test_dir.join("subdir/file2.txt"))
1055
            .await?
1056
            .permissions()
1057
            .mode()
1058
            & 0o777;
1059
        assert_eq!(file_mode, 0o555, "real files must stay read-only");
1060
1061
        Ok(())
1062
    }
1063
1064
    /// `hardlink_directory_tree` must recreate symlink entries as symlinks at
1065
    /// the destination (not dereference them), and the subsequent
1066
    /// `set_readonly_recursive` walk over the materialized tree must succeed.
1067
    /// This is the end-to-end shape `DirectoryCache::get_or_create` runs.
1068
    #[cfg(unix)]
1069
    #[nativelink_test("crate")]
1070
    async fn test_hardlink_directory_tree_preserves_symlinks() -> Result<(), Error> {
1071
        let (temp_dir, src_dir) = create_test_directory().await?;
1072
1073
        // Symlink to a sibling file, a dangling relative symlink, and a
1074
        // symlink to a subdirectory — all inside the source tree.
1075
        fs::symlink("file1.txt", src_dir.join("link_to_file1")).await?;
1076
        fs::symlink("../does/not/exist", src_dir.join("dangling_link")).await?;
1077
        fs::symlink("subdir", src_dir.join("link_to_subdir")).await?;
1078
1079
        let dst_dir = temp_dir.path().join("test_dst");
1080
        hardlink_directory_tree(&src_dir, &dst_dir).await?;
1081
1082
        // Each symlink is materialized as a symlink with its target intact.
1083
        for (link, expected_target) in [
1084
            ("link_to_file1", "file1.txt"),
1085
            ("dangling_link", "../does/not/exist"),
1086
            ("link_to_subdir", "subdir"),
1087
        ] {
1088
            let link_path = dst_dir.join(link);
1089
            assert!(
1090
                fs::symlink_metadata(&link_path).await?.is_symlink(),
1091
                "{} must be a symlink in the materialized tree",
1092
                link_path.display()
1093
            );
1094
            assert_eq!(
1095
                fs::read_link(&link_path).await?,
1096
                PathBuf::from(expected_target),
1097
                "{} target must be preserved",
1098
                link_path.display()
1099
            );
1100
        }
1101
1102
        // The read-only walk over the materialized tree must not choke on the
1103
        // symlinks (this is the operation that previously failed the cache).
1104
        set_readonly_recursive(&dst_dir).await?;
1105
1106
        Ok(())
1107
    }
1108
1109
    #[nativelink_test("crate")]
1110
    async fn test_calculate_directory_size() -> Result<(), Error> {
1111
        let (_temp_dir, test_dir) = create_test_directory().await?;
1112
1113
        let size = calculate_directory_size(&test_dir).await?;
1114
1115
        // "Hello, World!" = 13 bytes
1116
        // "Nested file" = 11 bytes
1117
        // Total = 24 bytes
1118
        assert_eq!(size, 24);
1119
1120
        Ok(())
1121
    }
1122
1123
    #[nativelink_test("crate")]
1124
    async fn test_hardlink_nonexistent_source() {
1125
        let temp_dir = TempDir::new().unwrap();
1126
        let src = temp_dir.path().join("nonexistent");
1127
        let dst = temp_dir.path().join("dest");
1128
1129
        let result = hardlink_directory_tree(&src, &dst).await;
1130
        assert!(result.is_err());
1131
    }
1132
1133
    #[nativelink_test("crate")]
1134
    async fn test_hardlink_existing_destination() -> Result<(), Error> {
1135
        let (temp_dir, src_dir) = create_test_directory().await?;
1136
        let dst_dir = temp_dir.path().join("existing");
1137
1138
        fs::create_dir(&dst_dir).await?;
1139
1140
        let result = hardlink_directory_tree(&src_dir, &dst_dir).await;
1141
        assert!(result.is_err());
1142
1143
        Ok(())
1144
    }
1145
}