1
- use std:: process:: Stdio ;
1
+ use std:: { io :: Write , process:: Stdio } ;
2
2
use tokio:: process:: Command ;
3
3
use tracing:: { instrument, trace, warn} ;
4
4
use url:: Url ;
5
+ use uv_warnings:: warn_user_once;
5
6
6
7
use crate :: credentials:: Credentials ;
7
8
@@ -19,7 +20,7 @@ pub(crate) enum KeyringProviderBackend {
19
20
/// Use the `keyring` command to fetch credentials.
20
21
Subprocess ,
21
22
#[ cfg( test) ]
22
- Dummy ( std :: collections :: HashMap < ( String , & ' static str ) , & ' static str > ) ,
23
+ Dummy ( Vec < ( String , & ' static str , & ' static str ) > ) ,
23
24
}
24
25
25
26
impl KeyringProvider {
@@ -35,7 +36,7 @@ impl KeyringProvider {
35
36
/// Returns [`None`] if no password was found for the username or if any errors
36
37
/// are encountered in the keyring backend.
37
38
#[ instrument( skip_all, fields( url = % url. to_string( ) , username) ) ]
38
- pub async fn fetch ( & self , url : & Url , username : & str ) -> Option < Credentials > {
39
+ pub async fn fetch ( & self , url : & Url , username : Option < & str > ) -> Option < Credentials > {
39
40
// Validate the request
40
41
debug_assert ! (
41
42
url. host_str( ) . is_some( ) ,
@@ -46,14 +47,14 @@ impl KeyringProvider {
46
47
"Should only use keyring for urls without a password"
47
48
) ;
48
49
debug_assert ! (
49
- !username. is_empty( ) ,
50
- "Should only use keyring with a username"
50
+ !username. map ( str :: is_empty) . unwrap_or ( false ) ,
51
+ "Should only use keyring with a non-empty username"
51
52
) ;
52
53
53
54
// Check the full URL first
54
55
// <https://github.com/pypa/pip/blob/ae5fff36b0aad6e5e0037884927eaa29163c0611/src/pip/_internal/network/auth.py#L376C1-L379C14>
55
56
trace ! ( "Checking keyring for URL {url}" ) ;
56
- let mut password = match self . backend {
57
+ let mut credentials = match self . backend {
57
58
KeyringProviderBackend :: Subprocess => {
58
59
self . fetch_subprocess ( url. as_str ( ) , username) . await
59
60
}
@@ -63,14 +64,14 @@ impl KeyringProvider {
63
64
}
64
65
} ;
65
66
// And fallback to a check for the host
66
- if password . is_none ( ) {
67
+ if credentials . is_none ( ) {
67
68
let host = if let Some ( port) = url. port ( ) {
68
69
format ! ( "{}:{}" , url. host_str( ) ?, port)
69
70
} else {
70
71
url. host_str ( ) ?. to_string ( )
71
72
} ;
72
73
trace ! ( "Checking keyring for host {host}" ) ;
73
- password = match self . backend {
74
+ credentials = match self . backend {
74
75
KeyringProviderBackend :: Subprocess => self . fetch_subprocess ( & host, username) . await ,
75
76
#[ cfg( test) ]
76
77
KeyringProviderBackend :: Dummy ( ref store) => {
@@ -79,19 +80,36 @@ impl KeyringProvider {
79
80
} ;
80
81
}
81
82
82
- password . map ( |password| Credentials :: new ( Some ( username. to_string ( ) ) , Some ( password) ) )
83
+ credentials . map ( |( username , password) | Credentials :: new ( Some ( username) , Some ( password) ) )
83
84
}
84
85
85
86
#[ instrument( skip( self ) ) ]
86
- async fn fetch_subprocess ( & self , service_name : & str , username : & str ) -> Option < String > {
87
+ async fn fetch_subprocess (
88
+ & self ,
89
+ service_name : & str ,
90
+ username : Option < & str > ,
91
+ ) -> Option < ( String , String ) > {
87
92
// https://github.com/pypa/pip/blob/24.0/src/pip/_internal/network/auth.py#L136-L141
88
- let child = Command :: new ( "keyring" )
89
- . arg ( "get" )
90
- . arg ( service_name)
91
- . arg ( username)
93
+ let mut command = Command :: new ( "keyring" ) ;
94
+ command. arg ( "get" ) . arg ( service_name) ;
95
+
96
+ if let Some ( username) = username {
97
+ command. arg ( username) ;
98
+ } else {
99
+ command. arg ( "--mode" ) . arg ( "creds" ) ;
100
+ }
101
+
102
+ let child = command
92
103
. stdin ( Stdio :: null ( ) )
93
104
. stdout ( Stdio :: piped ( ) )
94
- . stderr ( Stdio :: inherit ( ) )
105
+ // If we're using `--mode creds`, we need to capture the output in order to avoid
106
+ // showing users an "unrecognized arguments: --mode" error; otherwise, we stream stderr
107
+ // so the user has visibility into keyring's behavior if it's doing something slow
108
+ . stderr ( if username. is_some ( ) {
109
+ Stdio :: inherit ( )
110
+ } else {
111
+ Stdio :: piped ( )
112
+ } )
95
113
. spawn ( )
96
114
. inspect_err ( |err| warn ! ( "Failure running `keyring` command: {err}" ) )
97
115
. ok ( ) ?;
@@ -103,37 +121,74 @@ impl KeyringProvider {
103
121
. ok ( ) ?;
104
122
105
123
if output. status . success ( ) {
124
+ // If we captured stderr, display it in case it's helpful to the user
125
+ // TODO(zanieb): This was done when we added `--mode creds` support for parity with the
126
+ // existing behavior, but it might be a better UX to hide this on success? It also
127
+ // might be problematic that we're not streaming it. We could change this given some
128
+ // user feedback.
129
+ std:: io:: stderr ( ) . write_all ( & output. stderr ) . ok ( ) ;
130
+
106
131
// On success, parse the newline terminated password
107
- String :: from_utf8 ( output. stdout )
132
+ let output = String :: from_utf8 ( output. stdout )
108
133
. inspect_err ( |err| warn ! ( "Failed to parse response from `keyring` command: {err}" ) )
109
- . ok ( )
110
- . map ( |password| password. trim_end ( ) . to_string ( ) )
134
+ . ok ( ) ;
135
+
136
+ if let Some ( username) = username {
137
+ // We're only expecting a password
138
+ output. map ( |password| ( username. to_string ( ) , password. trim_end ( ) . to_string ( ) ) )
139
+ } else {
140
+ // We're expecting a username and password
141
+ output. and_then ( |output| {
142
+ let mut lines = output. lines ( ) ;
143
+ lines. next ( ) . and_then ( |username| {
144
+ lines
145
+ . next ( )
146
+ . map ( |password| ( username. to_string ( ) , password. to_string ( ) ) )
147
+ } )
148
+ } )
149
+ }
111
150
} else {
112
151
// On failure, no password was available
152
+ let stderr = std:: str:: from_utf8 ( & output. stderr ) . ok ( ) ?;
153
+ if stderr. contains ( "unrecognized arguments: --mode" ) {
154
+ // N.B. We do not show the `service_name` here because we'll show the warning twice
155
+ // otherwise, once for the URL and once for the realm.
156
+ warn_user_once ! (
157
+ "Attempted to fetch credentials using the `keyring` command, but it does not support `--mode creds`; upgrade to `keyring>=v25.2.1` for support or provide a username"
158
+ ) ;
159
+ } else if username. is_none ( ) {
160
+ // If we captured stderr, display it in case it's helpful to the user
161
+ std:: io:: stderr ( ) . write_all ( & output. stderr ) . ok ( ) ;
162
+ }
113
163
None
114
164
}
115
165
}
116
166
117
167
#[ cfg( test) ]
118
168
fn fetch_dummy (
119
- store : & std :: collections :: HashMap < ( String , & ' static str ) , & ' static str > ,
169
+ store : & Vec < ( String , & ' static str , & ' static str ) > ,
120
170
service_name : & str ,
121
- username : & str ,
122
- ) -> Option < String > {
123
- store
124
- . get ( & ( service_name. to_string ( ) , username) )
125
- . map ( |password| ( * password) . to_string ( ) )
171
+ username : Option < & str > ,
172
+ ) -> Option < ( String , String ) > {
173
+ store. iter ( ) . find_map ( |( service, user, password) | {
174
+ if service == service_name && username. map ( |username| username == * user) . unwrap_or ( true )
175
+ {
176
+ Some ( ( ( * user) . to_string ( ) , ( * password) . to_string ( ) ) )
177
+ } else {
178
+ None
179
+ }
180
+ } )
126
181
}
127
182
128
183
/// Create a new provider with [`KeyringProviderBackend::Dummy`].
129
184
#[ cfg( test) ]
130
- pub fn dummy < S : Into < String > , T : IntoIterator < Item = ( ( S , & ' static str ) , & ' static str ) > > (
185
+ pub fn dummy < S : Into < String > , T : IntoIterator < Item = ( S , & ' static str , & ' static str ) > > (
131
186
iter : T ,
132
187
) -> Self {
133
188
Self {
134
189
backend : KeyringProviderBackend :: Dummy (
135
190
iter. into_iter ( )
136
- . map ( |( ( service, username) , password) | ( ( service. into ( ) , username) , password) )
191
+ . map ( |( service, username, password) | ( service. into ( ) , username, password) )
137
192
. collect ( ) ,
138
193
) ,
139
194
}
@@ -142,10 +197,8 @@ impl KeyringProvider {
142
197
/// Create a new provider with no credentials available.
143
198
#[ cfg( test) ]
144
199
pub fn empty ( ) -> Self {
145
- use std:: collections:: HashMap ;
146
-
147
200
Self {
148
- backend : KeyringProviderBackend :: Dummy ( HashMap :: new ( ) ) ,
201
+ backend : KeyringProviderBackend :: Dummy ( Vec :: new ( ) ) ,
149
202
}
150
203
}
151
204
}
@@ -160,7 +213,7 @@ mod tests {
160
213
let url = Url :: parse ( "file:/etc/bin/" ) . unwrap ( ) ;
161
214
let keyring = KeyringProvider :: empty ( ) ;
162
215
// Panics due to debug assertion; returns `None` in production
163
- let result = std:: panic:: AssertUnwindSafe ( keyring. fetch ( & url, "user" ) )
216
+ let result = std:: panic:: AssertUnwindSafe ( keyring. fetch ( & url, Some ( "user" ) ) )
164
217
. catch_unwind ( )
165
218
. await ;
166
219
assert ! ( result. is_err( ) ) ;
@@ -171,18 +224,18 @@ mod tests {
171
224
let url = Url :: parse ( "https://user:password@example.com" ) . unwrap ( ) ;
172
225
let keyring = KeyringProvider :: empty ( ) ;
173
226
// Panics due to debug assertion; returns `None` in production
174
- let result = std:: panic:: AssertUnwindSafe ( keyring. fetch ( & url, url. username ( ) ) )
227
+ let result = std:: panic:: AssertUnwindSafe ( keyring. fetch ( & url, Some ( url. username ( ) ) ) )
175
228
. catch_unwind ( )
176
229
. await ;
177
230
assert ! ( result. is_err( ) ) ;
178
231
}
179
232
180
233
#[ tokio:: test]
181
- async fn fetch_url_with_no_username ( ) {
234
+ async fn fetch_url_with_empty_username ( ) {
182
235
let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
183
236
let keyring = KeyringProvider :: empty ( ) ;
184
237
// Panics due to debug assertion; returns `None` in production
185
- let result = std:: panic:: AssertUnwindSafe ( keyring. fetch ( & url, url. username ( ) ) )
238
+ let result = std:: panic:: AssertUnwindSafe ( keyring. fetch ( & url, Some ( url. username ( ) ) ) )
186
239
. catch_unwind ( )
187
240
. await ;
188
241
assert ! ( result. is_err( ) ) ;
@@ -192,23 +245,25 @@ mod tests {
192
245
async fn fetch_url_no_auth ( ) {
193
246
let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
194
247
let keyring = KeyringProvider :: empty ( ) ;
195
- let credentials = keyring. fetch ( & url, "user" ) ;
248
+ let credentials = keyring. fetch ( & url, Some ( "user" ) ) ;
196
249
assert ! ( credentials. await . is_none( ) ) ;
197
250
}
198
251
199
252
#[ tokio:: test]
200
253
async fn fetch_url ( ) {
201
254
let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
202
- let keyring = KeyringProvider :: dummy ( [ ( ( url. host_str ( ) . unwrap ( ) , "user" ) , "password" ) ] ) ;
255
+ let keyring = KeyringProvider :: dummy ( [ ( url. host_str ( ) . unwrap ( ) , "user" , "password" ) ] ) ;
203
256
assert_eq ! (
204
- keyring. fetch( & url, "user" ) . await ,
257
+ keyring. fetch( & url, Some ( "user" ) ) . await ,
205
258
Some ( Credentials :: new(
206
259
Some ( "user" . to_string( ) ) ,
207
260
Some ( "password" . to_string( ) )
208
261
) )
209
262
) ;
210
263
assert_eq ! (
211
- keyring. fetch( & url. join( "test" ) . unwrap( ) , "user" ) . await ,
264
+ keyring
265
+ . fetch( & url. join( "test" ) . unwrap( ) , Some ( "user" ) )
266
+ . await ,
212
267
Some ( Credentials :: new(
213
268
Some ( "user" . to_string( ) ) ,
214
269
Some ( "password" . to_string( ) )
@@ -219,34 +274,34 @@ mod tests {
219
274
#[ tokio:: test]
220
275
async fn fetch_url_no_match ( ) {
221
276
let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
222
- let keyring = KeyringProvider :: dummy ( [ ( ( "other.com" , "user" ) , "password" ) ] ) ;
223
- let credentials = keyring. fetch ( & url, "user" ) . await ;
277
+ let keyring = KeyringProvider :: dummy ( [ ( "other.com" , "user" , "password" ) ] ) ;
278
+ let credentials = keyring. fetch ( & url, Some ( "user" ) ) . await ;
224
279
assert_eq ! ( credentials, None ) ;
225
280
}
226
281
227
282
#[ tokio:: test]
228
283
async fn fetch_url_prefers_url_to_host ( ) {
229
284
let url = Url :: parse ( "https://example.com/" ) . unwrap ( ) ;
230
285
let keyring = KeyringProvider :: dummy ( [
231
- ( ( url. join ( "foo" ) . unwrap ( ) . as_str ( ) , "user" ) , "password" ) ,
232
- ( ( url. host_str ( ) . unwrap ( ) , "user" ) , "other-password" ) ,
286
+ ( url. join ( "foo" ) . unwrap ( ) . as_str ( ) , "user" , "password" ) ,
287
+ ( url. host_str ( ) . unwrap ( ) , "user" , "other-password" ) ,
233
288
] ) ;
234
289
assert_eq ! (
235
- keyring. fetch( & url. join( "foo" ) . unwrap( ) , "user" ) . await ,
290
+ keyring. fetch( & url. join( "foo" ) . unwrap( ) , Some ( "user" ) ) . await ,
236
291
Some ( Credentials :: new(
237
292
Some ( "user" . to_string( ) ) ,
238
293
Some ( "password" . to_string( ) )
239
294
) )
240
295
) ;
241
296
assert_eq ! (
242
- keyring. fetch( & url, "user" ) . await ,
297
+ keyring. fetch( & url, Some ( "user" ) ) . await ,
243
298
Some ( Credentials :: new(
244
299
Some ( "user" . to_string( ) ) ,
245
300
Some ( "other-password" . to_string( ) )
246
301
) )
247
302
) ;
248
303
assert_eq ! (
249
- keyring. fetch( & url. join( "bar" ) . unwrap( ) , "user" ) . await ,
304
+ keyring. fetch( & url. join( "bar" ) . unwrap( ) , Some ( "user" ) ) . await ,
250
305
Some ( Credentials :: new(
251
306
Some ( "user" . to_string( ) ) ,
252
307
Some ( "other-password" . to_string( ) )
@@ -257,8 +312,22 @@ mod tests {
257
312
#[ tokio:: test]
258
313
async fn fetch_url_username ( ) {
259
314
let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
260
- let keyring = KeyringProvider :: dummy ( [ ( ( url. host_str ( ) . unwrap ( ) , "user" ) , "password" ) ] ) ;
261
- let credentials = keyring. fetch ( & url, "user" ) . await ;
315
+ let keyring = KeyringProvider :: dummy ( [ ( url. host_str ( ) . unwrap ( ) , "user" , "password" ) ] ) ;
316
+ let credentials = keyring. fetch ( & url, Some ( "user" ) ) . await ;
317
+ assert_eq ! (
318
+ credentials,
319
+ Some ( Credentials :: new(
320
+ Some ( "user" . to_string( ) ) ,
321
+ Some ( "password" . to_string( ) )
322
+ ) )
323
+ ) ;
324
+ }
325
+
326
+ #[ tokio:: test]
327
+ async fn fetch_url_no_username ( ) {
328
+ let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
329
+ let keyring = KeyringProvider :: dummy ( [ ( url. host_str ( ) . unwrap ( ) , "user" , "password" ) ] ) ;
330
+ let credentials = keyring. fetch ( & url, None ) . await ;
262
331
assert_eq ! (
263
332
credentials,
264
333
Some ( Credentials :: new(
@@ -271,13 +340,13 @@ mod tests {
271
340
#[ tokio:: test]
272
341
async fn fetch_url_username_no_match ( ) {
273
342
let url = Url :: parse ( "https://example.com" ) . unwrap ( ) ;
274
- let keyring = KeyringProvider :: dummy ( [ ( ( url. host_str ( ) . unwrap ( ) , "foo" ) , "password" ) ] ) ;
275
- let credentials = keyring. fetch ( & url, "bar" ) . await ;
343
+ let keyring = KeyringProvider :: dummy ( [ ( url. host_str ( ) . unwrap ( ) , "foo" , "password" ) ] ) ;
344
+ let credentials = keyring. fetch ( & url, Some ( "bar" ) ) . await ;
276
345
assert_eq ! ( credentials, None ) ;
277
346
278
347
// Still fails if we have `foo` in the URL itself
279
348
let url = Url :: parse ( "https://foo@example.com" ) . unwrap ( ) ;
280
- let credentials = keyring. fetch ( & url, "bar" ) . await ;
349
+ let credentials = keyring. fetch ( & url, Some ( "bar" ) ) . await ;
281
350
assert_eq ! ( credentials, None ) ;
282
351
}
283
352
}
0 commit comments