Skip to main content

gix_credentials/helper/
cascade.rs

1use crate::{
2    Program, helper,
3    helper::Cascade,
4    protocol,
5    protocol::{Context, ContextOptions},
6};
7
8impl Default for Cascade {
9    fn default() -> Self {
10        Cascade {
11            programs: Vec::new(),
12            stderr: true,
13            use_http_path: false,
14            context_options: ContextOptions::default(),
15            query_user_only: false,
16        }
17    }
18}
19
20/// Initialization
21impl Cascade {
22    /// Return the programs to run for the current platform.
23    ///
24    /// These are typically used as basis for all credential cascade invocations, with configured programs following afterwards.
25    ///
26    /// # Note
27    ///
28    /// These defaults emulate what typical git installations may use these days, as in fact it's a configurable which comes
29    /// from installation-specific configuration files which we cannot know (or guess at best).
30    /// This seems like an acceptable trade-off as helpers are ignored if they fail or are not existing.
31    pub fn platform_builtin() -> Vec<Program> {
32        if cfg!(target_os = "macos") {
33            Some("osxkeychain")
34        } else if cfg!(target_os = "linux") {
35            Some("libsecret")
36        } else if cfg!(target_os = "windows") {
37            Some("manager-core")
38        } else {
39            None
40        }
41        .map(|name| vec![Program::from_custom_definition(name)])
42        .unwrap_or_default()
43    }
44}
45
46/// Builder
47impl Cascade {
48    /// Extend the list of programs to run `programs`.
49    pub fn extend(mut self, programs: impl IntoIterator<Item = Program>) -> Self {
50        self.programs.extend(programs);
51        self
52    }
53    /// If `toggle` is true, http(s) urls will use the path portions of the url to obtain a credential for.
54    ///
55    /// Otherwise, they will only take the user name into account.
56    pub fn use_http_path(mut self, toggle: bool) -> Self {
57        self.use_http_path = toggle;
58        self
59    }
60
61    /// If `toggle` is true, a bogus password will be provided to prevent any helper program from prompting for it, nor will
62    /// we prompt for the password. The resulting identity will have a bogus password and it's expected to not be used by the
63    /// consuming transport.
64    pub fn query_user_only(mut self, toggle: bool) -> Self {
65        self.query_user_only = toggle;
66        self
67    }
68}
69
70/// Finalize
71impl Cascade {
72    /// Invoke the cascade by `invoking` each program with `action`, and configuring potential prompts with `prompt` options.
73    /// The latter can also be used to disable the prompt entirely when setting the `mode` to [`Disable`][gix_prompt::Mode::Disable];=.
74    ///
75    /// When _getting_ credentials, all programs are asked until the credentials are complete, stopping the cascade.
76    /// When _storing_ or _erasing_ all programs are instructed in order.
77    #[expect(
78        clippy::result_large_err,
79        reason = "will be removed once `gix-error` is used consistently"
80    )]
81    pub fn invoke(&mut self, mut action: helper::Action, mut prompt: gix_prompt::Options) -> protocol::Result {
82        if let Some(ctx) = action.context_mut() {
83            ctx.options = self.context_options;
84        }
85        let mut url = action
86            .context_mut()
87            .map(|ctx| {
88                #[expect(
89                    clippy::manual_inspect,
90                    reason = "the suggested rewrite is a false positive for this mutation"
91                )] /* false positive */
92                ctx.destructure_url_in_place(self.use_http_path).map(|ctx| {
93                    if self.query_user_only && ctx.password.is_none() {
94                        ctx.password = Some("".into());
95                    }
96                    ctx
97                })
98            })
99            .transpose()?
100            .and_then(|ctx| ctx.url.take());
101
102        for program in &mut self.programs {
103            program.stderr = self.stderr;
104            match helper::invoke::raw(program, &action) {
105                Ok(None) => {}
106                Ok(Some(stdout)) => {
107                    let Context {
108                        options: _,
109                        protocol,
110                        host,
111                        path,
112                        username,
113                        password,
114                        oauth_refresh_token,
115                        password_expiry_utc,
116                        url: ctx_url,
117                        quit,
118                    } = Context::from_bytes(&stdout, self.context_options)?;
119                    if let Some(dst_ctx) = action.context_mut() {
120                        if let Some(src) = path {
121                            dst_ctx.path = Some(src);
122                        }
123                        if let Some(src) = password_expiry_utc {
124                            dst_ctx.password_expiry_utc = Some(src);
125                        }
126                        for (src, dst) in [
127                            (protocol, &mut dst_ctx.protocol),
128                            (host, &mut dst_ctx.host),
129                            (username, &mut dst_ctx.username),
130                            (password, &mut dst_ctx.password),
131                            (oauth_refresh_token, &mut dst_ctx.oauth_refresh_token),
132                        ] {
133                            if let Some(src) = src {
134                                *dst = Some(src);
135                            }
136                        }
137                        if let Some(src) = ctx_url {
138                            dst_ctx.url = Some(src);
139                            url = dst_ctx.destructure_url_in_place(self.use_http_path)?.url.take();
140                        }
141                        if dst_ctx
142                            .password_expiry_utc
143                            .is_some_and(|expiry_date| expiry_date < gix_date::Time::now_utc().seconds)
144                        {
145                            dst_ctx.password_expiry_utc = None;
146                            dst_ctx.clear_secrets();
147                        }
148                        if dst_ctx.username.is_some() && dst_ctx.password.is_some() {
149                            break;
150                        }
151                        if quit.unwrap_or_default() {
152                            dst_ctx.quit = quit;
153                            break;
154                        }
155                    }
156                }
157                Err(helper::Error::CredentialsHelperFailed { .. }) => continue, // ignore helpers that we can't call
158                Err(err) if action.context().is_some() => return Err(err.into()), // communication errors are fatal when getting credentials
159                Err(_) => {} // for other actions, ignore everything, try the operation
160            }
161        }
162
163        if prompt.mode != gix_prompt::Mode::Disable {
164            if let Some(ctx) = action.context_mut() {
165                ctx.url = url;
166                if ctx.username.is_none() {
167                    let message = ctx.to_prompt("Username");
168                    prompt.mode = gix_prompt::Mode::Visible;
169                    ctx.username = gix_prompt::ask(&message, &prompt)
170                        .map_err(|err| protocol::Error::Prompt {
171                            prompt: message,
172                            source: err,
173                        })?
174                        .into();
175                }
176                if ctx.password.is_none() {
177                    let message = ctx.to_prompt("Password");
178                    prompt.mode = gix_prompt::Mode::Hidden;
179                    ctx.password = gix_prompt::ask(&message, &prompt)
180                        .map_err(|err| protocol::Error::Prompt {
181                            prompt: message,
182                            source: err,
183                        })?
184                        .into();
185                }
186            }
187        }
188
189        protocol::helper_outcome_to_result(
190            action.context().map(|ctx| helper::Outcome {
191                username: ctx.username.clone(),
192                password: ctx.password.clone(),
193                oauth_refresh_token: ctx.oauth_refresh_token.clone(),
194                quit: ctx.quit.unwrap_or(false),
195                next: ctx.to_owned().into(),
196            }),
197            action,
198        )
199    }
200}