From 681e8e060fdc4ca2d0a180159ce63a720d3b93cf Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 15 Jan 2026 07:23:10 -0600 Subject: [PATCH] Improve cache initialization failure error message (#17469) Adds an error chain to improve clarity when we fail to initialize the cache directory. Related to https://github.com/astral-sh/uv/issues/17465 --------- Co-authored-by: Claude --- crates/uv-cache/src/lib.rs | 6 ++- crates/uv/tests/it/cache.rs | 70 ++++++++++++++++++++++++++++++++ crates/uv/tests/it/common/mod.rs | 40 ++++++++++++++++++ crates/uv/tests/it/main.rs | 3 ++ crates/uv/tests/it/venv.rs | 3 +- 5 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 crates/uv/tests/it/cache.rs diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index d67118cd5..252f42e12 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -40,6 +40,8 @@ pub const ARCHIVE_VERSION: u8 = 0; pub enum Error { #[error(transparent)] Io(#[from] io::Error), + #[error("Failed to initialize cache at `{}`", _0.user_display())] + Init(PathBuf, #[source] io::Error), #[error("Could not make the path absolute")] Absolute(#[source] io::Error), #[error("Could not acquire lock")] @@ -455,7 +457,7 @@ impl Cache { pub async fn init(self) -> Result { let root = &self.root; - Self::create_base_files(root)?; + Self::create_base_files(root).map_err(|err| Error::Init(root.clone(), err))?; // Block cache removal operations from interfering. let lock_file = match LockedFile::acquire( @@ -491,7 +493,7 @@ impl Cache { pub fn init_no_wait(self) -> Result, Error> { let root = &self.root; - Self::create_base_files(root)?; + Self::create_base_files(root).map_err(|err| Error::Init(root.clone(), err))?; // Block cache removal operations from interfering. let Some(lock_file) = LockedFile::acquire_no_wait( diff --git a/crates/uv/tests/it/cache.rs b/crates/uv/tests/it/cache.rs new file mode 100644 index 000000000..14bf972e8 --- /dev/null +++ b/crates/uv/tests/it/cache.rs @@ -0,0 +1,70 @@ +#[cfg(unix)] +use anyhow::Result; +#[cfg(unix)] +use assert_fs::prelude::*; +#[cfg(unix)] +use std::process::Command; + +#[cfg(unix)] +use crate::common::{TestContext, get_bin, uv_snapshot}; + +/// When the cache directory cannot be created (e.g., due to permissions), we should show a +/// chained error message that indicates we failed to initialize the cache. +#[test] +#[cfg(unix)] +fn cache_init_failure() -> Result<()> { + use crate::common::ReadOnlyDirectoryGuard; + + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["iniconfig"] + "#, + )?; + + // Create a read-only directory that will serve as the parent of the cache. + // The guard sets it to read-only and restores original permissions on drop (including panic). + let cache_parent = context.temp_dir.child("cache_parent"); + fs_err::create_dir(&cache_parent)?; + let _guard = ReadOnlyDirectoryGuard::new(cache_parent.path())?; + + // Point the cache to a subdirectory within the read-only parent + let cache_dir = cache_parent.child("cache"); + + let mut filters = context.filters(); + // Filter both the relative path (in the first line) and absolute path (in the cause) + filters.push((r"cache_parent/cache", "[CACHE_DIR]")); + filters.push(( + r"failed to create directory `.*`", + "failed to create directory `[CACHE_DIR]`", + )); + + // Build the sync command manually to use our custom cache directory. + // We can't use context.sync() because it adds --cache-dir with the default cache. + let mut command = Command::new(get_bin()); + command + .arg("sync") + .arg("--cache-dir") + .arg(cache_dir.path()) + .current_dir(context.temp_dir.path()); + context.add_shared_env(&mut command, false); + + // Running a command should fail with a chained error about cache initialization + uv_snapshot!(&filters, command, @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to initialize cache at `[CACHE_DIR]` + Caused by: failed to create directory `[CACHE_DIR]`: Permission denied (os error 13) + "); + + Ok(()) +} diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index a742793a6..3c195ebb4 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -2151,6 +2151,46 @@ pub async fn download_to_disk(url: &str, path: &Path) { file.sync_all().await.unwrap(); } +/// A guard that sets a directory to read-only and restores original permissions when dropped. +/// +/// This is useful for tests that need to make a directory read-only and ensure +/// the permissions are restored even if the test panics. +#[cfg(unix)] +pub struct ReadOnlyDirectoryGuard { + path: PathBuf, + original_mode: u32, +} + +#[cfg(unix)] +impl ReadOnlyDirectoryGuard { + /// Sets the directory to read-only (removes write permission) and returns a guard + /// that will restore the original permissions when dropped. + pub fn new(path: impl Into) -> std::io::Result { + use std::os::unix::fs::PermissionsExt; + let path = path.into(); + let metadata = fs_err::metadata(&path)?; + let original_mode = metadata.permissions().mode(); + // Remove write permissions (keep read and execute) + let readonly_mode = original_mode & !0o222; + fs_err::set_permissions(&path, std::fs::Permissions::from_mode(readonly_mode))?; + Ok(Self { + path, + original_mode, + }) + } +} + +#[cfg(unix)] +impl Drop for ReadOnlyDirectoryGuard { + fn drop(&mut self) { + use std::os::unix::fs::PermissionsExt; + let _ = fs_err::set_permissions( + &self.path, + std::fs::Permissions::from_mode(self.original_mode), + ); + } +} + /// Utility macro to return the name of the current function. /// /// https://stackoverflow.com/a/40234666/3549270 diff --git a/crates/uv/tests/it/main.rs b/crates/uv/tests/it/main.rs index b81292b49..16ac12102 100644 --- a/crates/uv/tests/it/main.rs +++ b/crates/uv/tests/it/main.rs @@ -13,6 +13,9 @@ mod build; #[cfg(feature = "python")] mod build_backend; +#[cfg(all(feature = "python", feature = "pypi"))] +mod cache; + #[cfg(all(feature = "python", feature = "pypi"))] mod cache_clean; diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index c2b9761aa..9094deb3d 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -1379,7 +1379,8 @@ fn path_with_trailing_space_gives_proper_error() { ----- stdout ----- ----- stderr ----- - error: failed to open file `[CACHE_DIR]/ /CACHEDIR.TAG`: The system cannot find the path specified. (os error 3) + error: Failed to initialize cache at `[CACHE_DIR]/ ` + Caused by: failed to open file `[CACHE_DIR]/ /CACHEDIR.TAG`: The system cannot find the path specified. (os error 3) "### ); // Note the extra trailing `/` in the snapshot is due to the filters, not the actual output.