Skip to main content

kube_client/config/
file_config.rs

1use std::{
2    collections::{BTreeMap, HashMap},
3    fs, io,
4    path::{Path, PathBuf},
5};
6
7use secrecy::{ExposeSecret, SecretString};
8use serde::{Deserialize, Deserializer, Serialize, Serializer};
9
10use super::{KubeconfigError, LoadDataError};
11
12/// [`CLUSTER_EXTENSION_KEY`] is reserved in the cluster extensions list for exec plugin config.
13const CLUSTER_EXTENSION_KEY: &str = "client.authentication.k8s.io/exec";
14
15/// [`Kubeconfig`] represents information on how to connect to a remote Kubernetes cluster
16///
17/// Stored in `~/.kube/config` by default, but can be distributed across multiple paths in passed through `KUBECONFIG`.
18/// An analogue of the [config type from client-go](https://github.com/kubernetes/client-go/blob/7697067af71046b18e03dbda04e01a5bb17f9809/tools/clientcmd/api/types.go).
19///
20/// This type (and its children) are exposed primarily for convenience.
21///
22/// [`Config`][crate::Config] is the __intended__ developer interface to help create a [`Client`][crate::Client],
23/// and this will handle the difference between in-cluster deployment and local development.
24#[derive(Clone, Debug, Serialize, Deserialize, Default)]
25#[cfg_attr(test, derive(PartialEq))]
26pub struct Kubeconfig {
27    /// General information to be use for cli interactions
28    #[serde(skip_serializing_if = "Option::is_none")]
29    pub preferences: Option<Preferences>,
30    /// Referenceable names to cluster configs
31    #[serde(default, deserialize_with = "deserialize_null_as_default")]
32    pub clusters: Vec<NamedCluster>,
33    /// Referenceable names to user configs
34    #[serde(rename = "users")]
35    #[serde(default, deserialize_with = "deserialize_null_as_default")]
36    pub auth_infos: Vec<NamedAuthInfo>,
37    /// Referenceable names to context configs
38    #[serde(default, deserialize_with = "deserialize_null_as_default")]
39    pub contexts: Vec<NamedContext>,
40    /// The name of the context that you would like to use by default
41    #[serde(rename = "current-context")]
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub current_context: Option<String>,
44    /// Additional information for extenders so that reads and writes don't clobber unknown fields.
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub extensions: Option<Vec<NamedExtension>>,
47
48    // legacy fields TODO: remove
49    /// Legacy field from TypeMeta
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub kind: Option<String>,
52    /// Legacy field from TypeMeta
53    #[serde(rename = "apiVersion")]
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub api_version: Option<String>,
56
57    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
58    ///
59    /// If you are relying on this for standard fields present in upstream client-go,
60    /// please consider submitting a PR to add them as typed fields.
61    #[serde(flatten)]
62    pub other: BTreeMap<String, serde_json::Value>,
63}
64
65/// Preferences stores extensions for cli.
66#[derive(Clone, Debug, Serialize, Deserialize, Default)]
67#[cfg_attr(test, derive(PartialEq, Eq))]
68pub struct Preferences {
69    /// Enable colors
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub colors: Option<bool>,
72    /// Extensions holds additional information. This is useful for extenders so that reads and writes don't clobber unknown fields.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub extensions: Option<Vec<NamedExtension>>,
75
76    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
77    ///
78    /// If you are relying on this for standard fields present in upstream client-go,
79    /// please consider submitting a PR to add them as typed fields.
80    #[serde(flatten)]
81    pub other: BTreeMap<String, serde_json::Value>,
82}
83
84/// NamedExtension associates name with extension.
85#[derive(Clone, Debug, Serialize, Deserialize)]
86#[cfg_attr(test, derive(PartialEq, Eq))]
87pub struct NamedExtension {
88    /// Name of extension
89    pub name: String,
90    /// Additional information for extenders so that reads and writes don't clobber unknown fields
91    pub extension: serde_json::Value,
92}
93
94/// NamedCluster associates name with cluster.
95#[derive(Clone, Debug, Serialize, Deserialize, Default)]
96#[cfg_attr(test, derive(PartialEq, Eq))]
97pub struct NamedCluster {
98    /// Name of cluster
99    pub name: String,
100    /// Information about how to communicate with a kubernetes cluster
101    #[serde(skip_serializing_if = "Option::is_none")]
102    pub cluster: Option<Cluster>,
103
104    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
105    ///
106    /// If you are relying on this for standard fields present in upstream client-go,
107    /// please consider submitting a PR to add them as typed fields.
108    #[serde(flatten)]
109    pub other: BTreeMap<String, serde_json::Value>,
110}
111
112/// Cluster stores information to connect Kubernetes cluster.
113#[derive(Clone, Debug, Serialize, Deserialize, Default)]
114#[cfg_attr(test, derive(PartialEq, Eq))]
115pub struct Cluster {
116    /// The address of the kubernetes cluster (https://hostname:port).
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub server: Option<String>,
119    /// Skips the validity check for the server's certificate. This will make your HTTPS connections insecure.
120    #[serde(rename = "insecure-skip-tls-verify")]
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub insecure_skip_tls_verify: Option<bool>,
123    /// The path to a cert file for the certificate authority.
124    #[serde(rename = "certificate-authority")]
125    #[serde(skip_serializing_if = "Option::is_none")]
126    pub certificate_authority: Option<String>,
127    /// PEM-encoded certificate authority certificates. Overrides `certificate_authority`
128    #[serde(rename = "certificate-authority-data")]
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub certificate_authority_data: Option<String>,
131    /// URL to the proxy to be used for all requests.
132    #[serde(rename = "proxy-url")]
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub proxy_url: Option<String>,
135    /// Compression is enabled by default with the `gzip` feature.
136    /// `disable_compression` allows client to opt-out of response compression for all requests to the server.
137    /// This is useful to speed up requests (specifically lists) when client-server network bandwidth is ample,
138    /// by saving time on compression (server-side) and decompression (client-side):
139    /// https://github.com/kubernetes/kubernetes/issues/112296
140    #[serde(rename = "disable-compression")]
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub disable_compression: Option<bool>,
143    /// Name used to check server certificate.
144    ///
145    /// If `tls_server_name` is `None`, the hostname used to contact the server is used.
146    #[serde(rename = "tls-server-name")]
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub tls_server_name: Option<String>,
149    /// Additional information for extenders so that reads and writes don't clobber unknown fields
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub extensions: Option<Vec<NamedExtension>>,
152
153    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
154    ///
155    /// If you are relying on this for standard fields present in upstream client-go,
156    /// please consider submitting a PR to add them as typed fields.
157    #[serde(flatten)]
158    pub other: BTreeMap<String, serde_json::Value>,
159}
160
161/// NamedAuthInfo associates name with authentication.
162#[derive(Clone, Debug, Serialize, Deserialize, Default)]
163#[cfg_attr(test, derive(PartialEq))]
164pub struct NamedAuthInfo {
165    /// Name of the user
166    pub name: String,
167    /// Information that describes identity of the user
168    #[serde(rename = "user")]
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub auth_info: Option<AuthInfo>,
171
172    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
173    ///
174    /// If you are relying on this for standard fields present in upstream client-go,
175    /// please consider submitting a PR to add them as typed fields.
176    #[serde(flatten)]
177    pub other: BTreeMap<String, serde_json::Value>,
178}
179
180fn serialize_secretstring<S>(pw: &Option<SecretString>, serializer: S) -> Result<S::Ok, S::Error>
181where
182    S: Serializer,
183{
184    match pw {
185        Some(secret) => serializer.serialize_str(secret.expose_secret()),
186        None => serializer.serialize_none(),
187    }
188}
189
190fn deserialize_secretstring<'de, D>(deserializer: D) -> Result<Option<SecretString>, D::Error>
191where
192    D: Deserializer<'de>,
193{
194    match Option::<String>::deserialize(deserializer) {
195        Ok(Some(secret)) => Ok(Some(SecretString::new(secret.into()))),
196        Ok(None) => Ok(None),
197        Err(e) => Err(e),
198    }
199}
200
201fn deserialize_null_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
202where
203    T: Default + Deserialize<'de>,
204    D: Deserializer<'de>,
205{
206    let opt = Option::deserialize(deserializer)?;
207    Ok(opt.unwrap_or_default())
208}
209
210/// AuthInfo stores information to tell cluster who you are.
211#[derive(Clone, Debug, Serialize, Deserialize, Default)]
212pub struct AuthInfo {
213    /// The username for basic authentication to the kubernetes cluster.
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub username: Option<String>,
216    /// The password for basic authentication to the kubernetes cluster.
217    #[serde(skip_serializing_if = "Option::is_none", default)]
218    #[serde(
219        serialize_with = "serialize_secretstring",
220        deserialize_with = "deserialize_secretstring"
221    )]
222    pub password: Option<SecretString>,
223
224    /// The bearer token for authentication to the kubernetes cluster.
225    #[serde(skip_serializing_if = "Option::is_none", default)]
226    #[serde(
227        serialize_with = "serialize_secretstring",
228        deserialize_with = "deserialize_secretstring"
229    )]
230    pub token: Option<SecretString>,
231    /// Pointer to a file that contains a bearer token (as described above). If both `token` and token_file` are present, `token` takes precedence.
232    #[serde(rename = "tokenFile")]
233    #[serde(skip_serializing_if = "Option::is_none")]
234    pub token_file: Option<String>,
235
236    /// Path to a client cert file for TLS.
237    #[serde(rename = "client-certificate")]
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub client_certificate: Option<String>,
240    /// PEM-encoded data from a client cert file for TLS. Overrides `client_certificate`
241    /// this key should be base64 encoded instead of the decode string data
242    #[serde(rename = "client-certificate-data")]
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub client_certificate_data: Option<String>,
245
246    /// Path to a client key file for TLS.
247    #[serde(rename = "client-key")]
248    #[serde(skip_serializing_if = "Option::is_none")]
249    pub client_key: Option<String>,
250    /// PEM-encoded data from a client key file for TLS. Overrides `client_key`
251    /// this key should be base64 encoded instead of the decode string data
252    #[serde(rename = "client-key-data")]
253    #[serde(skip_serializing_if = "Option::is_none", default)]
254    #[serde(
255        serialize_with = "serialize_secretstring",
256        deserialize_with = "deserialize_secretstring"
257    )]
258    pub client_key_data: Option<SecretString>,
259
260    /// The username to act-as.
261    #[serde(rename = "as")]
262    #[serde(skip_serializing_if = "Option::is_none")]
263    pub impersonate: Option<String>,
264    /// The uid to impersonate.
265    #[serde(rename = "as-uid")]
266    #[serde(skip_serializing_if = "Option::is_none")]
267    pub impersonate_uid: Option<String>,
268    /// The groups to imperonate.
269    #[serde(rename = "as-groups")]
270    #[serde(skip_serializing_if = "Option::is_none")]
271    pub impersonate_groups: Option<Vec<String>>,
272    /// Additional information for impersonated user.
273    #[serde(rename = "as-user-extra")]
274    #[serde(skip_serializing_if = "Option::is_none")]
275    pub impersonate_user_extra: Option<HashMap<String, Vec<String>>>,
276
277    /// Additional information for extenders so that reads and writes don't clobber unknown fields.
278    #[serde(skip_serializing_if = "Option::is_none")]
279    pub extensions: Option<Vec<NamedExtension>>,
280
281    /// Specifies a custom authentication plugin for the kubernetes cluster.
282    #[serde(rename = "auth-provider")]
283    #[serde(skip_serializing_if = "Option::is_none")]
284    pub auth_provider: Option<AuthProviderConfig>,
285
286    /// Specifies a custom exec-based authentication plugin for the kubernetes cluster.
287    #[serde(skip_serializing_if = "Option::is_none")]
288    pub exec: Option<ExecConfig>,
289
290    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
291    ///
292    /// If you are relying on this for standard fields present in upstream client-go,
293    /// please consider submitting a PR to add them as typed fields.
294    #[serde(flatten)]
295    pub other: BTreeMap<String, serde_json::Value>,
296}
297
298#[cfg(test)]
299impl PartialEq for AuthInfo {
300    fn eq(&self, other: &Self) -> bool {
301        serde_json::to_value(self).unwrap() == serde_json::to_value(other).unwrap()
302    }
303}
304
305/// AuthProviderConfig stores auth for specified cloud provider.
306#[derive(Clone, Debug, Serialize, Deserialize, Default)]
307#[cfg_attr(test, derive(PartialEq, Eq))]
308pub struct AuthProviderConfig {
309    /// Name of the auth provider
310    pub name: String,
311    /// Auth provider configuration
312    #[serde(default)]
313    pub config: HashMap<String, String>,
314
315    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
316    ///
317    /// If you are relying on this for standard fields present in upstream client-go,
318    /// please consider submitting a PR to add them as typed fields.
319    #[serde(flatten)]
320    pub other: BTreeMap<String, serde_json::Value>,
321}
322
323/// ExecConfig stores credential-plugin configuration.
324#[derive(Clone, Debug, Serialize, Deserialize, Default)]
325#[cfg_attr(test, derive(PartialEq, Eq))]
326pub struct ExecConfig {
327    /// Preferred input version of the ExecInfo.
328    ///
329    /// The returned ExecCredentials MUST use the same encoding version as the input.
330    #[serde(rename = "apiVersion")]
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub api_version: Option<String>,
333    /// Command to execute.
334    #[serde(skip_serializing_if = "Option::is_none")]
335    pub command: Option<String>,
336    /// Arguments to pass to the command when executing it.
337    #[serde(skip_serializing_if = "Option::is_none")]
338    pub args: Option<Vec<String>>,
339    /// Env defines additional environment variables to expose to the process.
340    ///
341    /// TODO: These are unioned with the host's environment, as well as variables client-go uses to pass argument to the plugin.
342    #[serde(skip_serializing_if = "Option::is_none")]
343    pub env: Option<Vec<HashMap<String, String>>>,
344    /// Specifies which environment variables the host should avoid passing to the auth plugin.
345    ///
346    /// This does currently not exist upstream and cannot be specified on disk.
347    /// It has been suggested in client-go via <https://github.com/kubernetes/client-go/issues/1177>
348    #[serde(skip)]
349    pub drop_env: Option<Vec<String>>,
350
351    /// This text is shown to the user when the executable doesn't seem to be present.
352    /// For example, `brew install foo-cli` might be a good InstallHint for foo-cli on Mac OS systems.
353    #[serde(rename = "installHint")]
354    #[serde(skip_serializing_if = "Option::is_none")]
355    pub install_hint: Option<String>,
356
357    /// Interactive mode of the auth plugins
358    #[serde(rename = "interactiveMode")]
359    #[serde(skip_serializing_if = "Option::is_none")]
360    pub interactive_mode: Option<ExecInteractiveMode>,
361
362    /// ProvideClusterInfo determines whether or not to provide cluster information,
363    /// which could potentially contain very large CA data, to this exec plugin as a
364    /// part of the KUBERNETES_EXEC_INFO environment variable. By default, it is set
365    /// to false. Package k8s.io/client-go/tools/auth/exec provides helper methods for
366    /// reading this environment variable.
367    #[serde(default, rename = "provideClusterInfo")]
368    pub provide_cluster_info: bool,
369
370    /// Cluster information to pass to the plugin.
371    /// Should be used only when `provide_cluster_info` is True.
372    #[serde(skip)]
373    pub cluster: Option<ExecAuthCluster>,
374
375    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
376    ///
377    /// If you are relying on this for standard fields present in upstream client-go,
378    /// please consider submitting a PR to add them as typed fields.
379    #[serde(flatten)]
380    pub other: BTreeMap<String, serde_json::Value>,
381}
382
383/// ExecInteractiveMode define the interactity of the child process
384#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
385#[cfg_attr(test, derive(Eq))]
386pub enum ExecInteractiveMode {
387    /// Never get interactive
388    Never,
389    /// If available et interactive
390    IfAvailable,
391    /// Alwayes get interactive
392    Always,
393}
394
395/// NamedContext associates name with context.
396#[derive(Clone, Debug, Serialize, Deserialize, Default)]
397#[cfg_attr(test, derive(PartialEq, Eq))]
398pub struct NamedContext {
399    /// Name of the context
400    pub name: String,
401    /// Associations for the context
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub context: Option<Context>,
404
405    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
406    ///
407    /// If you are relying on this for standard fields present in upstream client-go,
408    /// please consider submitting a PR to add them as typed fields.
409    #[serde(flatten)]
410    pub other: BTreeMap<String, serde_json::Value>,
411}
412
413/// Context stores tuple of cluster and user information.
414#[derive(Clone, Debug, Serialize, Deserialize, Default)]
415#[cfg_attr(test, derive(PartialEq, Eq))]
416pub struct Context {
417    /// Name of the cluster for this context
418    pub cluster: String,
419    /// Name of the `AuthInfo` for this context
420    pub user: Option<String>,
421    /// The default namespace to use on unspecified requests
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub namespace: Option<String>,
424    /// Additional information for extenders so that reads and writes don't clobber unknown fields
425    #[serde(skip_serializing_if = "Option::is_none")]
426    pub extensions: Option<Vec<NamedExtension>>,
427
428    /// Additional fields not explicitly modeled, preserved for round-trip serialization.
429    ///
430    /// If you are relying on this for standard fields present in upstream client-go,
431    /// please consider submitting a PR to add them as typed fields.
432    #[serde(flatten)]
433    pub other: BTreeMap<String, serde_json::Value>,
434}
435
436const KUBECONFIG: &str = "KUBECONFIG";
437
438/// Some helpers on the raw Config object are exposed for people needing to parse it
439impl Kubeconfig {
440    /// Read a Config from an arbitrary location
441    pub fn read_from<P: AsRef<Path>>(path: P) -> Result<Kubeconfig, KubeconfigError> {
442        let data =
443            read_path(&path).map_err(|source| KubeconfigError::ReadConfig(source, path.as_ref().into()))?;
444
445        // Remap all files we read to absolute paths.
446        let mut merged_docs = None;
447        for mut config in kubeconfig_from_yaml(&data)? {
448            if let Some(dir) = path.as_ref().parent() {
449                for named in config.clusters.iter_mut() {
450                    if let Some(cluster) = &mut named.cluster
451                        && let Some(path) = &cluster.certificate_authority
452                        && let Some(abs_path) = to_absolute(dir, path)
453                    {
454                        cluster.certificate_authority = Some(abs_path);
455                    }
456                }
457                for named in config.auth_infos.iter_mut() {
458                    if let Some(auth_info) = &mut named.auth_info {
459                        if let Some(path) = &auth_info.client_certificate
460                            && let Some(abs_path) = to_absolute(dir, path)
461                        {
462                            auth_info.client_certificate = Some(abs_path);
463                        }
464                        if let Some(path) = &auth_info.client_key
465                            && let Some(abs_path) = to_absolute(dir, path)
466                        {
467                            auth_info.client_key = Some(abs_path);
468                        }
469                        if let Some(path) = &auth_info.token_file
470                            && let Some(abs_path) = to_absolute(dir, path)
471                        {
472                            auth_info.token_file = Some(abs_path);
473                        }
474                        // Exec plugin commands can also be relative paths. Match
475                        // client-go (`GetAuthInfoFileReferences`) and only resolve
476                        // them when they contain a path separator, so bare `PATH`
477                        // lookups like `aws` are left untouched.
478                        if let Some(exec) = &mut auth_info.exec
479                            && let Some(command) = &exec.command
480                            && command.contains(std::path::MAIN_SEPARATOR)
481                            && let Some(abs_path) = to_absolute(dir, command)
482                        {
483                            exec.command = Some(abs_path);
484                        }
485                    }
486                }
487            }
488            if let Some(c) = merged_docs {
489                merged_docs = Some(Kubeconfig::merge(c, config)?);
490            } else {
491                merged_docs = Some(config);
492            }
493        }
494        // Empty file defaults to an empty Kubeconfig
495        Ok(merged_docs.unwrap_or_default())
496    }
497
498    /// Read a Config from an arbitrary YAML string
499    ///
500    /// This is preferable to using serde_saphyr::from_str() because it will correctly
501    /// parse multi-document YAML text and merge them into a single `Kubeconfig`
502    pub fn from_yaml(text: &str) -> Result<Kubeconfig, KubeconfigError> {
503        kubeconfig_from_yaml(text)?
504            .into_iter()
505            .try_fold(Kubeconfig::default(), Kubeconfig::merge)
506    }
507
508    /// Read a Config from `KUBECONFIG` or the the default location.
509    pub fn read() -> Result<Kubeconfig, KubeconfigError> {
510        match Self::from_env()? {
511            Some(config) => Ok(config),
512            None => Self::read_from(default_kube_path().ok_or(KubeconfigError::FindPath)?),
513        }
514    }
515
516    /// Create `Kubeconfig` from `KUBECONFIG` environment variable.
517    /// Supports list of files to be merged.
518    ///
519    /// # Panics
520    ///
521    /// Panics if `KUBECONFIG` value contains the NUL character.
522    pub fn from_env() -> Result<Option<Self>, KubeconfigError> {
523        match std::env::var_os(KUBECONFIG) {
524            Some(value) => {
525                let paths = std::env::split_paths(&value)
526                    .filter(|p| !p.as_os_str().is_empty())
527                    .collect::<Vec<_>>();
528                if paths.is_empty() {
529                    return Ok(None);
530                }
531
532                let merged = paths.iter().try_fold(Kubeconfig::default(), |m, p| {
533                    Kubeconfig::read_from(p).and_then(|c| m.merge(c))
534                })?;
535                Ok(Some(merged))
536            }
537
538            None => Ok(None),
539        }
540    }
541
542    /// Merge kubeconfig file according to the rules described in
543    /// <https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#merging-kubeconfig-files>
544    ///
545    /// > Merge the files listed in the `KUBECONFIG` environment variable according to these rules:
546    /// >
547    /// > - Ignore empty filenames.
548    /// > - Produce errors for files with content that cannot be deserialized.
549    /// > - The first file to set a particular value or map key wins.
550    /// > - Never change the value or map key.
551    /// >   Example: Preserve the context of the first file to set `current-context`.
552    /// >   Example: If two files specify a `red-user`, use only values from the first file's `red-user`.
553    /// >            Even if the second file has non-conflicting entries under `red-user`, discard them.
554    pub fn merge(mut self, next: Kubeconfig) -> Result<Self, KubeconfigError> {
555        if self.kind.is_some() && next.kind.is_some() && self.kind != next.kind {
556            return Err(KubeconfigError::KindMismatch);
557        }
558        if self.api_version.is_some() && next.api_version.is_some() && self.api_version != next.api_version {
559            return Err(KubeconfigError::ApiVersionMismatch);
560        }
561
562        self.kind = self.kind.or(next.kind);
563        self.api_version = self.api_version.or(next.api_version);
564        self.preferences = self.preferences.or(next.preferences);
565        append_new_named(&mut self.clusters, next.clusters, |x| &x.name);
566        append_new_named(&mut self.auth_infos, next.auth_infos, |x| &x.name);
567        append_new_named(&mut self.contexts, next.contexts, |x| &x.name);
568        self.current_context = self.current_context.or(next.current_context);
569        self.extensions = self.extensions.or(next.extensions);
570        // Merge extra fields: first-wins per key
571        for (key, value) in next.other {
572            self.other.entry(key).or_insert(value);
573        }
574        Ok(self)
575    }
576}
577
578fn kubeconfig_from_yaml(text: &str) -> Result<Vec<Kubeconfig>, KubeconfigError> {
579    serde_saphyr::from_multiple(text).map_err(|e| KubeconfigError::Parse(Box::new(e)))
580}
581
582fn append_new_named<T, F>(base: &mut Vec<T>, next: Vec<T>, f: F)
583where
584    F: Fn(&T) -> &String,
585{
586    use std::collections::HashSet;
587    base.extend({
588        let existing = base.iter().map(&f).collect::<HashSet<_>>();
589        next.into_iter()
590            .filter(|x| !existing.contains(f(x)))
591            .collect::<Vec<_>>()
592    });
593}
594
595fn read_path<P: AsRef<Path>>(path: P) -> io::Result<String> {
596    let bytes = fs::read(&path)?;
597    match bytes.as_slice() {
598        [0xFF, 0xFE, ..] => {
599            let utf16_data: Vec<u16> = bytes[2..]
600                .chunks(2)
601                .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
602                .collect();
603            String::from_utf16(&utf16_data)
604                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-16 LE"))
605        }
606        [0xFE, 0xFF, ..] => {
607            let utf16_data: Vec<u16> = bytes[2..]
608                .chunks(2)
609                .map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
610                .collect();
611            String::from_utf16(&utf16_data)
612                .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-16 BE"))
613        }
614        [0xEF, 0xBB, 0xBF, ..] => String::from_utf8(bytes[3..].to_vec())
615            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8 BOM")),
616        _ => {
617            String::from_utf8(bytes).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "Invalid UTF-8"))
618        }
619    }
620}
621
622fn to_absolute(dir: &Path, file: &str) -> Option<String> {
623    let path = Path::new(&file);
624    if path.is_relative() {
625        dir.join(path).to_str().map(str::to_owned)
626    } else {
627        None
628    }
629}
630
631impl Cluster {
632    pub(crate) fn load_certificate_authority(&self) -> Result<Option<Vec<u8>>, KubeconfigError> {
633        if self.certificate_authority.is_none() && self.certificate_authority_data.is_none() {
634            return Ok(None);
635        }
636
637        let ca = load_from_base64_or_file(
638            &self.certificate_authority_data.as_deref(),
639            &self.certificate_authority,
640        )
641        .map_err(KubeconfigError::LoadCertificateAuthority)?;
642        Ok(ca)
643    }
644}
645
646impl AuthInfo {
647    pub(crate) fn identity_pem(&self) -> Result<Option<Vec<u8>>, KubeconfigError> {
648        let client_cert = self.load_client_certificate()?;
649        let client_key = self.load_client_key()?;
650
651        match (client_cert, client_key) {
652            (None, None) => Ok(None),
653
654            (Some(_), None) => Err(KubeconfigError::LoadClientKey(
655                LoadDataError::NoBase64DataOrFile,
656            )),
657
658            (None, Some(_)) => Err(KubeconfigError::LoadClientCertificate(
659                LoadDataError::NoBase64DataOrFile,
660            )),
661
662            (Some(cert), Some(mut key)) => {
663                key.extend_from_slice(&cert);
664                Ok(Some(key))
665            },
666        }
667    }
668
669    pub(crate) fn load_client_certificate(&self) -> Result<Option<Vec<u8>>, KubeconfigError> {
670        // TODO Shouldn't error when `self.client_certificate_data.is_none() && self.client_certificate.is_none()`
671
672        load_from_base64_or_file(&self.client_certificate_data.as_deref(), &self.client_certificate)
673            .map_err(KubeconfigError::LoadClientCertificate)
674    }
675
676    pub(crate) fn load_client_key(&self) -> Result<Option<Vec<u8>>, KubeconfigError> {
677        // TODO Shouldn't error when `self.client_key_data.is_none() && self.client_key.is_none()`
678
679        load_from_base64_or_file(
680            &self.client_key_data.as_ref().map(|secret| secret.expose_secret()),
681            &self.client_key,
682        )
683        .map_err(KubeconfigError::LoadClientKey)
684    }
685}
686
687/// Connection information for auth plugins that have `provideClusterInfo` enabled.
688///
689/// This is a copy of [`kube::config::Cluster`] with certificate_authority passed as bytes without the path.
690/// Taken from [clientauthentication/types.go#Cluster](https://github.com/kubernetes/client-go/blob/477cb782cf024bc70b7239f0dca91e5774811950/pkg/apis/clientauthentication/types.go#L73-L129)
691#[derive(Clone, Debug, Serialize, Deserialize, Default)]
692#[serde(rename_all = "kebab-case")]
693#[cfg_attr(test, derive(PartialEq, Eq))]
694pub struct ExecAuthCluster {
695    /// The address of the kubernetes cluster (https://hostname:port).
696    #[serde(skip_serializing_if = "Option::is_none")]
697    pub server: Option<String>,
698    /// Skips the validity check for the server's certificate. This will make your HTTPS connections insecure.
699    #[serde(skip_serializing_if = "Option::is_none")]
700    pub insecure_skip_tls_verify: Option<bool>,
701    /// PEM-encoded certificate authority certificates. Overrides `certificate_authority`
702    #[serde(default, skip_serializing_if = "Option::is_none")]
703    #[serde(with = "base64serde")]
704    pub certificate_authority_data: Option<Vec<u8>>,
705    /// URL to the proxy to be used for all requests.
706    #[serde(skip_serializing_if = "Option::is_none")]
707    pub proxy_url: Option<String>,
708    /// Name used to check server certificate.
709    ///
710    /// If `tls_server_name` is `None`, the hostname used to contact the server is used.
711    #[serde(skip_serializing_if = "Option::is_none")]
712    pub tls_server_name: Option<String>,
713    /// This can be anything
714    #[serde(skip_serializing_if = "Option::is_none")]
715    pub config: Option<serde_json::Value>,
716}
717
718impl TryFrom<&Cluster> for ExecAuthCluster {
719    type Error = KubeconfigError;
720
721    fn try_from(cluster: &crate::config::Cluster) -> Result<Self, KubeconfigError> {
722        let certificate_authority_data = cluster.load_certificate_authority()?;
723        Ok(Self {
724            server: cluster.server.clone(),
725            insecure_skip_tls_verify: cluster.insecure_skip_tls_verify,
726            certificate_authority_data,
727            proxy_url: cluster.proxy_url.clone(),
728            tls_server_name: cluster.tls_server_name.clone(),
729            config: cluster.extensions.as_ref().and_then(|extensions| {
730                extensions
731                    .iter()
732                    .find(|extension| extension.name == CLUSTER_EXTENSION_KEY)
733                    .map(|extension| extension.extension.clone())
734            }),
735        })
736    }
737}
738
739fn load_from_base64_or_file<P: AsRef<Path>>(
740    value: &Option<&str>,
741    file: &Option<P>,
742) -> Result<Option<Vec<u8>>, LoadDataError> {
743    let data = value
744        .map(load_from_base64)
745        .or_else(|| file.as_ref().map(load_from_file));
746    match data {
747        Some(data) => Ok(Some(ensure_trailing_newline(data?))),
748        None => Ok(None),
749    }
750}
751
752fn load_from_base64(value: &str) -> Result<Vec<u8>, LoadDataError> {
753    use base64::Engine;
754    base64::engine::general_purpose::STANDARD
755        .decode(value)
756        .map_err(LoadDataError::DecodeBase64)
757}
758
759fn load_from_file<P: AsRef<Path>>(file: &P) -> Result<Vec<u8>, LoadDataError> {
760    fs::read(file).map_err(|source| LoadDataError::ReadFile(source, file.as_ref().into()))
761}
762
763// Ensure there is a trailing newline in the blob
764// Don't bother if the blob is empty
765fn ensure_trailing_newline(mut data: Vec<u8>) -> Vec<u8> {
766    if data.last().map(|end| *end != b'\n').unwrap_or(false) {
767        data.push(b'\n');
768    }
769    data
770}
771
772/// Returns kubeconfig path from `$HOME/.kube/config`.
773fn default_kube_path() -> Option<PathBuf> {
774    // Before Rust 1.85.0, `home_dir` would return wrong results on Windows, usage of the crate
775    // `home` was encouraged (and is what kube-rs did).
776    // Rust 1.85.0 fixed the problem (https://doc.rust-lang.org/1.85.0/std/env/fn.home_dir.html),
777    // Rust 1.87.0 removed the function deprecation.
778    // As the MSRV was bumped to 1.85.0 we are safe to use the fixed std function.
779    std::env::home_dir().map(|h| h.join(".kube").join("config"))
780}
781
782mod base64serde {
783    use base64::Engine;
784    use serde::{Deserialize, Deserializer, Serialize, Serializer};
785
786    pub fn serialize<S: Serializer>(v: &Option<Vec<u8>>, s: S) -> Result<S::Ok, S::Error> {
787        match v {
788            Some(v) => {
789                let encoded = base64::engine::general_purpose::STANDARD.encode(v);
790                String::serialize(&encoded, s)
791            }
792            None => <Option<String>>::serialize(&None, s),
793        }
794    }
795
796    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<Vec<u8>>, D::Error> {
797        let data = <Option<String>>::deserialize(d)?;
798        match data {
799            Some(data) => Ok(Some(
800                base64::engine::general_purpose::STANDARD
801                    .decode(data.as_bytes())
802                    .map_err(serde::de::Error::custom)?,
803            )),
804            None => Ok(None),
805        }
806    }
807}
808
809#[cfg(test)]
810mod tests {
811    use crate::config::file_loader::ConfigLoader;
812
813    use super::*;
814    use serde_json::{Value, json};
815
816    #[test]
817    fn kubeconfig_merge() {
818        let kubeconfig1 = Kubeconfig {
819            current_context: Some("default".into()),
820            auth_infos: vec![NamedAuthInfo {
821                name: "red-user".into(),
822                auth_info: Some(AuthInfo {
823                    token: Some(SecretString::new("first-token".into())),
824                    ..Default::default()
825                }),
826                ..Default::default()
827            }],
828            ..Default::default()
829        };
830        let kubeconfig2 = Kubeconfig {
831            current_context: Some("dev".into()),
832            auth_infos: vec![
833                NamedAuthInfo {
834                    name: "red-user".into(),
835                    auth_info: Some(AuthInfo {
836                        token: Some(SecretString::new("second-token".into())),
837                        username: Some("red-user".into()),
838                        ..Default::default()
839                    }),
840                    ..Default::default()
841                },
842                NamedAuthInfo {
843                    name: "green-user".into(),
844                    auth_info: Some(AuthInfo {
845                        token: Some(SecretString::new("new-token".into())),
846                        ..Default::default()
847                    }),
848                    ..Default::default()
849                },
850            ],
851            ..Default::default()
852        };
853
854        let merged = kubeconfig1.merge(kubeconfig2).unwrap();
855        // Preserves first `current_context`
856        assert_eq!(merged.current_context, Some("default".into()));
857        // Auth info with the same name does not overwrite
858        assert_eq!(merged.auth_infos[0].name, "red-user");
859        assert_eq!(
860            merged.auth_infos[0]
861                .auth_info
862                .as_ref()
863                .unwrap()
864                .token
865                .as_ref()
866                .map(|t| t.expose_secret()),
867            Some("first-token")
868        );
869        // Even if it's not conflicting
870        assert_eq!(merged.auth_infos[0].auth_info.as_ref().unwrap().username, None);
871        // New named auth info is appended
872        assert_eq!(merged.auth_infos[1].name, "green-user");
873    }
874
875    #[test]
876    fn kubeconfig_deserialize() {
877        let config_yaml = "apiVersion: v1
878clusters:
879- cluster:
880    certificate-authority-data: LS0t<SNIP>LS0tLQo=
881    server: https://ABCDEF0123456789.gr7.us-west-2.eks.amazonaws.com
882  name: eks
883- cluster:
884    certificate-authority: /home/kevin/.minikube/ca.crt
885    extensions:
886    - extension:
887        last-update: Thu, 18 Feb 2021 16:59:26 PST
888        provider: minikube.sigs.k8s.io
889        version: v1.17.1
890      name: cluster_info
891    server: https://192.168.49.2:8443
892  name: minikube
893contexts:
894- context:
895    cluster: minikube
896    extensions:
897    - extension:
898        last-update: Thu, 18 Feb 2021 16:59:26 PST
899        provider: minikube.sigs.k8s.io
900        version: v1.17.1
901      name: context_info
902    namespace: default
903    user: minikube
904  name: minikube
905- context:
906    cluster: arn:aws:eks:us-west-2:012345678912:cluster/eks
907    user: arn:aws:eks:us-west-2:012345678912:cluster/eks
908  name: eks
909current-context: minikube
910kind: Config
911preferences: {}
912users:
913- name: arn:aws:eks:us-west-2:012345678912:cluster/eks
914  user:
915    exec:
916      apiVersion: client.authentication.k8s.io/v1alpha1
917      args:
918      - --region
919      - us-west-2
920      - eks
921      - get-token
922      - --cluster-name
923      - eks
924      command: aws
925      env: null
926      installHint: Please install aws cli
927      provideClusterInfo: false
928    as: admin
929    as-uid: '12345'
930    as-groups:
931    - group1
932    - group2
933    as-user-extra:
934      scopes:
935      - read
936      - write
937    extensions:
938    - name: authinfo_ext
939      extension:
940        key: value
941- name: minikube
942  user:
943    client-certificate: /home/kevin/.minikube/profiles/minikube/client.crt
944    client-key: /home/kevin/.minikube/profiles/minikube/client.key";
945
946        let config = Kubeconfig::from_yaml(config_yaml).unwrap();
947
948        assert_eq!(config.clusters[0].name, "eks");
949        assert_eq!(config.clusters[1].name, "minikube");
950
951        let cluster1 = config.clusters[1].cluster.as_ref().unwrap();
952        assert_eq!(
953            cluster1.extensions.as_ref().unwrap()[0].extension.get("provider"),
954            Some(&Value::String("minikube.sigs.k8s.io".to_owned()))
955        );
956
957        // Verify new AuthInfo fields (impersonate_uid, impersonate_user_extra, extensions)
958        let auth_info = config.auth_infos[0].auth_info.as_ref().unwrap();
959        assert_eq!(auth_info.impersonate.as_deref(), Some("admin"));
960        assert_eq!(auth_info.impersonate_uid.as_deref(), Some("12345"));
961        assert_eq!(
962            auth_info.impersonate_groups.as_deref(),
963            Some(["group1".to_string(), "group2".to_string()].as_slice())
964        );
965        let extra = auth_info.impersonate_user_extra.as_ref().unwrap();
966        assert_eq!(
967            extra.get("scopes").unwrap(),
968            &vec!["read".to_string(), "write".to_string()]
969        );
970        let auth_ext = auth_info.extensions.as_ref().unwrap();
971        assert_eq!(auth_ext[0].name, "authinfo_ext");
972
973        // Verify ExecConfig.install_hint
974        let exec = auth_info.exec.as_ref().unwrap();
975        assert_eq!(exec.install_hint.as_deref(), Some("Please install aws cli"));
976    }
977
978    #[test]
979    fn kubeconfig_multi_document_merge() -> Result<(), KubeconfigError> {
980        let config_yaml = r#"---
981apiVersion: v1
982clusters:
983- cluster:
984    certificate-authority-data: aGVsbG8K
985    server: https://0.0.0.0:6443
986  name: k3d-promstack
987contexts:
988- context:
989    cluster: k3d-promstack
990    user: admin@k3d-promstack
991  name: k3d-promstack
992current-context: k3d-promstack
993kind: Config
994preferences: {}
995users:
996- name: admin@k3d-promstack
997  user:
998    client-certificate-data: aGVsbG8K
999    client-key-data: aGVsbG8K
1000---
1001apiVersion: v1
1002clusters:
1003- cluster:
1004    certificate-authority-data: aGVsbG8K
1005    server: https://0.0.0.0:6443
1006  name: k3d-k3s-default
1007contexts:
1008- context:
1009    cluster: k3d-k3s-default
1010    user: admin@k3d-k3s-default
1011  name: k3d-k3s-default
1012current-context: k3d-k3s-default
1013kind: Config
1014preferences: {}
1015users:
1016- name: admin@k3d-k3s-default
1017  user:
1018    client-certificate-data: aGVsbG8K
1019    client-key-data: aGVsbG8K
1020"#;
1021        let cfg = Kubeconfig::from_yaml(config_yaml)?;
1022
1023        // Ensure we have data from both documents:
1024        assert_eq!(cfg.clusters[0].name, "k3d-promstack");
1025        assert_eq!(cfg.clusters[1].name, "k3d-k3s-default");
1026
1027        Ok(())
1028    }
1029
1030    #[test]
1031    fn kubeconfig_split_sections_merge() -> Result<(), KubeconfigError> {
1032        let config1 = r#"
1033apiVersion: v1
1034clusters:
1035- cluster:
1036    certificate-authority-data: aGVsbG8K
1037    server: https://0.0.0.0:6443
1038  name: k3d-promstack
1039contexts:
1040- context:
1041    cluster: k3d-promstack
1042    user: admin@k3d-promstack
1043  name: k3d-promstack
1044current-context: k3d-promstack
1045kind: Config
1046preferences: {}
1047"#;
1048
1049        let config2 = r#"
1050users:
1051- name: admin@k3d-k3s-default
1052  user:
1053    client-certificate-data: aGVsbG8K
1054    client-key-data: aGVsbG8K
1055"#;
1056
1057        let kubeconfig1 = Kubeconfig::from_yaml(config1)?;
1058        let kubeconfig2 = Kubeconfig::from_yaml(config2)?;
1059        let merged = kubeconfig1.merge(kubeconfig2).unwrap();
1060
1061        // Ensure we have data from both files:
1062        assert_eq!(merged.clusters[0].name, "k3d-promstack");
1063        assert_eq!(merged.contexts[0].name, "k3d-promstack");
1064        assert_eq!(merged.auth_infos[0].name, "admin@k3d-k3s-default");
1065
1066        Ok(())
1067    }
1068
1069    #[test]
1070    fn kubeconfig_from_empty_string() {
1071        let cfg = Kubeconfig::from_yaml("").unwrap();
1072
1073        assert_eq!(cfg, Kubeconfig::default());
1074    }
1075
1076    #[test]
1077    fn authinfo_deserialize_null_secret() {
1078        let authinfo_yaml = r#"
1079username: user
1080password: 
1081"#;
1082        let authinfo: AuthInfo = serde_saphyr::from_str(authinfo_yaml).unwrap();
1083        assert_eq!(authinfo.username, Some("user".to_string()));
1084        assert!(authinfo.password.is_none());
1085    }
1086
1087    #[test]
1088    fn authinfo_debug_does_not_output_password() {
1089        let authinfo_yaml = r#"
1090username: user
1091password: kube_rs
1092"#;
1093        let authinfo: AuthInfo = serde_saphyr::from_str(authinfo_yaml).unwrap();
1094        let authinfo_debug_output = format!("{authinfo:?}");
1095        let expected_output = "AuthInfo { \
1096        username: Some(\"user\"), \
1097        password: Some(SecretBox<str>([REDACTED])), \
1098        token: None, token_file: None, client_certificate: None, \
1099        client_certificate_data: None, client_key: None, \
1100        client_key_data: None, impersonate: None, \
1101        impersonate_uid: None, \
1102        impersonate_groups: None, \
1103        impersonate_user_extra: None, \
1104        extensions: None, \
1105        auth_provider: None, \
1106        exec: None, \
1107        other: {} \
1108        }";
1109
1110        assert_eq!(authinfo_debug_output, expected_output)
1111    }
1112
1113    #[tokio::test]
1114    async fn authinfo_exec_provide_cluster_info() {
1115        let config = r#"
1116apiVersion: v1
1117clusters:
1118- cluster:
1119    server: https://localhost:8080
1120    extensions:
1121    - name: client.authentication.k8s.io/exec
1122      extension:
1123        audience: foo
1124        other: bar
1125  name: foo-cluster
1126contexts:
1127- context:
1128    cluster: foo-cluster
1129    user: foo-user
1130    namespace: bar
1131  name: foo-context
1132current-context: foo-context
1133kind: Config
1134users:
1135- name: foo-user
1136  user:
1137    exec:
1138      apiVersion: client.authentication.k8s.io/v1alpha1
1139      args:
1140      - arg-1
1141      - arg-2
1142      command: foo-command
1143      provideClusterInfo: true
1144"#;
1145        let kube_config = Kubeconfig::from_yaml(config).unwrap();
1146        let config_loader = ConfigLoader::load(kube_config, None, None, None).await.unwrap();
1147        let auth_info = config_loader.user;
1148        let exec = auth_info.exec.unwrap();
1149        assert!(exec.provide_cluster_info);
1150        let cluster = exec.cluster.unwrap();
1151        assert_eq!(
1152            cluster.config.unwrap(),
1153            json!({"audience": "foo", "other": "bar"})
1154        );
1155    }
1156
1157    #[tokio::test]
1158    async fn parse_kubeconfig_encodings() {
1159        let files = vec![
1160            "kubeconfig_utf8.yaml",
1161            "kubeconfig_utf16le.yaml",
1162            "kubeconfig_utf16be.yaml",
1163        ];
1164
1165        for file_name in files {
1166            let path = PathBuf::from(format!(
1167                "{}/src/config/test_data/{}",
1168                env!("CARGO_MANIFEST_DIR"),
1169                file_name
1170            ));
1171            let cfg = Kubeconfig::read_from(path).unwrap();
1172            assert_eq!(cfg.clusters[0].name, "k3d-promstack");
1173            assert_eq!(cfg.contexts[0].name, "k3d-promstack");
1174            assert_eq!(cfg.auth_infos[0].name, "admin@k3d-k3s-default");
1175        }
1176    }
1177
1178    #[test]
1179    fn read_from_resolves_relative_exec_command() {
1180        // Relative exec plugin commands are resolved against the kubeconfig
1181        // directory (like client-go), while bare PATH commands are left as-is.
1182        // Regression for kdash-rs/kdash#541.
1183        let dir = tempfile::TempDir::new().unwrap();
1184        let path = dir.path().join("config");
1185        // Build the relative command with the platform separator so it contains
1186        // `MAIN_SEPARATOR` on every OS (the guard uses `MAIN_SEPARATOR`, which is
1187        // `\` on Windows), keeping the test separator-agnostic.
1188        let rel_cmd = format!("auth{}token.sh", std::path::MAIN_SEPARATOR);
1189        std::fs::write(
1190            &path,
1191            format!(
1192                r#"
1193apiVersion: v1
1194kind: Config
1195current-context: ctx
1196clusters:
1197- name: cluster
1198  cluster:
1199    server: https://localhost:6443
1200contexts:
1201- name: ctx
1202  context:
1203    cluster: cluster
1204    user: relative-user
1205users:
1206- name: relative-user
1207  user:
1208    exec:
1209      apiVersion: client.authentication.k8s.io/v1beta1
1210      command: {rel_cmd}
1211- name: path-user
1212  user:
1213    exec:
1214      apiVersion: client.authentication.k8s.io/v1beta1
1215      command: aws
1216"#
1217            ),
1218        )
1219        .unwrap();
1220
1221        let cfg = Kubeconfig::read_from(&path).unwrap();
1222
1223        // Relative command containing a separator is made absolute.
1224        let expected = to_absolute(dir.path(), &rel_cmd);
1225        assert_eq!(
1226            cfg.auth_infos[0]
1227                .auth_info
1228                .as_ref()
1229                .and_then(|a| a.exec.as_ref())
1230                .and_then(|e| e.command.clone()),
1231            expected
1232        );
1233
1234        // Bare PATH command is untouched.
1235        assert_eq!(
1236            cfg.auth_infos[1]
1237                .auth_info
1238                .as_ref()
1239                .and_then(|a| a.exec.as_ref())
1240                .and_then(|e| e.command.as_deref()),
1241            Some("aws")
1242        );
1243    }
1244
1245    #[test]
1246    fn kubeconfig_round_trip_preserves_unknown_fields() {
1247        let yaml = r#"
1248apiVersion: v1
1249kind: Config
1250current-context: test
1251custom-top-level-field: should-be-preserved
1252clusters:
1253- name: test-cluster
1254  cluster:
1255    server: https://localhost:6443
1256    certificate-authority-data: dGVzdA==
1257    custom-cluster-field: cluster-extra
1258contexts:
1259- name: test
1260  context:
1261    cluster: test-cluster
1262    user: test-user
1263    custom-context-field: context-extra
1264users:
1265- name: test-user
1266  user:
1267    exec:
1268      apiVersion: client.authentication.k8s.io/v1beta1
1269      command: gke-gcloud-auth-plugin
1270      provideClusterInfo: true
1271      interactiveMode: IfAvailable
1272      custom-exec-field: exec-extra
1273"#;
1274
1275        let config: Kubeconfig = Kubeconfig::from_yaml(yaml).unwrap();
1276
1277        // Verify custom field is captured in the catch-all `other` map
1278        let exec = config.auth_infos[0]
1279            .auth_info
1280            .as_ref()
1281            .unwrap()
1282            .exec
1283            .as_ref()
1284            .unwrap();
1285        assert_eq!(
1286            exec.other.get("custom-exec-field").and_then(|v| v.as_str()),
1287            Some("exec-extra")
1288        );
1289
1290        // Round-trip: serialize back to YAML
1291        let serialized = serde_saphyr::to_string(&config).unwrap();
1292
1293        // Verify unknown fields are preserved
1294        assert!(
1295            serialized.contains("custom-top-level-field"),
1296            "top-level unknown field was lost:\n{serialized}"
1297        );
1298        assert!(
1299            serialized.contains("custom-cluster-field"),
1300            "cluster unknown field was lost:\n{serialized}"
1301        );
1302        assert!(
1303            serialized.contains("custom-context-field"),
1304            "context unknown field was lost:\n{serialized}"
1305        );
1306        assert!(
1307            serialized.contains("custom-exec-field"),
1308            "exec unknown field was lost:\n{serialized}"
1309        );
1310
1311        // Verify re-deserialization produces the same result
1312        let reparsed = Kubeconfig::from_yaml(&serialized).unwrap();
1313        assert_eq!(config, reparsed);
1314    }
1315}