1use std::collections::{BTreeMap, BTreeSet};
2use std::io;
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, Mutex};
5use std::time::SystemTime;
6
7use fs_err as fs;
8use fs_err::DirEntry;
9use itertools::Itertools;
10use reflink_copy as reflink;
11use rustc_hash::FxHashMap;
12use serde::{Deserialize, Serialize};
13use tempfile::tempdir_in;
14use tracing::{debug, instrument, trace, warn};
15use walkdir::WalkDir;
16
17use uv_distribution_filename::WheelFilename;
18use uv_fs::Simplified;
19use uv_preview::{Preview, PreviewFeature};
20use uv_warnings::{warn_user, warn_user_once};
21
22use crate::Error;
23
24#[expect(clippy::struct_field_names)]
26#[derive(Debug, Default)]
27pub struct Locks {
28 copy_dir_locks: Mutex<FxHashMap<PathBuf, Arc<Mutex<()>>>>,
30 site_packages_paths: Mutex<FxHashMap<PathBuf, BTreeSet<(WheelFilename, PathBuf)>>>,
33 preview: Preview,
35}
36
37impl Locks {
38 pub fn new(preview: Preview) -> Self {
40 Self {
41 copy_dir_locks: Mutex::new(FxHashMap::default()),
42 site_packages_paths: Mutex::new(FxHashMap::default()),
43 preview,
44 }
45 }
46
47 fn register_installed_path(&self, relative: &Path, absolute: &Path, wheel: &WheelFilename) {
54 debug_assert!(!relative.is_absolute());
55 debug_assert!(absolute.is_absolute());
56
57 if relative.components().count() != 1 {
60 return;
61 }
62
63 self.site_packages_paths
64 .lock()
65 .unwrap()
66 .entry(relative.to_path_buf())
67 .or_default()
68 .insert((wheel.clone(), absolute.to_path_buf()));
69 }
70
71 pub fn warn_package_conflicts(self) -> Result<(), io::Error> {
104 if !self
106 .preview
107 .is_enabled(PreviewFeature::DetectModuleConflicts)
108 {
109 return Ok(());
110 }
111
112 for (relative, wheels) in &*self.site_packages_paths.lock().unwrap() {
113 let mut wheel_iter = wheels.iter();
115 let Some(first_wheel) = wheel_iter.next() else {
116 debug_assert!(false, "at least one wheel");
117 continue;
118 };
119 if wheel_iter.next().is_none() {
120 continue;
121 }
122
123 let file_type = fs_err::metadata(&first_wheel.1)?.file_type();
125 if file_type.is_file() {
126 let files: BTreeSet<(&WheelFilename, u64)> = wheels
129 .iter()
130 .map(|(wheel, absolute)| Ok((wheel, absolute.metadata()?.len())))
131 .collect::<Result<_, io::Error>>()?;
132 Self::warn_file_conflict(relative, &files);
133 } else if file_type.is_dir() {
134 Self::warn_directory_conflict(relative, wheels)?;
137 } else {
138 }
141 }
142
143 Ok(())
144 }
145
146 fn warn_directory_conflict(
156 directory: &Path,
157 wheels: &BTreeSet<(WheelFilename, PathBuf)>,
158 ) -> Result<bool, io::Error> {
159 let mut files: BTreeMap<PathBuf, BTreeSet<(&WheelFilename, u64)>> = BTreeMap::default();
162 let mut subdirectories: BTreeMap<PathBuf, BTreeSet<(WheelFilename, PathBuf)>> =
165 BTreeMap::default();
166
167 for (wheel, absolute) in wheels {
169 for dir_entry in fs_err::read_dir(absolute)? {
170 let dir_entry = dir_entry?;
171 let relative = directory.join(dir_entry.file_name());
172 let file_type = dir_entry.file_type()?;
173 if file_type.is_file() {
174 files
175 .entry(relative)
176 .or_default()
177 .insert((wheel, dir_entry.metadata()?.len()));
178 } else if file_type.is_dir() {
179 subdirectories
180 .entry(relative)
181 .or_default()
182 .insert((wheel.clone(), dir_entry.path()));
183 } else {
184 }
187 }
188 }
189
190 for (file, file_wheels) in files {
191 if Self::warn_file_conflict(&file, &file_wheels) {
192 return Ok(true);
193 }
194 }
195
196 for (subdirectory, subdirectory_wheels) in subdirectories {
197 if subdirectory_wheels.len() == 1 {
198 continue;
199 }
200 if Self::warn_directory_conflict(&subdirectory, &subdirectory_wheels)? {
203 return Ok(true);
204 }
205 }
206
207 Ok(false)
208 }
209
210 fn warn_file_conflict(file: &Path, file_wheels: &BTreeSet<(&WheelFilename, u64)>) -> bool {
216 let Some((_, file_len)) = file_wheels.first() else {
217 debug_assert!(false, "Always at least one element");
218 return false;
219 };
220 if !file_wheels
221 .iter()
222 .any(|(_, file_len_other)| file_len_other != file_len)
223 {
224 return false;
225 }
226
227 let packages = file_wheels
228 .iter()
229 .map(|(wheel_filename, _file_len)| {
230 format!("* {} ({})", wheel_filename.name, wheel_filename)
231 })
232 .join("\n");
233 warn_user!(
234 "The file `{}` is provided by more than one package, \
235 which causes an install race condition and can result in a broken module. \
236 Packages containing the file:\n{}",
237 file.user_display(),
238 packages
239 );
240
241 true
245 }
246}
247
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
249#[serde(deny_unknown_fields, rename_all = "kebab-case")]
250#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
251#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
252pub enum LinkMode {
253 #[serde(alias = "reflink")]
255 #[cfg_attr(feature = "clap", value(alias = "reflink"))]
256 Clone,
257 Copy,
259 Hardlink,
261 Symlink,
263}
264
265impl Default for LinkMode {
266 fn default() -> Self {
267 if cfg!(any(target_os = "macos", target_os = "ios")) {
268 Self::Clone
269 } else {
270 Self::Hardlink
271 }
272 }
273}
274
275impl LinkMode {
276 #[instrument(skip_all)]
278 pub fn link_wheel_files(
279 self,
280 site_packages: impl AsRef<Path>,
281 wheel: impl AsRef<Path>,
282 locks: &Locks,
283 filename: &WheelFilename,
284 ) -> Result<usize, Error> {
285 match self {
286 Self::Clone => clone_wheel_files(site_packages, wheel, locks, filename),
287 Self::Copy => copy_wheel_files(site_packages, wheel, locks, filename),
288 Self::Hardlink => hardlink_wheel_files(site_packages, wheel, locks, filename),
289 Self::Symlink => symlink_wheel_files(site_packages, wheel, locks, filename),
290 }
291 }
292
293 pub fn is_symlink(&self) -> bool {
295 matches!(self, Self::Symlink)
296 }
297}
298
299fn clone_wheel_files(
306 site_packages: impl AsRef<Path>,
307 wheel: impl AsRef<Path>,
308 locks: &Locks,
309 filename: &WheelFilename,
310) -> Result<usize, Error> {
311 let wheel = wheel.as_ref();
312 let mut count = 0usize;
313 let mut attempt = Attempt::default();
314
315 for entry in fs::read_dir(wheel)? {
316 let entry = entry?;
317 locks.register_installed_path(
318 entry
319 .path()
320 .strip_prefix(wheel)
321 .expect("wheel path starts with wheel root"),
322 &entry.path(),
323 filename,
324 );
325 clone_recursive(site_packages.as_ref(), wheel, locks, &entry, &mut attempt)?;
326 count += 1;
327 }
328
329 let now = SystemTime::now();
336
337 match fs::File::open(site_packages.as_ref()) {
338 Ok(dir) => {
339 if let Err(err) = dir.set_modified(now) {
340 debug!(
341 "Failed to update mtime for {}: {err}",
342 site_packages.as_ref().display()
343 );
344 }
345 }
346 Err(err) => debug!(
347 "Failed to open {} to update mtime: {err}",
348 site_packages.as_ref().display()
349 ),
350 }
351
352 Ok(count)
353}
354
355#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
360enum Attempt {
361 #[default]
362 Initial,
363 Subsequent,
364 UseCopyFallback,
365}
366
367fn clone_recursive(
384 site_packages: &Path,
385 wheel: &Path,
386 locks: &Locks,
387 entry: &DirEntry,
388 attempt: &mut Attempt,
389) -> Result<(), Error> {
390 let from = entry.path();
392 let to = site_packages.join(
393 from.strip_prefix(wheel)
394 .expect("wheel path starts with wheel root"),
395 );
396
397 trace!("Cloning {} to {}", from.display(), to.display());
398
399 if (cfg!(windows) || cfg!(target_os = "linux")) && from.is_dir() {
400 fs::create_dir_all(&to)?;
401 for entry in fs::read_dir(from)? {
402 clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
403 }
404 return Ok(());
405 }
406
407 match attempt {
408 Attempt::Initial => {
409 if let Err(err) = reflink::reflink(&from, &to) {
410 if err.kind() == std::io::ErrorKind::AlreadyExists {
411 if entry.file_type()?.is_dir() {
414 for entry in fs::read_dir(from)? {
415 clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
416 }
417 } else {
418 let tempdir = tempdir_in(site_packages)?;
420 let tempfile = tempdir.path().join(from.file_name().unwrap());
421 if reflink::reflink(&from, &tempfile).is_ok() {
422 fs::rename(&tempfile, to)?;
423 } else {
424 debug!(
425 "Failed to clone `{}` to temporary location `{}`, attempting to copy files as a fallback",
426 from.display(),
427 tempfile.display(),
428 );
429 *attempt = Attempt::UseCopyFallback;
430 synchronized_copy(&from, &to, locks)?;
431 }
432 }
433 } else {
434 debug!(
435 "Failed to clone `{}` to `{}`, attempting to copy files as a fallback",
436 from.display(),
437 to.display()
438 );
439 *attempt = Attempt::UseCopyFallback;
441 clone_recursive(site_packages, wheel, locks, entry, attempt)?;
442 }
443 }
444 }
445 Attempt::Subsequent => {
446 if let Err(err) = reflink::reflink(&from, &to) {
447 if err.kind() == std::io::ErrorKind::AlreadyExists {
448 if entry.file_type()?.is_dir() {
450 for entry in fs::read_dir(from)? {
451 clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
452 }
453 } else {
454 let tempdir = tempdir_in(site_packages)?;
456 let tempfile = tempdir.path().join(from.file_name().unwrap());
457 reflink::reflink(&from, &tempfile)?;
458 fs::rename(&tempfile, to)?;
459 }
460 } else {
461 return Err(Error::Reflink { from, to, err });
462 }
463 }
464 }
465 Attempt::UseCopyFallback => {
466 if entry.file_type()?.is_dir() {
467 fs::create_dir_all(&to)?;
468 for entry in fs::read_dir(from)? {
469 clone_recursive(site_packages, wheel, locks, &entry?, attempt)?;
470 }
471 } else {
472 synchronized_copy(&from, &to, locks)?;
473 }
474 warn_user_once!(
475 "Failed to clone files; falling back to full copy. This may lead to degraded performance.\n If the cache and target directories are on different filesystems, reflinking may not be supported.\n If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
476 );
477 }
478 }
479
480 if *attempt == Attempt::Initial {
481 *attempt = Attempt::Subsequent;
482 }
483 Ok(())
484}
485
486fn copy_wheel_files(
488 site_packages: impl AsRef<Path>,
489 wheel: impl AsRef<Path>,
490 locks: &Locks,
491 filename: &WheelFilename,
492) -> Result<usize, Error> {
493 let mut count = 0usize;
494
495 for entry in WalkDir::new(&wheel) {
497 let entry = entry?;
498 let path = entry.path();
499 let relative = path.strip_prefix(&wheel).expect("walkdir starts with root");
500 let out_path = site_packages.as_ref().join(relative);
501 locks.register_installed_path(relative, path, filename);
502
503 if entry.file_type().is_dir() {
504 fs::create_dir_all(&out_path)?;
505 continue;
506 }
507
508 synchronized_copy(path, &out_path, locks)?;
509
510 count += 1;
511 }
512
513 Ok(count)
514}
515
516fn hardlink_wheel_files(
518 site_packages: impl AsRef<Path>,
519 wheel: impl AsRef<Path>,
520 locks: &Locks,
521 filename: &WheelFilename,
522) -> Result<usize, Error> {
523 let mut attempt = Attempt::default();
524 let mut count = 0usize;
525
526 for entry in WalkDir::new(&wheel) {
528 let entry = entry?;
529 let path = entry.path();
530 let relative = path.strip_prefix(&wheel).expect("walkdir starts with root");
531 let out_path = site_packages.as_ref().join(relative);
532
533 locks.register_installed_path(relative, path, filename);
534
535 if entry.file_type().is_dir() {
536 fs::create_dir_all(&out_path)?;
537 continue;
538 }
539
540 if path.ends_with("RECORD") {
542 synchronized_copy(path, &out_path, locks)?;
543 count += 1;
544 continue;
545 }
546
547 match attempt {
549 Attempt::Initial => {
550 attempt = Attempt::Subsequent;
552 if let Err(err) = fs::hard_link(path, &out_path) {
553 if err.kind() == std::io::ErrorKind::AlreadyExists {
555 debug!(
556 "File already exists (initial attempt), overwriting: {}",
557 out_path.display()
558 );
559 let tempdir = tempdir_in(&site_packages)?;
561 let tempfile = tempdir.path().join(entry.file_name());
562 if fs::hard_link(path, &tempfile).is_ok() {
563 fs_err::rename(&tempfile, &out_path)?;
564 } else {
565 debug!(
566 "Failed to hardlink `{}` to `{}`, attempting to copy files as a fallback",
567 out_path.display(),
568 path.display()
569 );
570 synchronized_copy(path, &out_path, locks)?;
571 attempt = Attempt::UseCopyFallback;
572 }
573 } else {
574 debug!(
575 "Failed to hardlink `{}` to `{}`, attempting to copy files as a fallback",
576 out_path.display(),
577 path.display()
578 );
579 synchronized_copy(path, &out_path, locks)?;
580 attempt = Attempt::UseCopyFallback;
581 }
582 }
583 }
584 Attempt::Subsequent => {
585 if let Err(err) = fs::hard_link(path, &out_path) {
586 if err.kind() == std::io::ErrorKind::AlreadyExists {
588 debug!(
589 "File already exists (subsequent attempt), overwriting: {}",
590 out_path.display()
591 );
592 let tempdir = tempdir_in(&site_packages)?;
594 let tempfile = tempdir.path().join(entry.file_name());
595 fs::hard_link(path, &tempfile)?;
596 fs_err::rename(&tempfile, &out_path)?;
597 } else {
598 return Err(err.into());
599 }
600 }
601 }
602 Attempt::UseCopyFallback => {
603 synchronized_copy(path, &out_path, locks)?;
604 warn_user_once!(
605 "Failed to hardlink files; falling back to full copy. This may lead to degraded performance.\n If the cache and target directories are on different filesystems, hardlinking may not be supported.\n If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
606 );
607 }
608 }
609
610 count += 1;
611 }
612
613 Ok(count)
614}
615
616fn symlink_wheel_files(
618 site_packages: impl AsRef<Path>,
619 wheel: impl AsRef<Path>,
620 locks: &Locks,
621 filename: &WheelFilename,
622) -> Result<usize, Error> {
623 let mut attempt = Attempt::default();
624 let mut count = 0usize;
625
626 for entry in WalkDir::new(&wheel) {
628 let entry = entry?;
629 let path = entry.path();
630 let relative = path.strip_prefix(&wheel).unwrap();
631 let out_path = site_packages.as_ref().join(relative);
632
633 locks.register_installed_path(relative, path, filename);
634
635 if entry.file_type().is_dir() {
636 fs::create_dir_all(&out_path)?;
637 continue;
638 }
639
640 if path.ends_with("RECORD") {
642 synchronized_copy(path, &out_path, locks)?;
643 count += 1;
644 continue;
645 }
646
647 match attempt {
649 Attempt::Initial => {
650 attempt = Attempt::Subsequent;
652 if let Err(err) = create_symlink(path, &out_path) {
653 if err.kind() == std::io::ErrorKind::AlreadyExists {
655 debug!(
656 "File already exists (initial attempt), overwriting: {}",
657 out_path.display()
658 );
659 let tempdir = tempdir_in(&site_packages)?;
661 let tempfile = tempdir.path().join(entry.file_name());
662 if create_symlink(path, &tempfile).is_ok() {
663 fs::rename(&tempfile, &out_path)?;
664 } else {
665 debug!(
666 "Failed to symlink `{}` to `{}`, attempting to copy files as a fallback",
667 out_path.display(),
668 path.display()
669 );
670 synchronized_copy(path, &out_path, locks)?;
671 attempt = Attempt::UseCopyFallback;
672 }
673 } else {
674 debug!(
675 "Failed to symlink `{}` to `{}`, attempting to copy files as a fallback",
676 out_path.display(),
677 path.display()
678 );
679 synchronized_copy(path, &out_path, locks)?;
680 attempt = Attempt::UseCopyFallback;
681 }
682 }
683 }
684 Attempt::Subsequent => {
685 if let Err(err) = create_symlink(path, &out_path) {
686 if err.kind() == std::io::ErrorKind::AlreadyExists {
688 debug!(
689 "File already exists (subsequent attempt), overwriting: {}",
690 out_path.display()
691 );
692 let tempdir = tempdir_in(&site_packages)?;
694 let tempfile = tempdir.path().join(entry.file_name());
695 create_symlink(path, &tempfile)?;
696 fs::rename(&tempfile, &out_path)?;
697 } else {
698 return Err(err.into());
699 }
700 }
701 }
702 Attempt::UseCopyFallback => {
703 synchronized_copy(path, &out_path, locks)?;
704 warn_user_once!(
705 "Failed to symlink files; falling back to full copy. This may lead to degraded performance.\n If the cache and target directories are on different filesystems, symlinking may not be supported.\n If this is intentional, set `export UV_LINK_MODE=copy` or use `--link-mode=copy` to suppress this warning."
706 );
707 }
708 }
709
710 count += 1;
711 }
712
713 Ok(count)
714}
715
716fn synchronized_copy(from: &Path, to: &Path, locks: &Locks) -> std::io::Result<()> {
721 let dir_lock = {
723 let mut locks_guard = locks.copy_dir_locks.lock().unwrap();
724 locks_guard
725 .entry(to.parent().unwrap().to_path_buf())
726 .or_insert_with(|| Arc::new(Mutex::new(())))
727 .clone()
728 };
729
730 let _dir_guard = dir_lock.lock().unwrap();
732
733 fs::copy(from, to)?;
735
736 Ok(())
737}
738
739#[cfg(unix)]
740fn create_symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
741 fs_err::os::unix::fs::symlink(original, link)
742}
743
744#[cfg(windows)]
745fn create_symlink<P: AsRef<Path>, Q: AsRef<Path>>(original: P, link: Q) -> std::io::Result<()> {
746 if original.as_ref().is_dir() {
747 fs_err::os::windows::fs::symlink_dir(original, link)
748 } else {
749 fs_err::os::windows::fs::symlink_file(original, link)
750 }
751}