mirror of https://github.com/astral-sh/uv
Lock the credentials store when reading or writing (#15610)
Adds locking of the credentials store for concurrency safety. It's important to hold the lock from read -> write so credentials are not dropped during concurrent writes. I opted not to attach the lock to the store itself. Instead, I return the lock on read and require it on write to encourage safe use. Maybe attaching the source path to the store struct and adding a `lock(&self)` method would make sense? but then you can forget to take the lock at the right time. The main problem with the interface here is to write a _new_ store you have to take the lock yourself, and you could make a mistake by taking a lock for the wrong path or something. The fix for that would be to introduce a new `CredentialStoreHandle` type or something, but that seems overzealous rn. We also don't eagerly drop the lock on token read, although we could.
This commit is contained in:
parent
7d627b50ef
commit
7ac957af8f
|
|
@ -5095,6 +5095,7 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
"uv-fs",
|
||||||
"uv-keyring",
|
"uv-keyring",
|
||||||
"uv-once-map",
|
"uv-once-map",
|
||||||
"uv-redacted",
|
"uv-redacted",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ doctest = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
uv-fs = { workspace = true }
|
||||||
uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] }
|
uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] }
|
||||||
uv-once-map = { workspace = true }
|
uv-once-map = { workspace = true }
|
||||||
uv-redacted = { workspace = true }
|
uv-redacted = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -71,8 +71,8 @@ impl Default for TextStoreMode {
|
||||||
})
|
})
|
||||||
.ok()?;
|
.ok()?;
|
||||||
|
|
||||||
match TextCredentialStore::from_file(&path) {
|
match TextCredentialStore::read(&path) {
|
||||||
Ok(store) => {
|
Ok((store, _lock)) => {
|
||||||
debug!("Loaded credential file {}", path.display());
|
debug!("Loaded credential file {}", path.display());
|
||||||
Some(store)
|
Some(store)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use rustc_hash::FxHashMap;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
use uv_fs::{LockedFile, with_added_extension};
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
|
||||||
use uv_state::{StateBucket, StateStore};
|
use uv_state::{StateBucket, StateStore};
|
||||||
|
|
@ -199,8 +200,17 @@ impl TextCredentialStore {
|
||||||
Ok(dir.join("credentials.toml"))
|
Ok(dir.join("credentials.toml"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Acquire a lock on the credentials file at the given path.
|
||||||
|
pub fn lock(path: &Path) -> Result<LockedFile, TomlCredentialError> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let lock = with_added_extension(path, ".lock");
|
||||||
|
Ok(LockedFile::acquire_blocking(lock, "credentials store")?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Read credentials from a file.
|
/// Read credentials from a file.
|
||||||
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
|
fn from_file<P: AsRef<Path>>(path: P) -> Result<Self, TomlCredentialError> {
|
||||||
let content = fs::read_to_string(path)?;
|
let content = fs::read_to_string(path)?;
|
||||||
let credentials: TomlCredentials = toml::from_str(&content)?;
|
let credentials: TomlCredentials = toml::from_str(&content)?;
|
||||||
|
|
||||||
|
|
@ -213,8 +223,26 @@ impl TextCredentialStore {
|
||||||
Ok(Self { credentials })
|
Ok(Self { credentials })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read credentials from a file.
|
||||||
|
///
|
||||||
|
/// Returns [`TextCredentialStore`] and a [`LockedFile`] to hold if mutating the store.
|
||||||
|
///
|
||||||
|
/// If the store will not be written to following the read, the lock can be dropped.
|
||||||
|
pub fn read<P: AsRef<Path>>(path: P) -> Result<(Self, LockedFile), TomlCredentialError> {
|
||||||
|
let lock = Self::lock(path.as_ref())?;
|
||||||
|
let store = Self::from_file(path)?;
|
||||||
|
Ok((store, lock))
|
||||||
|
}
|
||||||
|
|
||||||
/// Persist credentials to a file.
|
/// Persist credentials to a file.
|
||||||
pub fn write<P: AsRef<Path>>(self, path: P) -> Result<(), TomlCredentialError> {
|
///
|
||||||
|
/// Requires a [`LockedFile`] from [`TextCredentialStore::lock`] or
|
||||||
|
/// [`TextCredentialStore::read`] to ensure exclusive access.
|
||||||
|
pub fn write<P: AsRef<Path>>(
|
||||||
|
self,
|
||||||
|
path: P,
|
||||||
|
_lock: LockedFile,
|
||||||
|
) -> Result<(), TomlCredentialError> {
|
||||||
let credentials = self
|
let credentials = self
|
||||||
.credentials
|
.credentials
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -390,7 +418,12 @@ password = "pass2"
|
||||||
|
|
||||||
// Test saving
|
// Test saving
|
||||||
let temp_output = NamedTempFile::new().unwrap();
|
let temp_output = NamedTempFile::new().unwrap();
|
||||||
store.write(temp_output.path()).unwrap();
|
store
|
||||||
|
.write(
|
||||||
|
temp_output.path(),
|
||||||
|
TextCredentialStore::lock(temp_file.path()).unwrap(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
let content = fs::read_to_string(temp_output.path()).unwrap();
|
let content = fs::read_to_string(temp_output.path()).unwrap();
|
||||||
assert!(content.contains("example.com"));
|
assert!(content.contains("example.com"));
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
use std::borrow::Cow;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
@ -12,6 +13,24 @@ pub mod cachedir;
|
||||||
mod path;
|
mod path;
|
||||||
pub mod which;
|
pub mod which;
|
||||||
|
|
||||||
|
/// Append an extension to a [`PathBuf`].
|
||||||
|
///
|
||||||
|
/// Unlike [`Path::with_extension`], this function does not replace an existing extension.
|
||||||
|
///
|
||||||
|
/// If there is no file name, the path is returned unchanged.
|
||||||
|
///
|
||||||
|
/// This mimics the behavior of the unstable [`Path::with_added_extension`] method.
|
||||||
|
pub fn with_added_extension<'a>(path: &'a Path, extension: &str) -> Cow<'a, Path> {
|
||||||
|
let Some(name) = path.file_name() else {
|
||||||
|
// If there is no file name, we cannot add an extension.
|
||||||
|
return Cow::Borrowed(path);
|
||||||
|
};
|
||||||
|
let mut name = name.to_os_string();
|
||||||
|
name.push(".");
|
||||||
|
name.push(extension.trim_start_matches('.'));
|
||||||
|
Cow::Owned(path.with_file_name(name))
|
||||||
|
}
|
||||||
|
|
||||||
/// Attempt to check if the two paths refer to the same file.
|
/// Attempt to check if the two paths refer to the same file.
|
||||||
///
|
///
|
||||||
/// Returns `Some(true)` if the files are missing, but would be the same if they existed.
|
/// Returns `Some(true)` if the files are missing, but would be the same if they existed.
|
||||||
|
|
@ -812,3 +831,45 @@ pub fn copy_dir_all(src: impl AsRef<Path>, dst: impl AsRef<Path>) -> std::io::Re
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_with_added_extension() {
|
||||||
|
// Test with simple package name (no dots)
|
||||||
|
let path = PathBuf::from("python");
|
||||||
|
let result = with_added_extension(&path, "exe");
|
||||||
|
assert_eq!(result, PathBuf::from("python.exe"));
|
||||||
|
|
||||||
|
// Test with package name containing single dot
|
||||||
|
let path = PathBuf::from("awslabs.cdk-mcp-server");
|
||||||
|
let result = with_added_extension(&path, "exe");
|
||||||
|
assert_eq!(result, PathBuf::from("awslabs.cdk-mcp-server.exe"));
|
||||||
|
|
||||||
|
// Test with package name containing multiple dots
|
||||||
|
let path = PathBuf::from("org.example.tool");
|
||||||
|
let result = with_added_extension(&path, "exe");
|
||||||
|
assert_eq!(result, PathBuf::from("org.example.tool.exe"));
|
||||||
|
|
||||||
|
// Test with different extensions
|
||||||
|
let path = PathBuf::from("script");
|
||||||
|
let result = with_added_extension(&path, "ps1");
|
||||||
|
assert_eq!(result, PathBuf::from("script.ps1"));
|
||||||
|
|
||||||
|
// Test with path that has directory components
|
||||||
|
let path = PathBuf::from("some/path/to/awslabs.cdk-mcp-server");
|
||||||
|
let result = with_added_extension(&path, "exe");
|
||||||
|
assert_eq!(
|
||||||
|
result,
|
||||||
|
PathBuf::from("some/path/to/awslabs.cdk-mcp-server.exe")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test with empty path (edge case)
|
||||||
|
let path = PathBuf::new();
|
||||||
|
let result = with_added_extension(&path, "exe");
|
||||||
|
assert_eq!(result, path); // Should return unchanged
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,27 +2,10 @@
|
||||||
|
|
||||||
use std::env::consts::EXE_EXTENSION;
|
use std::env::consts::EXE_EXTENSION;
|
||||||
use std::ffi::OsStr;
|
use std::ffi::OsStr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::Path;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
/// Append an extension to a [`PathBuf`].
|
use uv_fs::with_added_extension;
|
||||||
///
|
|
||||||
/// Unlike [`Path::with_extension`], this function does not replace an existing extension.
|
|
||||||
///
|
|
||||||
/// If there is no file name, the path is returned unchanged.
|
|
||||||
///
|
|
||||||
/// This mimics the behavior of the unstable [`Path::with_added_extension`] method.
|
|
||||||
fn add_extension_to_path(mut path: PathBuf, extension: &str) -> PathBuf {
|
|
||||||
let Some(name) = path.file_name() else {
|
|
||||||
// If there is no file name, we cannot add an extension.
|
|
||||||
return path;
|
|
||||||
};
|
|
||||||
let mut name = name.to_os_string();
|
|
||||||
name.push(".");
|
|
||||||
name.push(extension.trim_start_matches('.'));
|
|
||||||
path.set_file_name(name);
|
|
||||||
path
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum WindowsRunnable {
|
pub enum WindowsRunnable {
|
||||||
|
|
@ -109,7 +92,7 @@ impl WindowsRunnable {
|
||||||
.map(|script_type| {
|
.map(|script_type| {
|
||||||
(
|
(
|
||||||
script_type,
|
script_type,
|
||||||
add_extension_to_path(script_path.clone(), script_type.to_extension()),
|
with_added_extension(&script_path, script_type.to_extension()),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.find(|(_, script_path)| script_path.is_file())
|
.find(|(_, script_path)| script_path.is_file())
|
||||||
|
|
@ -120,50 +103,16 @@ impl WindowsRunnable {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
use super::WindowsRunnable;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
use fs_err as fs;
|
use fs_err as fs;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
|
use std::ffi::OsStr;
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_add_extension_to_path() {
|
|
||||||
// Test with simple package name (no dots)
|
|
||||||
let path = PathBuf::from("python");
|
|
||||||
let result = add_extension_to_path(path, "exe");
|
|
||||||
assert_eq!(result, PathBuf::from("python.exe"));
|
|
||||||
|
|
||||||
// Test with package name containing single dot
|
|
||||||
let path = PathBuf::from("awslabs.cdk-mcp-server");
|
|
||||||
let result = add_extension_to_path(path, "exe");
|
|
||||||
assert_eq!(result, PathBuf::from("awslabs.cdk-mcp-server.exe"));
|
|
||||||
|
|
||||||
// Test with package name containing multiple dots
|
|
||||||
let path = PathBuf::from("org.example.tool");
|
|
||||||
let result = add_extension_to_path(path, "exe");
|
|
||||||
assert_eq!(result, PathBuf::from("org.example.tool.exe"));
|
|
||||||
|
|
||||||
// Test with different extensions
|
|
||||||
let path = PathBuf::from("script");
|
|
||||||
let result = add_extension_to_path(path, "ps1");
|
|
||||||
assert_eq!(result, PathBuf::from("script.ps1"));
|
|
||||||
|
|
||||||
// Test with path that has directory components
|
|
||||||
let path = PathBuf::from("some/path/to/awslabs.cdk-mcp-server");
|
|
||||||
let result = add_extension_to_path(path, "exe");
|
|
||||||
assert_eq!(
|
|
||||||
result,
|
|
||||||
PathBuf::from("some/path/to/awslabs.cdk-mcp-server.exe")
|
|
||||||
);
|
|
||||||
|
|
||||||
// Test with empty path (edge case)
|
|
||||||
let path = PathBuf::new();
|
|
||||||
let result = add_extension_to_path(path.clone(), "exe");
|
|
||||||
assert_eq!(result, path); // Should return unchanged
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Helper function to create a temporary directory with test files
|
/// Helper function to create a temporary directory with test files
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
fn create_test_environment() -> io::Result<tempfile::TempDir> {
|
fn create_test_environment() -> io::Result<tempfile::TempDir> {
|
||||||
|
|
|
||||||
|
|
@ -115,9 +115,9 @@ pub(crate) async fn login(
|
||||||
AuthBackend::Keyring(provider) => {
|
AuthBackend::Keyring(provider) => {
|
||||||
provider.store(&url, &credentials).await?;
|
provider.store(&url, &credentials).await?;
|
||||||
}
|
}
|
||||||
AuthBackend::TextStore(mut text_store) => {
|
AuthBackend::TextStore(mut store, _lock) => {
|
||||||
text_store.insert(service.clone(), credentials);
|
store.insert(service.clone(), credentials);
|
||||||
text_store.write(TextCredentialStore::default_file()?)?;
|
store.write(TextCredentialStore::default_file()?, _lock)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,12 +61,12 @@ pub(crate) async fn logout(
|
||||||
.await
|
.await
|
||||||
.with_context(|| format!("Unable to remove credentials for {display_url}"))?;
|
.with_context(|| format!("Unable to remove credentials for {display_url}"))?;
|
||||||
}
|
}
|
||||||
AuthBackend::TextStore(mut text_store) => {
|
AuthBackend::TextStore(mut store, _lock) => {
|
||||||
if text_store.remove(&service).is_none() {
|
if store.remove(&service).is_none() {
|
||||||
bail!("No matching entry found for {display_url}");
|
bail!("No matching entry found for {display_url}");
|
||||||
}
|
}
|
||||||
text_store
|
store
|
||||||
.write(TextCredentialStore::default_file()?)
|
.write(TextCredentialStore::default_file()?, _lock)
|
||||||
.with_context(|| "Failed to persist changes to credentials after removal")?;
|
.with_context(|| "Failed to persist changes to credentials after removal")?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use uv_auth::{KeyringProvider, TextCredentialStore, TomlCredentialError};
|
use uv_auth::{KeyringProvider, TextCredentialStore, TomlCredentialError};
|
||||||
use uv_configuration::KeyringProviderType;
|
use uv_configuration::KeyringProviderType;
|
||||||
|
use uv_fs::LockedFile;
|
||||||
use uv_preview::Preview;
|
use uv_preview::Preview;
|
||||||
|
|
||||||
pub(crate) mod dir;
|
pub(crate) mod dir;
|
||||||
|
|
@ -10,7 +11,7 @@ pub(crate) mod token;
|
||||||
/// The storage backend to use in `uv auth` commands.
|
/// The storage backend to use in `uv auth` commands.
|
||||||
enum AuthBackend {
|
enum AuthBackend {
|
||||||
Keyring(KeyringProvider),
|
Keyring(KeyringProvider),
|
||||||
TextStore(TextCredentialStore),
|
TextStore(TextCredentialStore, LockedFile),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthBackend {
|
impl AuthBackend {
|
||||||
|
|
@ -27,10 +28,14 @@ impl AuthBackend {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, we'll use the plain text credential store
|
// Otherwise, we'll use the plain text credential store
|
||||||
match TextCredentialStore::from_file(TextCredentialStore::default_file()?) {
|
let path = TextCredentialStore::default_file()?;
|
||||||
Ok(store) => Ok(Self::TextStore(store)),
|
match TextCredentialStore::read(&path) {
|
||||||
|
Ok((store, lock)) => Ok(Self::TextStore(store, lock)),
|
||||||
Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
|
Err(TomlCredentialError::Io(err)) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||||
Ok(Self::TextStore(TextCredentialStore::default()))
|
Ok(Self::TextStore(
|
||||||
|
TextCredentialStore::default(),
|
||||||
|
TextCredentialStore::lock(&path)?,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
Err(err) => Err(err),
|
Err(err) => Err(err),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ pub(crate) async fn token(
|
||||||
.fetch(url, Some(&username))
|
.fetch(url, Some(&username))
|
||||||
.await
|
.await
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?,
|
.ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?,
|
||||||
AuthBackend::TextStore(text_store) => text_store
|
AuthBackend::TextStore(store, _lock) => store
|
||||||
.get_credentials(url)
|
.get_credentials(url)
|
||||||
.cloned()
|
.cloned()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?,
|
.ok_or_else(|| anyhow::anyhow!("Failed to fetch credentials for {display_url}"))?,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue