/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 | | } |