uv_fs/
lib.rs

1use std::borrow::Cow;
2use std::fmt::Display;
3use std::path::{Path, PathBuf};
4
5use tempfile::NamedTempFile;
6use tracing::{debug, error, info, trace, warn};
7
8pub use crate::path::*;
9
10pub mod cachedir;
11mod path;
12pub mod which;
13
14/// Append an extension to a [`PathBuf`].
15///
16/// Unlike [`Path::with_extension`], this function does not replace an existing extension.
17///
18/// If there is no file name, the path is returned unchanged.
19///
20/// This mimics the behavior of the unstable [`Path::with_added_extension`] method.
21pub fn with_added_extension<'a>(path: &'a Path, extension: &str) -> Cow<'a, Path> {
22    let Some(name) = path.file_name() else {
23        // If there is no file name, we cannot add an extension.
24        return Cow::Borrowed(path);
25    };
26    let mut name = name.to_os_string();
27    name.push(".");
28    name.push(extension.trim_start_matches('.'));
29    Cow::Owned(path.with_file_name(name))
30}
31
32/// Attempt to check if the two paths refer to the same file.
33///
34/// Returns `Some(true)` if the files are missing, but would be the same if they existed.
35pub fn is_same_file_allow_missing(left: &Path, right: &Path) -> Option<bool> {
36    // First, check an exact path comparison.
37    if left == right {
38        return Some(true);
39    }
40
41    // Second, check the files directly.
42    if let Ok(value) = same_file::is_same_file(left, right) {
43        return Some(value);
44    }
45
46    // Often, one of the directories won't exist yet so perform the comparison up a level.
47    if let (Some(left_parent), Some(right_parent), Some(left_name), Some(right_name)) = (
48        left.parent(),
49        right.parent(),
50        left.file_name(),
51        right.file_name(),
52    ) {
53        match same_file::is_same_file(left_parent, right_parent) {
54            Ok(true) => return Some(left_name == right_name),
55            Ok(false) => return Some(false),
56            _ => (),
57        }
58    }
59
60    // We couldn't determine if they're the same.
61    None
62}
63
64/// Reads data from the path and requires that it be valid UTF-8 or UTF-16.
65///
66/// This uses BOM sniffing to determine if the data should be transcoded
67/// from UTF-16 to Rust's `String` type (which uses UTF-8).
68///
69/// This should generally only be used when one specifically wants to support
70/// reading UTF-16 transparently.
71///
72/// If the file path is `-`, then contents are read from stdin instead.
73#[cfg(feature = "tokio")]
74pub async fn read_to_string_transcode(path: impl AsRef<Path>) -> std::io::Result<String> {
75    use std::io::Read;
76
77    use encoding_rs_io::DecodeReaderBytes;
78
79    let path = path.as_ref();
80    let raw = if path == Path::new("-") {
81        let mut buf = Vec::with_capacity(1024);
82        std::io::stdin().read_to_end(&mut buf)?;
83        buf
84    } else {
85        fs_err::tokio::read(path).await?
86    };
87    let mut buf = String::with_capacity(1024);
88    DecodeReaderBytes::new(&*raw)
89        .read_to_string(&mut buf)
90        .map_err(|err| {
91            let path = path.display();
92            std::io::Error::other(format!("failed to decode file {path}: {err}"))
93        })?;
94    Ok(buf)
95}
96
97/// Create a symlink at `dst` pointing to `src`, replacing any existing symlink.
98///
99/// On Windows, this uses the `junction` crate to create a junction point. The
100/// operation is _not_ atomic, as we first delete the junction, then create a
101/// junction at the same path.
102///
103/// Note that because junctions are used, the source must be a directory.
104///
105/// Changes to this function should be reflected in [`create_symlink`].
106#[cfg(windows)]
107pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
108    // If the source is a file, we can't create a junction
109    if src.as_ref().is_file() {
110        return Err(std::io::Error::new(
111            std::io::ErrorKind::InvalidInput,
112            format!(
113                "Cannot create a junction for {}: is not a directory",
114                src.as_ref().display()
115            ),
116        ));
117    }
118
119    // Remove the existing symlink, if any.
120    match junction::delete(dunce::simplified(dst.as_ref())) {
121        Ok(()) => match fs_err::remove_dir_all(dst.as_ref()) {
122            Ok(()) => {}
123            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
124            Err(err) => return Err(err),
125        },
126        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
127        Err(err) => return Err(err),
128    }
129
130    // Replace it with a new symlink.
131    junction::create(
132        dunce::simplified(src.as_ref()),
133        dunce::simplified(dst.as_ref()),
134    )
135}
136
137/// Create a symlink at `dst` pointing to `src`, replacing any existing symlink if necessary.
138///
139/// On Unix, this method creates a temporary file, then moves it into place.
140#[cfg(unix)]
141pub fn replace_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
142    // Attempt to create the symlink directly.
143    match fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref()) {
144        Ok(()) => Ok(()),
145        Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
146            // Create a symlink, using a temporary file to ensure atomicity.
147            let temp_dir = tempfile::tempdir_in(dst.as_ref().parent().unwrap())?;
148            let temp_file = temp_dir.path().join("link");
149            fs_err::os::unix::fs::symlink(src, &temp_file)?;
150
151            // Move the symlink into the target location.
152            fs_err::rename(&temp_file, dst.as_ref())?;
153
154            Ok(())
155        }
156        Err(err) => Err(err),
157    }
158}
159
160/// Create a symlink at `dst` pointing to `src`.
161///
162/// On Windows, this uses the `junction` crate to create a junction point.
163///
164/// Note that because junctions are used, the source must be a directory.
165///
166/// Changes to this function should be reflected in [`replace_symlink`].
167#[cfg(windows)]
168pub fn create_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
169    // If the source is a file, we can't create a junction
170    if src.as_ref().is_file() {
171        return Err(std::io::Error::new(
172            std::io::ErrorKind::InvalidInput,
173            format!(
174                "Cannot create a junction for {}: is not a directory",
175                src.as_ref().display()
176            ),
177        ));
178    }
179
180    junction::create(
181        dunce::simplified(src.as_ref()),
182        dunce::simplified(dst.as_ref()),
183    )
184}
185
186/// Create a symlink at `dst` pointing to `src`.
187#[cfg(unix)]
188pub fn create_symlink(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
189    fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())
190}
191
192#[cfg(unix)]
193pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
194    fs_err::remove_file(path.as_ref())
195}
196
197/// Create a symlink at `dst` pointing to `src` on Unix or copy `src` to `dst` on Windows
198///
199/// This does not replace an existing symlink or file at `dst`.
200///
201/// This does not fallback to copying on Unix.
202///
203/// This function should only be used for files. If targeting a directory, use [`replace_symlink`]
204/// instead; it will use a junction on Windows, which is more performant.
205pub fn symlink_or_copy_file(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
206    #[cfg(windows)]
207    {
208        fs_err::copy(src.as_ref(), dst.as_ref())?;
209    }
210    #[cfg(unix)]
211    {
212        fs_err::os::unix::fs::symlink(src.as_ref(), dst.as_ref())?;
213    }
214
215    Ok(())
216}
217
218#[cfg(windows)]
219pub fn remove_symlink(path: impl AsRef<Path>) -> std::io::Result<()> {
220    match junction::delete(dunce::simplified(path.as_ref())) {
221        Ok(()) => match fs_err::remove_dir_all(path.as_ref()) {
222            Ok(()) => Ok(()),
223            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
224            Err(err) => Err(err),
225        },
226        Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
227        Err(err) => Err(err),
228    }
229}
230
231/// Return a [`NamedTempFile`] in the specified directory.
232///
233/// Sets the permissions of the temporary file to `0o666`, to match the non-temporary file default.
234/// ([`NamedTempfile`] defaults to `0o600`.)
235#[cfg(unix)]
236pub fn tempfile_in(path: &Path) -> std::io::Result<NamedTempFile> {
237    use std::os::unix::fs::PermissionsExt;
238    tempfile::Builder::new()
239        .permissions(std::fs::Permissions::from_mode(0o666))
240        .tempfile_in(path)
241}
242
243/// Return a [`NamedTempFile`] in the specified directory.
244#[cfg(not(unix))]
245pub fn tempfile_in(path: &Path) -> std::io::Result<NamedTempFile> {
246    tempfile::Builder::new().tempfile_in(path)
247}
248
249/// Write `data` to `path` atomically using a temporary file and atomic rename.
250#[cfg(feature = "tokio")]
251pub async fn write_atomic(path: impl AsRef<Path>, data: impl AsRef<[u8]>) -> std::io::Result<()> {
252    let temp_file = tempfile_in(
253        path.as_ref()
254            .parent()
255            .expect("Write path must have a parent"),
256    )?;
257    fs_err::tokio::write(&temp_file, &data).await?;
258    persist_with_retry(temp_file, path.as_ref()).await
259}
260
261/// Write `data` to `path` atomically using a temporary file and atomic rename.
262pub fn write_atomic_sync(path: impl AsRef<Path>, data: impl AsRef<[u8]>) -> std::io::Result<()> {
263    let temp_file = tempfile_in(
264        path.as_ref()
265            .parent()
266            .expect("Write path must have a parent"),
267    )?;
268    fs_err::write(&temp_file, &data)?;
269    persist_with_retry_sync(temp_file, path.as_ref())
270}
271
272/// Copy `from` to `to` atomically using a temporary file and atomic rename.
273pub fn copy_atomic_sync(from: impl AsRef<Path>, to: impl AsRef<Path>) -> std::io::Result<()> {
274    let temp_file = tempfile_in(to.as_ref().parent().expect("Write path must have a parent"))?;
275    fs_err::copy(from.as_ref(), &temp_file)?;
276    persist_with_retry_sync(temp_file, to.as_ref())
277}
278
279#[cfg(windows)]
280fn backoff_file_move() -> backon::ExponentialBackoff {
281    use backon::BackoffBuilder;
282    // This amounts to 10 total seconds of trying the operation.
283    // We start at 10 milliseconds and try 9 times, doubling each time, so the last try will take
284    // about 10*(2^9) milliseconds ~= 5 seconds. All other attempts combined should equal
285    // the length of the last attempt (because it's a sum of powers of 2), so 10 seconds overall.
286    backon::ExponentialBuilder::default()
287        .with_min_delay(std::time::Duration::from_millis(10))
288        .with_max_times(9)
289        .build()
290}
291
292/// Rename a file, retrying (on Windows) if it fails due to transient operating system errors.
293#[cfg(feature = "tokio")]
294pub async fn rename_with_retry(
295    from: impl AsRef<Path>,
296    to: impl AsRef<Path>,
297) -> Result<(), std::io::Error> {
298    #[cfg(windows)]
299    {
300        use backon::Retryable;
301        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
302        // This is most common for DLLs, and the common suggestion is to retry the operation with
303        // some backoff.
304        //
305        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
306        let from = from.as_ref();
307        let to = to.as_ref();
308
309        let rename = async || fs_err::rename(from, to);
310
311        rename
312            .retry(backoff_file_move())
313            .sleep(tokio::time::sleep)
314            .when(|e| e.kind() == std::io::ErrorKind::PermissionDenied)
315            .notify(|err, _dur| {
316                warn!(
317                    "Retrying rename from {} to {} due to transient error: {}",
318                    from.display(),
319                    to.display(),
320                    err
321                );
322            })
323            .await
324    }
325    #[cfg(not(windows))]
326    {
327        fs_err::tokio::rename(from, to).await
328    }
329}
330
331/// Rename or copy a file, retrying (on Windows) if it fails due to transient operating system
332/// errors, in a synchronous context.
333#[cfg_attr(not(windows), allow(unused_variables))]
334pub fn with_retry_sync(
335    from: impl AsRef<Path>,
336    to: impl AsRef<Path>,
337    operation_name: &str,
338    operation: impl Fn() -> Result<(), std::io::Error>,
339) -> Result<(), std::io::Error> {
340    #[cfg(windows)]
341    {
342        use backon::BlockingRetryable;
343        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
344        // This is most common for DLLs, and the common suggestion is to retry the operation with
345        // some backoff.
346        //
347        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
348        let from = from.as_ref();
349        let to = to.as_ref();
350
351        operation
352            .retry(backoff_file_move())
353            .sleep(std::thread::sleep)
354            .when(|err| err.kind() == std::io::ErrorKind::PermissionDenied)
355            .notify(|err, _dur| {
356                warn!(
357                    "Retrying {} from {} to {} due to transient error: {}",
358                    operation_name,
359                    from.display(),
360                    to.display(),
361                    err
362                );
363            })
364            .call()
365            .map_err(|err| {
366                std::io::Error::other(format!(
367                    "Failed {} {} to {}: {}",
368                    operation_name,
369                    from.display(),
370                    to.display(),
371                    err
372                ))
373            })
374    }
375    #[cfg(not(windows))]
376    {
377        operation()
378    }
379}
380
381/// Why a file persist failed
382#[cfg(windows)]
383enum PersistRetryError {
384    /// Something went wrong while persisting, maybe retry (contains error message)
385    Persist(String),
386    /// Something went wrong trying to retrieve the file to persist, we must bail
387    LostState,
388}
389
390/// Persist a `NamedTempFile`, retrying (on Windows) if it fails due to transient operating system errors, in a synchronous context.
391pub async fn persist_with_retry(
392    from: NamedTempFile,
393    to: impl AsRef<Path>,
394) -> Result<(), std::io::Error> {
395    #[cfg(windows)]
396    {
397        use backon::Retryable;
398        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
399        // This is most common for DLLs, and the common suggestion is to retry the operation with
400        // some backoff.
401        //
402        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
403        let to = to.as_ref();
404
405        // Ok there's a lot of complex ownership stuff going on here.
406        //
407        // the `NamedTempFile` `persist` method consumes `self`, and returns it back inside
408        // the Error in case of `PersistError`:
409        // https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist
410        // So every time we fail, we need to reset the `NamedTempFile` to try again.
411        //
412        // Every time we (re)try we call this outer closure (`let persist = ...`), so it needs to
413        // be at least a `FnMut` (as opposed to `Fnonce`). However the closure needs to return a
414        // totally owned `Future` (so effectively it returns a `FnOnce`).
415        //
416        // But if the `Future` is totally owned it *necessarily* can't write back the `NamedTempFile`
417        // to somewhere the outer `FnMut` can see using references. So we need to use `Arc`s
418        // with interior mutability (`Mutex`) to have the closure and all the Futures it creates share
419        // a single memory location that the `NamedTempFile` can be shuttled in and out of.
420        //
421        // In spite of the Mutex all of this code will run logically serially, so there shouldn't be a
422        // chance for a race where we try to get the `NamedTempFile` but it's actually None. The code
423        // is just written pedantically/robustly.
424        let from = std::sync::Arc::new(std::sync::Mutex::new(Some(from)));
425        let persist = || {
426            // Turn our by-ref-captured Arc into an owned Arc that the Future can capture by-value
427            let from2 = from.clone();
428
429            async move {
430                let maybe_file: Option<NamedTempFile> = from2
431                    .lock()
432                    .map_err(|_| PersistRetryError::LostState)?
433                    .take();
434                if let Some(file) = maybe_file {
435                    file.persist(to).map_err(|err| {
436                        let error_message: String = err.to_string();
437                        // Set back the `NamedTempFile` returned back by the Error
438                        if let Ok(mut guard) = from2.lock() {
439                            *guard = Some(err.file);
440                            PersistRetryError::Persist(error_message)
441                        } else {
442                            PersistRetryError::LostState
443                        }
444                    })
445                } else {
446                    Err(PersistRetryError::LostState)
447                }
448            }
449        };
450
451        let persisted = persist
452            .retry(backoff_file_move())
453            .sleep(tokio::time::sleep)
454            .when(|err| matches!(err, PersistRetryError::Persist(_)))
455            .notify(|err, _dur| {
456                if let PersistRetryError::Persist(error_message) = err {
457                    warn!(
458                        "Retrying to persist temporary file to {}: {}",
459                        to.display(),
460                        error_message,
461                    );
462                }
463            })
464            .await;
465
466        match persisted {
467            Ok(_) => Ok(()),
468            Err(PersistRetryError::Persist(error_message)) => Err(std::io::Error::other(format!(
469                "Failed to persist temporary file to {}: {}",
470                to.display(),
471                error_message,
472            ))),
473            Err(PersistRetryError::LostState) => Err(std::io::Error::other(format!(
474                "Failed to retrieve temporary file while trying to persist to {}",
475                to.display()
476            ))),
477        }
478    }
479    #[cfg(not(windows))]
480    {
481        async { fs_err::rename(from, to) }.await
482    }
483}
484
485/// Persist a `NamedTempFile`, retrying (on Windows) if it fails due to transient operating system errors, in a synchronous context.
486pub fn persist_with_retry_sync(
487    from: NamedTempFile,
488    to: impl AsRef<Path>,
489) -> Result<(), std::io::Error> {
490    #[cfg(windows)]
491    {
492        use backon::BlockingRetryable;
493        // On Windows, antivirus software can lock files temporarily, making them inaccessible.
494        // This is most common for DLLs, and the common suggestion is to retry the operation with
495        // some backoff.
496        //
497        // See: <https://github.com/astral-sh/uv/issues/1491> & <https://github.com/astral-sh/uv/issues/9531>
498        let to = to.as_ref();
499
500        // the `NamedTempFile` `persist` method consumes `self`, and returns it back inside the Error in case of `PersistError`
501        // https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html#method.persist
502        // So we will update the `from` optional value in safe and borrow-checker friendly way every retry
503        // Allows us to use the NamedTempFile inside a FnMut closure used for backoff::retry
504        let mut from = Some(from);
505        let persist = || {
506            // Needed because we cannot move out of `from`, a captured variable in an `FnMut` closure, and then pass it to the async move block
507            if let Some(file) = from.take() {
508                file.persist(to).map_err(|err| {
509                    let error_message = err.to_string();
510                    // Set back the NamedTempFile returned back by the Error
511                    from = Some(err.file);
512                    PersistRetryError::Persist(error_message)
513                })
514            } else {
515                Err(PersistRetryError::LostState)
516            }
517        };
518
519        let persisted = persist
520            .retry(backoff_file_move())
521            .sleep(std::thread::sleep)
522            .when(|err| matches!(err, PersistRetryError::Persist(_)))
523            .notify(|err, _dur| {
524                if let PersistRetryError::Persist(error_message) = err {
525                    warn!(
526                        "Retrying to persist temporary file to {}: {}",
527                        to.display(),
528                        error_message,
529                    );
530                }
531            })
532            .call();
533
534        match persisted {
535            Ok(_) => Ok(()),
536            Err(PersistRetryError::Persist(error_message)) => Err(std::io::Error::other(format!(
537                "Failed to persist temporary file to {}: {}",
538                to.display(),
539                error_message,
540            ))),
541            Err(PersistRetryError::LostState) => Err(std::io::Error::other(format!(
542                "Failed to retrieve temporary file while trying to persist to {}",
543                to.display()
544            ))),
545        }
546    }
547    #[cfg(not(windows))]
548    {
549        fs_err::rename(from, to)
550    }
551}
552
553/// Iterate over the subdirectories of a directory.
554///
555/// If the directory does not exist, returns an empty iterator.
556pub fn directories(
557    path: impl AsRef<Path>,
558) -> Result<impl Iterator<Item = PathBuf>, std::io::Error> {
559    let entries = match path.as_ref().read_dir() {
560        Ok(entries) => Some(entries),
561        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
562        Err(err) => return Err(err),
563    };
564    Ok(entries
565        .into_iter()
566        .flatten()
567        .filter_map(|entry| match entry {
568            Ok(entry) => Some(entry),
569            Err(err) => {
570                warn!("Failed to read entry: {err}");
571                None
572            }
573        })
574        .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_dir()))
575        .map(|entry| entry.path()))
576}
577
578/// Iterate over the entries in a directory.
579///
580/// If the directory does not exist, returns an empty iterator.
581pub fn entries(path: impl AsRef<Path>) -> Result<impl Iterator<Item = PathBuf>, std::io::Error> {
582    let entries = match path.as_ref().read_dir() {
583        Ok(entries) => Some(entries),
584        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
585        Err(err) => return Err(err),
586    };
587    Ok(entries
588        .into_iter()
589        .flatten()
590        .filter_map(|entry| match entry {
591            Ok(entry) => Some(entry),
592            Err(err) => {
593                warn!("Failed to read entry: {err}");
594                None
595            }
596        })
597        .map(|entry| entry.path()))
598}
599
600/// Iterate over the files in a directory.
601///
602/// If the directory does not exist, returns an empty iterator.
603pub fn files(path: impl AsRef<Path>) -> Result<impl Iterator<Item = PathBuf>, std::io::Error> {
604    let entries = match path.as_ref().read_dir() {
605        Ok(entries) => Some(entries),
606        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
607        Err(err) => return Err(err),
608    };
609    Ok(entries
610        .into_iter()
611        .flatten()
612        .filter_map(|entry| match entry {
613            Ok(entry) => Some(entry),
614            Err(err) => {
615                warn!("Failed to read entry: {err}");
616                None
617            }
618        })
619        .filter(|entry| entry.file_type().is_ok_and(|file_type| file_type.is_file()))
620        .map(|entry| entry.path()))
621}
622
623/// Returns `true` if a path is a temporary file or directory.
624pub fn is_temporary(path: impl AsRef<Path>) -> bool {
625    path.as_ref()
626        .file_name()
627        .and_then(|name| name.to_str())
628        .is_some_and(|name| name.starts_with(".tmp"))
629}
630
631/// Checks if the grandparent directory of the given executable is the base
632/// of a virtual environment.
633///
634/// The procedure described in PEP 405 includes checking both the parent and
635/// grandparent directory of an executable, but in practice we've found this to
636/// be unnecessary.
637pub fn is_virtualenv_executable(executable: impl AsRef<Path>) -> bool {
638    executable
639        .as_ref()
640        .parent()
641        .and_then(Path::parent)
642        .is_some_and(is_virtualenv_base)
643}
644
645/// Returns `true` if a path is the base path of a virtual environment,
646/// indicated by the presence of a `pyvenv.cfg` file.
647///
648/// The procedure described in PEP 405 includes scanning `pyvenv.cfg`
649/// for a `home` key, but in practice we've found this to be
650/// unnecessary.
651pub fn is_virtualenv_base(path: impl AsRef<Path>) -> bool {
652    path.as_ref().join("pyvenv.cfg").is_file()
653}
654
655/// Whether the error is due to a lock being held.
656fn is_known_already_locked_error(err: &std::fs::TryLockError) -> bool {
657    match err {
658        std::fs::TryLockError::WouldBlock => true,
659        std::fs::TryLockError::Error(err) => {
660            // On Windows, we've seen: Os { code: 33, kind: Uncategorized, message: "The process cannot access the file because another process has locked a portion of the file." }
661            if cfg!(windows) && err.raw_os_error() == Some(33) {
662                return true;
663            }
664            false
665        }
666    }
667}
668
669/// A file lock that is automatically released when dropped.
670#[derive(Debug)]
671#[must_use]
672pub struct LockedFile(fs_err::File);
673
674impl LockedFile {
675    /// Inner implementation for [`LockedFile::acquire_blocking`] and [`LockedFile::acquire`].
676    fn lock_file_blocking(file: fs_err::File, resource: &str) -> Result<Self, std::io::Error> {
677        trace!(
678            "Checking lock for `{resource}` at `{}`",
679            file.path().user_display()
680        );
681        match file.file().try_lock() {
682            Ok(()) => {
683                debug!("Acquired lock for `{resource}`");
684                Ok(Self(file))
685            }
686            Err(err) => {
687                // Log error code and enum kind to help debugging more exotic failures.
688                if !is_known_already_locked_error(&err) {
689                    debug!("Try lock error: {err:?}");
690                }
691                info!(
692                    "Waiting to acquire lock for `{resource}` at `{}`",
693                    file.path().user_display(),
694                );
695                file.lock()?;
696                debug!("Acquired lock for `{resource}`");
697                Ok(Self(file))
698            }
699        }
700    }
701
702    /// Inner implementation for [`LockedFile::acquire_no_wait`].
703    fn lock_file_no_wait(file: fs_err::File, resource: &str) -> Option<Self> {
704        trace!(
705            "Checking lock for `{resource}` at `{}`",
706            file.path().user_display()
707        );
708        match file.try_lock() {
709            Ok(()) => {
710                debug!("Acquired lock for `{resource}`");
711                Some(Self(file))
712            }
713            Err(err) => {
714                // Log error code and enum kind to help debugging more exotic failures.
715                if !is_known_already_locked_error(&err) {
716                    debug!("Try lock error: {err:?}");
717                }
718                debug!("Lock is busy for `{resource}`");
719                None
720            }
721        }
722    }
723
724    /// Inner implementation for [`LockedFile::acquire_shared_blocking`] and
725    /// [`LockedFile::acquire_blocking`].
726    fn lock_file_shared_blocking(
727        file: fs_err::File,
728        resource: &str,
729    ) -> Result<Self, std::io::Error> {
730        trace!(
731            "Checking shared lock for `{resource}` at `{}`",
732            file.path().user_display()
733        );
734        match file.try_lock_shared() {
735            Ok(()) => {
736                debug!("Acquired shared lock for `{resource}`");
737                Ok(Self(file))
738            }
739            Err(err) => {
740                // Log error code and enum kind to help debugging more exotic failures.
741                if !is_known_already_locked_error(&err) {
742                    debug!("Try lock error: {err:?}");
743                }
744                info!(
745                    "Waiting to acquire shared lock for `{resource}` at `{}`",
746                    file.path().user_display(),
747                );
748                file.lock_shared()?;
749                debug!("Acquired shared lock for `{resource}`");
750                Ok(Self(file))
751            }
752        }
753    }
754
755    /// The same as [`LockedFile::acquire`], but for synchronous contexts.
756    ///
757    /// Do not use from an async context, as this can block the runtime while waiting for another
758    /// process to release the lock.
759    pub fn acquire_blocking(
760        path: impl AsRef<Path>,
761        resource: impl Display,
762    ) -> Result<Self, std::io::Error> {
763        let file = Self::create(path)?;
764        let resource = resource.to_string();
765        Self::lock_file_blocking(file, &resource)
766    }
767
768    /// The same as [`LockedFile::acquire_blocking`], but for synchronous contexts.
769    ///
770    /// Do not use from an async context, as this can block the runtime while waiting for another
771    /// process to release the lock.
772    pub fn acquire_shared_blocking(
773        path: impl AsRef<Path>,
774        resource: impl Display,
775    ) -> Result<Self, std::io::Error> {
776        let file = Self::create(path)?;
777        let resource = resource.to_string();
778        Self::lock_file_shared_blocking(file, &resource)
779    }
780
781    /// Acquire a cross-process lock for a resource using a file at the provided path.
782    #[cfg(feature = "tokio")]
783    pub async fn acquire(
784        path: impl AsRef<Path>,
785        resource: impl Display,
786    ) -> Result<Self, std::io::Error> {
787        let file = Self::create(path)?;
788        let resource = resource.to_string();
789        tokio::task::spawn_blocking(move || Self::lock_file_blocking(file, &resource)).await?
790    }
791
792    /// Acquire a cross-process read lock for a shared resource using a file at the provided path.
793    #[cfg(feature = "tokio")]
794    pub async fn acquire_shared(
795        path: impl AsRef<Path>,
796        resource: impl Display,
797    ) -> Result<Self, std::io::Error> {
798        let file = Self::create(path)?;
799        let resource = resource.to_string();
800        tokio::task::spawn_blocking(move || Self::lock_file_shared_blocking(file, &resource))
801            .await?
802    }
803
804    /// Acquire a cross-process lock for a resource using a file at the provided path
805    ///
806    /// Unlike [`LockedFile::acquire`] this function will not wait for the lock to become available.
807    ///
808    /// If the lock is not immediately available, [`None`] is returned.
809    pub fn acquire_no_wait(path: impl AsRef<Path>, resource: impl Display) -> Option<Self> {
810        let file = Self::create(path).ok()?;
811        let resource = resource.to_string();
812        Self::lock_file_no_wait(file, &resource)
813    }
814
815    #[cfg(unix)]
816    fn create(path: impl AsRef<Path>) -> Result<fs_err::File, std::io::Error> {
817        use std::os::unix::fs::PermissionsExt;
818
819        // If path already exists, return it.
820        if let Ok(file) = fs_err::OpenOptions::new()
821            .read(true)
822            .write(true)
823            .open(path.as_ref())
824        {
825            return Ok(file);
826        }
827
828        // Otherwise, create a temporary file with 777 permissions. We must set
829        // permissions _after_ creating the file, to override the `umask`.
830        let file = if let Some(parent) = path.as_ref().parent() {
831            NamedTempFile::new_in(parent)?
832        } else {
833            NamedTempFile::new()?
834        };
835        if let Err(err) = file
836            .as_file()
837            .set_permissions(std::fs::Permissions::from_mode(0o777))
838        {
839            warn!("Failed to set permissions on temporary file: {err}");
840        }
841
842        // Try to move the file to path, but if path exists now, just open path
843        match file.persist_noclobber(path.as_ref()) {
844            Ok(file) => Ok(fs_err::File::from_parts(file, path.as_ref())),
845            Err(err) => {
846                if err.error.kind() == std::io::ErrorKind::AlreadyExists {
847                    fs_err::OpenOptions::new()
848                        .read(true)
849                        .write(true)
850                        .open(path.as_ref())
851                } else {
852                    Err(err.error)
853                }
854            }
855        }
856    }
857
858    #[cfg(not(unix))]
859    fn create(path: impl AsRef<Path>) -> std::io::Result<fs_err::File> {
860        fs_err::OpenOptions::new()
861            .read(true)
862            .write(true)
863            .create(true)
864            .open(path.as_ref())
865    }
866}
867
868impl Drop for LockedFile {
869    fn drop(&mut self) {
870        if let Err(err) = self.0.unlock() {
871            error!(
872                "Failed to unlock resource at `{}`; program may be stuck: {err}",
873                self.0.path().display()
874            );
875        } else {
876            debug!("Released lock at `{}`", self.0.path().display());
877        }
878    }
879}
880
881/// An asynchronous reader that reports progress as bytes are read.
882#[cfg(feature = "tokio")]
883pub struct ProgressReader<Reader: tokio::io::AsyncRead + Unpin, Callback: Fn(usize) + Unpin> {
884    reader: Reader,
885    callback: Callback,
886}
887
888#[cfg(feature = "tokio")]
889impl<Reader: tokio::io::AsyncRead + Unpin, Callback: Fn(usize) + Unpin>
890    ProgressReader<Reader, Callback>
891{
892    /// Create a new [`ProgressReader`] that wraps another reader.
893    pub fn new(reader: Reader, callback: Callback) -> Self {
894        Self { reader, callback }
895    }
896}
897
898#[cfg(feature = "tokio")]
899impl<Reader: tokio::io::AsyncRead + Unpin, Callback: Fn(usize) + Unpin> tokio::io::AsyncRead
900    for ProgressReader<Reader, Callback>
901{
902    fn poll_read(
903        mut self: std::pin::Pin<&mut Self>,
904        cx: &mut std::task::Context<'_>,
905        buf: &mut tokio::io::ReadBuf<'_>,
906    ) -> std::task::Poll<std::io::Result<()>> {
907        std::pin::Pin::new(&mut self.as_mut().reader)
908            .poll_read(cx, buf)
909            .map_ok(|()| {
910                (self.callback)(buf.filled().len());
911            })
912    }
913}
914
915/// Recursively copy a directory and its contents.
916pub fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Result<()> {
917    fs_err::create_dir_all(&dst)?;
918    for entry in fs_err::read_dir(src.as_ref())? {
919        let entry = entry?;
920        let ty = entry.file_type()?;
921        if ty.is_dir() {
922            copy_dir_all(entry.path(), dst.as_ref().join(entry.file_name()))?;
923        } else {
924            fs_err::copy(entry.path(), dst.as_ref().join(entry.file_name()))?;
925        }
926    }
927    Ok(())
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933    use std::path::PathBuf;
934
935    #[test]
936    fn test_with_added_extension() {
937        // Test with simple package name (no dots)
938        let path = PathBuf::from("python");
939        let result = with_added_extension(&path, "exe");
940        assert_eq!(result, PathBuf::from("python.exe"));
941
942        // Test with package name containing single dot
943        let path = PathBuf::from("awslabs.cdk-mcp-server");
944        let result = with_added_extension(&path, "exe");
945        assert_eq!(result, PathBuf::from("awslabs.cdk-mcp-server.exe"));
946
947        // Test with package name containing multiple dots
948        let path = PathBuf::from("org.example.tool");
949        let result = with_added_extension(&path, "exe");
950        assert_eq!(result, PathBuf::from("org.example.tool.exe"));
951
952        // Test with different extensions
953        let path = PathBuf::from("script");
954        let result = with_added_extension(&path, "ps1");
955        assert_eq!(result, PathBuf::from("script.ps1"));
956
957        // Test with path that has directory components
958        let path = PathBuf::from("some/path/to/awslabs.cdk-mcp-server");
959        let result = with_added_extension(&path, "exe");
960        assert_eq!(
961            result,
962            PathBuf::from("some/path/to/awslabs.cdk-mcp-server.exe")
963        );
964
965        // Test with empty path (edge case)
966        let path = PathBuf::new();
967        let result = with_added_extension(&path, "exe");
968        assert_eq!(result, path); // Should return unchanged
969    }
970}