diff --git a/crates/uv-settings/src/lib.rs b/crates/uv-settings/src/lib.rs index ecc687229..60367603f 100644 --- a/crates/uv-settings/src/lib.rs +++ b/crates/uv-settings/src/lib.rs @@ -58,6 +58,13 @@ impl FilesystemOptions { | std::io::ErrorKind::PermissionDenied ) => { + // For PermissionDenied, warn the user about inaccessible config. + if err.kind() == std::io::ErrorKind::PermissionDenied { + warn_user!( + "Permission denied while reading user configuration in `{}`; using defaults.", + file.user_display().cyan() + ); + } Ok(None) } Err(err) => Err(err), @@ -69,10 +76,35 @@ impl FilesystemOptions { return Ok(None); }; - tracing::debug!("Found system configuration in: `{}`", file.display()); - let options = read_file(&file)?; - validate_uv_toml(&file, &options)?; - Ok(Some(Self(options))) + tracing::debug!( + "Searching for system configuration in: `{}`", + file.display() + ); + match read_file(&file) { + Ok(options) => { + tracing::debug!("Found system configuration in: `{}`", file.display()); + validate_uv_toml(&file, &options)?; + Ok(Some(Self(options))) + } + Err(Error::Io(err)) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::NotADirectory + | std::io::ErrorKind::PermissionDenied + ) => + { + // For PermissionDenied, warn the user about inaccessible config. + if err.kind() == std::io::ErrorKind::PermissionDenied { + warn_user!( + "Permission denied while reading system configuration in `{}`; using defaults.", + file.user_display().cyan() + ); + } + Ok(None) + } + Err(err) => Err(err), + } } /// Find the [`FilesystemOptions`] for the given path. @@ -96,6 +128,23 @@ impl FilesystemOptions { textwrap::indent(&err.to_string(), " ") ); } + Err(Error::Io(err)) + if matches!( + err.kind(), + std::io::ErrorKind::NotFound + | std::io::ErrorKind::NotADirectory + | std::io::ErrorKind::PermissionDenied + ) => + { + // For PermissionDenied, warn the user about inaccessible workspace config. + if err.kind() == std::io::ErrorKind::PermissionDenied { + warn_user!( + "Permission denied while reading workspace configuration in `{}`; continuing search.", + ancestor.user_display().cyan() + ); + } + // Continue traversing the directory tree. + } Err(err) => { // Otherwise, warn and stop. return Err(err); diff --git a/crates/uv/tests/it/show_settings.rs b/crates/uv/tests/it/show_settings.rs index f951d754d..a3261094a 100644 --- a/crates/uv/tests/it/show_settings.rs +++ b/crates/uv/tests/it/show_settings.rs @@ -1,6 +1,9 @@ use std::path::Path; use std::process::Command; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + use assert_fs::prelude::*; use uv_static::EnvVars; @@ -10764,3 +10767,111 @@ fn build_isolation_override() -> anyhow::Result<()> { Ok(()) } + +/// Skip configuration in parent directory when permissions are denied. +#[test] +#[cfg_attr( + windows, + ignore = "Configuration tests are not yet supported on Windows" +)] +#[cfg(unix)] +fn resolve_permission_denied() -> anyhow::Result<()> { + // RAII guard to ensure permissions are restored even if the test fails or panics. + struct PermissionGuard<'a> { + path: &'a std::path::Path, + should_restore: bool, + } + + impl Drop for PermissionGuard<'_> { + fn drop(&mut self) { + if self.should_restore { + let _ = fs_err::set_permissions(self.path, std::fs::Permissions::from_mode(0o755)); + } + } + } + + let context = TestContext::new("3.12"); + + // Create a parent directory with a `uv.toml` file that sets a non-default resolution. + let parent = context.temp_dir.child("parent"); + fs_err::create_dir(&parent)?; + let config = parent.child("uv.toml"); + config.write_str(indoc::indoc! {r#" + [pip] + resolution = "lowest-direct" + index-url = "https://test.pypi.org/simple" + "#})?; + + // Create a child directory to run the command from. + let child = parent.child("child"); + fs_err::create_dir(&child)?; + let requirements_in = child.child("requirements.in"); + requirements_in.write_str("anyio>3.0.0")?; + + // Test 1: Normal permissions - should find and use parent config. + let result = context + .pip_compile() + .arg("--show-settings") + .arg("requirements.in") + .current_dir(&child) + .output()?; + assert!(result.status.success()); + let stdout = std::str::from_utf8(&result.stdout)?; + // Should contain the parent config's resolution. + assert!(stdout.contains("resolution: LowestDirect")); + assert!(stdout.contains("test.pypi.org")); + + // Test 2: Permission denied - should gracefully skip parent config. + // Try to remove read permissions from the config file itself (more targeted approach) + let permission_denied_setup = + fs_err::set_permissions(config.path(), std::fs::Permissions::from_mode(0o000)).is_ok(); + + let _guard = PermissionGuard { + path: config.path(), + should_restore: permission_denied_setup, + }; + + let result = context + .pip_compile() + .arg("--show-settings") + .arg("requirements.in") + .current_dir(&child) + .output()?; + + assert!(result.status.success()); + let stdout = std::str::from_utf8(&result.stdout)?; + let stderr = std::str::from_utf8(&result.stderr)?; + + // Should use default resolution (not parent config) if permissions were denied. + if permission_denied_setup { + assert!(stdout.contains("resolution: Highest")); + assert!(!stdout.contains("test.pypi.org")); + + // Should warn about permission denied (it's an error condition). + assert!(stderr.contains("Permission denied while reading workspace configuration")); + assert!(stderr.contains("continuing search")); + } else { + // If we couldn't set permissions, the test behaves like normal case + assert!(stdout.contains("resolution: LowestDirect")); + assert!(stdout.contains("test.pypi.org")); + assert!(!stderr.contains("Permission denied")); + } + + // Test 3: UV_NO_CONFIG - should skip all config discovery silently. + let result = context + .pip_compile() + .arg("--show-settings") + .arg("requirements.in") + .arg("--no-config") + .current_dir(&child) + .output()?; + + assert!(result.status.success()); + let stdout = std::str::from_utf8(&result.stdout)?; + let stderr = std::str::from_utf8(&result.stderr)?; + assert!(stdout.contains("resolution: Highest")); + assert!(!stdout.contains("test.pypi.org")); + assert!(!stderr.contains("Permission denied")); // UV_NO_CONFIG is silent + + Ok(()) +}