Skip to main content

host_identity/
ids.rs

1//! Identifier-based chain construction.
2//!
3//! Build a [`crate::Resolver`] from a list of string identifiers,
4//! the way an operator would specify sources in a config file. Complements
5//! the typed builder API: the typed constructors (`MachineIdFile::default`,
6//! `AwsImds::new(t)`, …) stay available and take precedence when you need
7//! non-default parameters; the identifier API covers the common
8//! "reasonable defaults, list in config" workflow.
9//!
10//! # Example
11//!
12//! ```
13//! # use host_identity::ids::{resolver_from_ids, source_ids};
14//! let resolver = resolver_from_ids([
15//!     source_ids::ENV_OVERRIDE,
16//!     source_ids::MACHINE_ID,
17//!     source_ids::DMI,
18//! ]).unwrap();
19//! ```
20//!
21//! # Identifiers
22//!
23//! The identifier for every built-in source is the string returned by
24//! [`SourceKind::as_str`]. Stable constants live in [`source_ids`] for
25//! compile-time typo catching.
26//!
27//! Two identifiers are recognised by [`SourceKind::from_id`] but cannot
28//! be built by [`resolver_from_ids`] because they need a caller-supplied
29//! path — `"file-override"` and `"kubernetes-downward-api"`. Passing
30//! either returns [`UnknownSourceError::RequiresPath`]; construct them
31//! manually with their typed constructors and `.push(...)` them onto
32//! the returned resolver.
33//!
34//! Cloud identifiers (`"aws-imds"`, `"gcp-metadata"`, …) need an HTTP
35//! transport — [`resolver_from_ids`] rejects them with
36//! [`UnknownSourceError::RequiresTransport`]; use
37//! [`resolver_from_ids_with_transport`] instead.
38
39use crate::source::{Source, SourceKind};
40use crate::{Resolver, sources};
41
42/// Stable identifier strings for every built-in source. Use these over
43/// raw string literals to catch typos at compile time.
44pub mod source_ids {
45    /// `"env-override"` — [`crate::sources::EnvOverride`] with the default
46    /// `HOST_IDENTITY` variable name.
47    pub const ENV_OVERRIDE: &str = "env-override";
48    /// `"file-override"` — [`crate::sources::FileOverride`]. Not
49    /// default-constructible; resolves to
50    /// [`super::UnknownSourceError::RequiresPath`] in identifier-based
51    /// builders.
52    pub const FILE_OVERRIDE: &str = "file-override";
53    /// `"container"` — [`crate::sources::ContainerId`] (feature `container`).
54    pub const CONTAINER: &str = "container";
55    /// `"lxc"` — [`crate::sources::LxcId`] (feature `container`).
56    pub const LXC: &str = "lxc";
57    /// `"machine-id"` — [`crate::sources::MachineIdFile`].
58    pub const MACHINE_ID: &str = "machine-id";
59    /// `"dbus-machine-id"` — [`crate::sources::DbusMachineIdFile`].
60    pub const DBUS_MACHINE_ID: &str = "dbus-machine-id";
61    /// `"dmi"` — [`crate::sources::DmiProductUuid`].
62    pub const DMI: &str = "dmi";
63    /// `"linux-hostid"` — [`crate::sources::LinuxHostIdFile`]. Opt-in;
64    /// not part of either default chain.
65    pub const LINUX_HOSTID: &str = "linux-hostid";
66    /// `"io-platform-uuid"` — [`crate::sources::IoPlatformUuid`].
67    pub const IO_PLATFORM_UUID: &str = "io-platform-uuid";
68    /// `"windows-machine-guid"` — [`crate::sources::WindowsMachineGuid`].
69    pub const WINDOWS_MACHINE_GUID: &str = "windows-machine-guid";
70    /// `"freebsd-hostid"` — [`crate::sources::FreeBsdHostIdFile`].
71    pub const FREEBSD_HOSTID: &str = "freebsd-hostid";
72    /// `"kenv-smbios"` — [`crate::sources::KenvSmbios`].
73    pub const KENV_SMBIOS: &str = "kenv-smbios";
74    /// `"bsd-kern-hostid"` — [`crate::sources::SysctlKernHostId`].
75    pub const BSD_KERN_HOSTID: &str = "bsd-kern-hostid";
76    /// `"illumos-hostid"` — [`crate::sources::IllumosHostId`].
77    pub const ILLUMOS_HOSTID: &str = "illumos-hostid";
78    /// `"aws-imds"` — [`crate::sources::AwsImds`]. Requires transport.
79    pub const AWS_IMDS: &str = "aws-imds";
80    /// `"gcp-metadata"` — [`crate::sources::GcpMetadata`]. Requires transport.
81    pub const GCP_METADATA: &str = "gcp-metadata";
82    /// `"azure-imds"` — [`crate::sources::AzureImds`]. Requires transport.
83    pub const AZURE_IMDS: &str = "azure-imds";
84    /// `"digital-ocean-metadata"` — [`crate::sources::DigitalOceanMetadata`].
85    /// Requires transport.
86    pub const DIGITAL_OCEAN_METADATA: &str = "digital-ocean-metadata";
87    /// `"hetzner-metadata"` — [`crate::sources::HetznerMetadata`]. Requires
88    /// transport.
89    pub const HETZNER_METADATA: &str = "hetzner-metadata";
90    /// `"oci-metadata"` — [`crate::sources::OciMetadata`]. Requires transport.
91    pub const OCI_METADATA: &str = "oci-metadata";
92    /// `"openstack-metadata"` — [`crate::sources::OpenStackMetadata`]. Requires
93    /// transport.
94    pub const OPENSTACK_METADATA: &str = "openstack-metadata";
95    /// `"kubernetes-pod-uid"` — [`crate::sources::KubernetesPodUid`].
96    pub const KUBERNETES_POD_UID: &str = "kubernetes-pod-uid";
97    /// `"kubernetes-service-account"` — [`crate::sources::KubernetesServiceAccount`].
98    pub const KUBERNETES_SERVICE_ACCOUNT: &str = "kubernetes-service-account";
99    /// `"kubernetes-downward-api"` — [`crate::sources::KubernetesDownwardApi`].
100    /// Not default-constructible; needs a path.
101    pub const KUBERNETES_DOWNWARD_API: &str = "kubernetes-downward-api";
102}
103
104/// Reasons an identifier-based chain could not be built.
105#[derive(Debug, thiserror::Error)]
106pub enum UnknownSourceError {
107    /// The identifier didn't match any built-in source.
108    #[error("unknown source identifier: `{0}`")]
109    Unknown(String),
110    /// The identifier names a source that requires a caller-supplied path
111    /// (e.g. `file-override`, `kubernetes-downward-api`). Build it
112    /// manually with the typed constructor and chain via `.push(...)`.
113    #[error(
114        "source `{0}` requires a caller-supplied path; construct it with its typed constructor and push it manually"
115    )]
116    RequiresPath(&'static str),
117    /// The identifier names a cloud source that needs an HTTP transport;
118    /// use [`resolver_from_ids_with_transport`].
119    #[error("source `{0}` requires an HTTP transport; use resolver_from_ids_with_transport")]
120    RequiresTransport(&'static str),
121    /// The identifier is valid but its crate feature is not enabled in
122    /// this build.
123    #[error("source `{0}` is not available — the `{1}` feature is not enabled")]
124    FeatureDisabled(&'static str, &'static str),
125}
126
127/// Build a [`Resolver`] from a list of source identifiers. Local sources
128/// only — cloud identifiers return [`UnknownSourceError::RequiresTransport`].
129///
130/// The returned resolver has the identifiers' sources in the order they
131/// were supplied. Call `.push(...)` / `.prepend(...)` on it to add
132/// typed-constructor sources (e.g. `FileOverride::new(path)`) that
133/// can't be built from an identifier alone.
134///
135/// # Errors
136///
137/// Returns [`UnknownSourceError`] on the first unrecognised, path-requiring,
138/// transport-requiring, or feature-disabled identifier.
139pub fn resolver_from_ids<S, I>(ids: I) -> Result<Resolver, UnknownSourceError>
140where
141    S: AsRef<str>,
142    I: IntoIterator<Item = S>,
143{
144    let mut resolver = Resolver::new();
145    for id in ids {
146        let source = local_source_from_id(id.as_ref())?;
147        resolver = resolver.push_boxed(source);
148    }
149    Ok(resolver)
150}
151
152/// Build a [`Resolver`] from a list of source identifiers, with an HTTP
153/// transport available for cloud sources.
154///
155/// Accepts the same identifiers as [`resolver_from_ids`] plus every
156/// enabled cloud source (`aws-imds`, `gcp-metadata`, `azure-imds`,
157/// `digital-ocean-metadata`, `hetzner-metadata`, `oci-metadata`).
158///
159/// # Errors
160///
161/// As [`resolver_from_ids`], minus [`UnknownSourceError::RequiresTransport`]
162/// (which can't occur here).
163#[cfg(feature = "_transport")]
164#[allow(
165    clippy::needless_pass_by_value,
166    reason = "by-value transport matches `resolve_with_transport` and `Resolver::with_network_defaults`; the final clone drops the original"
167)]
168pub fn resolver_from_ids_with_transport<S, I, T>(
169    ids: I,
170    transport: T,
171) -> Result<Resolver, UnknownSourceError>
172where
173    S: AsRef<str>,
174    I: IntoIterator<Item = S>,
175    T: crate::transport::HttpTransport + Clone + 'static,
176{
177    let mut resolver = Resolver::new();
178    for id in ids {
179        let source = source_from_id_with_transport(id.as_ref(), transport.clone())?;
180        resolver = resolver.push_boxed(source);
181    }
182    Ok(resolver)
183}
184
185/// Expand to `Ok(Box::new(ctor))` when the feature is on, or to a
186/// `FeatureDisabled` error when it isn't. Used by both source lookups
187/// below to keep the feature-gated arms to one line each.
188macro_rules! feature_ctor {
189    ($feature:literal, $id:literal, $ctor:expr) => {{
190        #[cfg(feature = $feature)]
191        {
192            Ok(Box::new($ctor))
193        }
194        #[cfg(not(feature = $feature))]
195        {
196            Err(UnknownSourceError::FeatureDisabled($id, $feature))
197        }
198    }};
199}
200
201fn local_source_from_id(id: &str) -> Result<Box<dyn Source>, UnknownSourceError> {
202    let kind = SourceKind::from_id(id).ok_or_else(|| UnknownSourceError::Unknown(id.to_owned()))?;
203    non_constructible_local(kind)
204        .or_else(|| feature_gated_local(kind))
205        .unwrap_or_else(|| Ok(plain_local(kind)))
206}
207
208// Variants whose construction either needs no work (`EnvOverride`),
209// needs a caller-supplied path (`FileOverride`, `KubernetesDownwardApi`),
210// or needs an HTTP transport (the cloud kinds).
211fn non_constructible_local(
212    kind: SourceKind,
213) -> Option<Result<Box<dyn Source>, UnknownSourceError>> {
214    match kind {
215        SourceKind::EnvOverride => Some(Ok(Box::new(sources::EnvOverride::new("HOST_IDENTITY")))),
216        SourceKind::FileOverride => Some(Err(UnknownSourceError::RequiresPath("file-override"))),
217        SourceKind::KubernetesDownwardApi => Some(Err(UnknownSourceError::RequiresPath(
218            "kubernetes-downward-api",
219        ))),
220        SourceKind::AwsImds
221        | SourceKind::GcpMetadata
222        | SourceKind::AzureImds
223        | SourceKind::DigitalOceanMetadata
224        | SourceKind::HetznerMetadata
225        | SourceKind::OciMetadata
226        | SourceKind::OpenStackMetadata => {
227            Some(Err(UnknownSourceError::RequiresTransport(kind.as_str())))
228        }
229        _ => None,
230    }
231}
232
233// Variants whose constructor is gated behind a crate feature.
234fn feature_gated_local(kind: SourceKind) -> Option<Result<Box<dyn Source>, UnknownSourceError>> {
235    Some(match kind {
236        SourceKind::Container => {
237            feature_ctor!("container", "container", sources::ContainerId::default())
238        }
239        SourceKind::Lxc => feature_ctor!("container", "lxc", sources::LxcId::default()),
240        SourceKind::KubernetesPodUid => feature_ctor!(
241            "k8s",
242            "kubernetes-pod-uid",
243            sources::KubernetesPodUid::default()
244        ),
245        SourceKind::KubernetesServiceAccount => feature_ctor!(
246            "k8s",
247            "kubernetes-service-account",
248            sources::KubernetesServiceAccount::default()
249        ),
250        _ => return None,
251    })
252}
253
254// Always-available platform-typed sources. Dispatched by platform
255// family so no single helper matches every variant at once.
256fn plain_local(kind: SourceKind) -> Box<dyn Source> {
257    linux_family_source(kind)
258        .or_else(|| native_non_linux_source(kind))
259        .unwrap_or_else(|| unreachable!("plain_local reached with unhandled kind: {kind:?}"))
260}
261
262fn linux_family_source(kind: SourceKind) -> Option<Box<dyn Source>> {
263    Some(match kind {
264        SourceKind::MachineId => Box::new(sources::MachineIdFile::default()),
265        SourceKind::DbusMachineId => Box::new(sources::DbusMachineIdFile::default()),
266        SourceKind::Dmi => Box::new(sources::DmiProductUuid::default()),
267        SourceKind::LinuxHostId => Box::new(sources::LinuxHostIdFile::default()),
268        _ => return None,
269    })
270}
271
272fn native_non_linux_source(kind: SourceKind) -> Option<Box<dyn Source>> {
273    Some(match kind {
274        SourceKind::IoPlatformUuid => Box::new(sources::IoPlatformUuid::default()),
275        SourceKind::WindowsMachineGuid => Box::new(sources::WindowsMachineGuid::default()),
276        SourceKind::FreeBsdHostId => Box::new(sources::FreeBsdHostIdFile::default()),
277        SourceKind::KenvSmbios => Box::new(sources::KenvSmbios::default()),
278        SourceKind::BsdKernHostId => Box::new(sources::SysctlKernHostId::default()),
279        SourceKind::IllumosHostId => Box::new(sources::IllumosHostId::default()),
280        _ => return None,
281    })
282}
283
284#[cfg(feature = "_transport")]
285fn source_from_id_with_transport<T>(
286    id: &str,
287    transport: T,
288) -> Result<Box<dyn Source>, UnknownSourceError>
289where
290    T: crate::transport::HttpTransport + Clone + 'static,
291{
292    let kind = SourceKind::from_id(id).ok_or_else(|| UnknownSourceError::Unknown(id.to_owned()))?;
293    match kind {
294        SourceKind::AwsImds => feature_ctor!("aws", "aws-imds", sources::AwsImds::new(transport)),
295        SourceKind::GcpMetadata => {
296            feature_ctor!("gcp", "gcp-metadata", sources::GcpMetadata::new(transport))
297        }
298        SourceKind::AzureImds => {
299            feature_ctor!("azure", "azure-imds", sources::AzureImds::new(transport))
300        }
301        SourceKind::DigitalOceanMetadata => feature_ctor!(
302            "digitalocean",
303            "digital-ocean-metadata",
304            sources::DigitalOceanMetadata::new(transport)
305        ),
306        SourceKind::HetznerMetadata => feature_ctor!(
307            "hetzner",
308            "hetzner-metadata",
309            sources::HetznerMetadata::new(transport)
310        ),
311        SourceKind::OciMetadata => {
312            feature_ctor!("oci", "oci-metadata", sources::OciMetadata::new(transport))
313        }
314        SourceKind::OpenStackMetadata => feature_ctor!(
315            "openstack",
316            "openstack-metadata",
317            sources::OpenStackMetadata::new(transport)
318        ),
319        _ => {
320            // Drop the cloned transport explicitly — the fallback path
321            // doesn't need it, and holding onto a clone until the end of
322            // scope would defer closing any transport-held resources
323            // (sockets, client handles) for no reason.
324            drop(transport);
325            local_source_from_id(id)
326        }
327    }
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn source_kind_from_id_round_trips_every_builtin() {
336        for kind in [
337            SourceKind::EnvOverride,
338            SourceKind::FileOverride,
339            SourceKind::Container,
340            SourceKind::Lxc,
341            SourceKind::MachineId,
342            SourceKind::DbusMachineId,
343            SourceKind::Dmi,
344            SourceKind::LinuxHostId,
345            SourceKind::IoPlatformUuid,
346            SourceKind::WindowsMachineGuid,
347            SourceKind::FreeBsdHostId,
348            SourceKind::KenvSmbios,
349            SourceKind::BsdKernHostId,
350            SourceKind::IllumosHostId,
351            SourceKind::AwsImds,
352            SourceKind::GcpMetadata,
353            SourceKind::AzureImds,
354            SourceKind::DigitalOceanMetadata,
355            SourceKind::HetznerMetadata,
356            SourceKind::OciMetadata,
357            SourceKind::OpenStackMetadata,
358            SourceKind::KubernetesPodUid,
359            SourceKind::KubernetesServiceAccount,
360            SourceKind::KubernetesDownwardApi,
361        ] {
362            assert_eq!(SourceKind::from_id(kind.as_str()), Some(kind));
363        }
364    }
365
366    // Guards against a future `SourceKind` variant being added without
367    // wiring it into the three-stage dispatch inside `local_source_from_id`.
368    // The exhaustive match that used to catch this at compile time was
369    // split into family-scoped helpers with `_ => return None` arms; this
370    // test closes that gap by ensuring every built-in identifier either
371    // constructs successfully or surfaces a documented `UnknownSourceError`
372    // — never a runtime `unreachable!` panic.
373    #[test]
374    fn local_source_from_id_handles_every_builtin_identifier() {
375        for id in [
376            source_ids::ENV_OVERRIDE,
377            source_ids::FILE_OVERRIDE,
378            source_ids::CONTAINER,
379            source_ids::LXC,
380            source_ids::MACHINE_ID,
381            source_ids::DBUS_MACHINE_ID,
382            source_ids::DMI,
383            source_ids::LINUX_HOSTID,
384            source_ids::IO_PLATFORM_UUID,
385            source_ids::WINDOWS_MACHINE_GUID,
386            source_ids::FREEBSD_HOSTID,
387            source_ids::KENV_SMBIOS,
388            source_ids::BSD_KERN_HOSTID,
389            source_ids::ILLUMOS_HOSTID,
390            source_ids::AWS_IMDS,
391            source_ids::GCP_METADATA,
392            source_ids::AZURE_IMDS,
393            source_ids::DIGITAL_OCEAN_METADATA,
394            source_ids::HETZNER_METADATA,
395            source_ids::OCI_METADATA,
396            source_ids::OPENSTACK_METADATA,
397            source_ids::KUBERNETES_POD_UID,
398            source_ids::KUBERNETES_SERVICE_ACCOUNT,
399            source_ids::KUBERNETES_DOWNWARD_API,
400        ] {
401            match local_source_from_id(id) {
402                Ok(_)
403                | Err(
404                    UnknownSourceError::RequiresPath(_)
405                    | UnknownSourceError::RequiresTransport(_)
406                    | UnknownSourceError::FeatureDisabled(_, _),
407                ) => {}
408                Err(UnknownSourceError::Unknown(got)) => {
409                    panic!("identifier `{id}` was reported as unknown (got `{got}`)");
410                }
411            }
412        }
413    }
414
415    #[test]
416    fn source_kind_from_id_rejects_unknown() {
417        assert_eq!(SourceKind::from_id("not-a-real-source"), None);
418        assert_eq!(SourceKind::from_id(""), None);
419        // Custom variants intentionally don't round-trip.
420        assert_eq!(SourceKind::from_id("my-custom-source"), None);
421    }
422
423    #[test]
424    fn resolver_from_ids_builds_chain_in_order() {
425        let resolver =
426            resolver_from_ids([source_ids::ENV_OVERRIDE, source_ids::MACHINE_ID]).unwrap();
427        assert_eq!(
428            resolver.source_kinds(),
429            vec![SourceKind::EnvOverride, SourceKind::MachineId]
430        );
431    }
432
433    #[test]
434    fn resolver_from_ids_rejects_unknown_identifier() {
435        match resolver_from_ids(["machine-id", "not-real"]).unwrap_err() {
436            UnknownSourceError::Unknown(s) => assert_eq!(s, "not-real"),
437            other => panic!("expected Unknown, got {other:?}"),
438        }
439    }
440
441    #[test]
442    fn resolver_from_ids_rejects_path_requiring_sources() {
443        match resolver_from_ids([source_ids::FILE_OVERRIDE]).unwrap_err() {
444            UnknownSourceError::RequiresPath(id) => assert_eq!(id, "file-override"),
445            other => panic!("expected RequiresPath, got {other:?}"),
446        }
447        #[cfg(feature = "k8s")]
448        match resolver_from_ids([source_ids::KUBERNETES_DOWNWARD_API]).unwrap_err() {
449            UnknownSourceError::RequiresPath(id) => {
450                assert_eq!(id, "kubernetes-downward-api");
451            }
452            other => panic!("expected RequiresPath, got {other:?}"),
453        }
454    }
455
456    #[cfg(feature = "aws")]
457    #[test]
458    fn resolver_from_ids_rejects_cloud_ids_without_transport() {
459        match resolver_from_ids([source_ids::AWS_IMDS]).unwrap_err() {
460            UnknownSourceError::RequiresTransport(id) => assert_eq!(id, "aws-imds"),
461            other => panic!("expected RequiresTransport, got {other:?}"),
462        }
463    }
464
465    #[cfg(feature = "aws")]
466    #[test]
467    fn resolver_from_ids_with_transport_accepts_cloud_ids() {
468        use crate::transport::HttpTransport;
469        use std::convert::Infallible;
470
471        #[derive(Clone)]
472        struct NoopTransport;
473        impl HttpTransport for NoopTransport {
474            type Error = Infallible;
475            fn send(
476                &self,
477                _req: http::Request<Vec<u8>>,
478            ) -> Result<http::Response<Vec<u8>>, Self::Error> {
479                Ok(http::Response::builder()
480                    .status(404)
481                    .body(Vec::new())
482                    .unwrap())
483            }
484        }
485
486        let resolver = resolver_from_ids_with_transport(
487            [
488                source_ids::ENV_OVERRIDE,
489                source_ids::AWS_IMDS,
490                source_ids::MACHINE_ID,
491            ],
492            NoopTransport,
493        )
494        .unwrap();
495        assert_eq!(
496            resolver.source_kinds(),
497            vec![
498                SourceKind::EnvOverride,
499                SourceKind::AwsImds,
500                SourceKind::MachineId
501            ],
502        );
503    }
504
505    #[cfg(not(feature = "k8s"))]
506    #[test]
507    fn resolver_from_ids_reports_feature_disabled() {
508        match resolver_from_ids([source_ids::KUBERNETES_POD_UID]).unwrap_err() {
509            UnknownSourceError::FeatureDisabled(id, feat) => {
510                assert_eq!(id, "kubernetes-pod-uid");
511                assert_eq!(feat, "k8s");
512            }
513            other => panic!("expected FeatureDisabled, got {other:?}"),
514        }
515    }
516}