11//! Docker client module using bollard.
22//!
33//! This module provides a shared Docker client instance and helper functions
4- //! for interacting with Docker via the bollard API.
4+ //! for interacting with Docker via the bollard API, including asynchronous
5+ //! credential support from the Docker CLI configuration.
56
7+ use bollard:: auth:: DockerCredentials ;
68use bollard:: Docker ;
9+ use serde:: Deserialize ;
10+ use std:: collections:: HashMap ;
711use std:: sync:: OnceLock ;
12+ use tokio:: io:: AsyncWriteExt ;
13+ use tokio:: process:: Command ;
814
915static DOCKER_CLIENT : OnceLock < Docker > = OnceLock :: new ( ) ;
1016
@@ -18,3 +24,156 @@ pub fn get_docker() -> &'static Docker {
1824 Docker :: connect_with_local_defaults ( ) . expect ( "Failed to connect to Docker daemon" )
1925 } )
2026}
27+
28+ #[ derive( Deserialize , Debug ) ]
29+ struct DockerConfig {
30+ auths : Option < HashMap < String , DockerConfigAuth > > ,
31+ #[ serde( rename = "credsStore" ) ]
32+ creds_store : Option < String > ,
33+ #[ serde( rename = "credHelpers" ) ]
34+ cred_helpers : Option < HashMap < String , String > > ,
35+ }
36+
37+ #[ derive( Deserialize , Debug ) ]
38+ struct DockerConfigAuth {
39+ auth : Option < String > ,
40+ }
41+
42+ #[ derive( Deserialize , Debug ) ]
43+ #[ serde( rename_all = "PascalCase" ) ]
44+ struct CredentialHelperResponse {
45+ username : Option < String > ,
46+ secret : Option < String > ,
47+ }
48+
49+ /// Get Docker credentials for a given registry from the Docker config file (~/.docker/config.json).
50+ /// This supports static auth strings, the global 'credsStore', and per-registry 'credHelpers'.
51+ pub async fn get_credentials ( registry : & str ) -> Option < DockerCredentials > {
52+ let config_path = std:: env:: var ( "DOCKER_CONFIG" )
53+ . map ( |p| std:: path:: PathBuf :: from ( p) . join ( "config.json" ) )
54+ . or_else ( |_| {
55+ std:: env:: var ( "HOME" ) . map ( |h| std:: path:: PathBuf :: from ( h) . join ( ".docker/config.json" ) )
56+ } )
57+ . ok ( ) ?;
58+
59+ let content = tokio:: fs:: read_to_string ( config_path) . await . ok ( ) ?;
60+ let config: DockerConfig = serde_json:: from_str ( & content) . ok ( ) ?;
61+
62+ // 1. Try Credential Helpers (Specific helper for registry)
63+ if let Some ( helpers) = & config. cred_helpers {
64+ if let Some ( helper_suffix) = helpers. get ( registry) {
65+ if let Some ( creds) = call_credential_helper ( helper_suffix, registry) . await {
66+ return Some ( creds) ;
67+ }
68+ }
69+ }
70+
71+ // 2. Try Static Auths in config.json
72+ if let Some ( auths) = & config. auths {
73+ let keys_to_check = get_registry_keys ( registry) ;
74+ for key in keys_to_check {
75+ if let Some ( auth_entry) = auths. get ( & key) {
76+ if let Some ( auth) = & auth_entry. auth {
77+ return Some ( DockerCredentials {
78+ auth : Some ( auth. clone ( ) ) ,
79+ ..Default :: default ( )
80+ } ) ;
81+ }
82+ }
83+ }
84+ }
85+
86+ // 3. Try Global Credentials Store
87+ if let Some ( helper_suffix) = & config. creds_store {
88+ if let Some ( creds) = call_credential_helper ( helper_suffix, registry) . await {
89+ return Some ( creds) ;
90+ }
91+ }
92+
93+ None
94+ }
95+
96+ /// Calls a docker-credential-helper (like 'osxkeychain', 'secretservice', 'wincred')
97+ async fn call_credential_helper ( helper_suffix : & str , registry : & str ) -> Option < DockerCredentials > {
98+ let helper_cmd = format ! ( "docker-credential-{}" , helper_suffix) ;
99+ let mut child = Command :: new ( helper_cmd)
100+ . arg ( "get" )
101+ . stdin ( std:: process:: Stdio :: piped ( ) )
102+ . stdout ( std:: process:: Stdio :: piped ( ) )
103+ . spawn ( )
104+ . ok ( ) ?;
105+
106+ if let Some ( mut stdin) = child. stdin . take ( ) {
107+ let _ = stdin. write_all ( registry. as_bytes ( ) ) . await ;
108+ let _ = stdin. flush ( ) . await ;
109+ }
110+
111+ let output = child. wait_with_output ( ) . await . ok ( ) ?;
112+
113+ if output. status . success ( ) {
114+ let creds: CredentialHelperResponse = serde_json:: from_slice ( & output. stdout ) . ok ( ) ?;
115+ if let ( Some ( username) , Some ( password) ) = ( creds. username , creds. secret ) {
116+ return Some ( DockerCredentials {
117+ username : Some ( username) ,
118+ password : Some ( password) ,
119+ ..Default :: default ( )
120+ } ) ;
121+ }
122+ }
123+ None
124+ }
125+
126+ /// Generates a list of possible keys in config.json for a given registry
127+ fn get_registry_keys ( registry : & str ) -> Vec < String > {
128+ let mut keys = vec ! [ registry. to_string( ) ] ;
129+
130+ if !registry. starts_with ( "http" ) {
131+ keys. push ( format ! ( "https://{}" , registry) ) ;
132+ }
133+
134+ if registry == "docker.io"
135+ || registry == "registry-1.docker.io"
136+ || registry == "index.docker.io"
137+ {
138+ keys. push ( "https://index.docker.io/v1/" . to_string ( ) ) ;
139+ keys. push ( "index.docker.io/v1/" . to_string ( ) ) ;
140+ keys. push ( "https://registry-1.docker.io/v2/" . to_string ( ) ) ;
141+ }
142+
143+ keys
144+ }
145+
146+ /// Extract the registry part from an image name.
147+ pub fn extract_registry ( image : & str ) -> & str {
148+ if let Some ( slash_pos) = image. find ( '/' ) {
149+ let part = & image[ ..slash_pos] ;
150+ // If the first part contains a dot or colon, or is "localhost", it's a registry
151+ if part. contains ( '.' ) || part. contains ( ':' ) || part == "localhost" {
152+ return part;
153+ }
154+ }
155+ "docker.io"
156+ }
157+
158+ /// Parse an image reference into (image, tag) components
159+ pub fn parse_image_reference ( image : & str ) -> ( & str , & str ) {
160+ // Handle digest references (image@sha256:...)
161+ if let Some ( at_pos) = image. find ( '@' ) {
162+ return ( & image[ ..at_pos] , & image[ at_pos..] ) ;
163+ }
164+
165+ // Handle tag references (image:tag)
166+ // Need to be careful with registry URLs that contain port numbers
167+ // e.g., localhost:5000/myimage:tag
168+ if let Some ( colon_pos) = image. rfind ( ':' ) {
169+ // Check if the colon is part of a port number in the registry URL
170+ let after_colon = & image[ colon_pos + 1 ..] ;
171+ // If there's a slash after the colon, it's a port number, not a tag
172+ if !after_colon. contains ( '/' ) {
173+ return ( & image[ ..colon_pos] , after_colon) ;
174+ }
175+ }
176+
177+ // No tag specified, use "latest"
178+ ( image, "latest" )
179+ }
0 commit comments