Skip to main content

host_identity_cli/
lib.rs

1//! `host-identity` — command-line interface for the `host-identity` crate.
2//! Binary was renamed from `hostid` to avoid colliding with coreutils
3//! `hostid(1)`; see `crates/host-identity-cli/Cargo.toml` for the
4//! `[[bin]]` name and the rationale.
5//!
6//! This crate also exposes a small library surface so build tooling
7//! (the workspace `xtask` that generates man pages) can reuse the
8//! exact `clap::Command` definition the binary ships with. End users
9//! should depend on the [`host-identity`] library directly.
10//!
11//! [`host-identity`]: https://crates.io/crates/host-identity
12
13use std::ffi::OsStr;
14use std::io::{self, Write};
15use std::path::PathBuf;
16use std::process::ExitCode;
17
18use anyhow::{Context, Result, anyhow};
19use clap::{Parser, Subcommand, ValueEnum};
20use host_identity::ids::{resolver_from_ids, source_ids};
21use host_identity::sources::FileOverride;
22use host_identity::{HostId, ResolveOutcome, Resolver, SourceKind, UnknownSourceError, Wrap};
23use serde::Serialize;
24
25/// Environment variable that, when set to a non-empty path, causes the
26/// CLI to prepend a [`FileOverride`] at the front of the resolver
27/// chain. Takes precedence over `HOST_IDENTITY`.
28const HOST_IDENTITY_FILE_ENV: &str = "HOST_IDENTITY_FILE";
29
30#[cfg(feature = "network")]
31mod transport;
32
33/// Crate version, re-exported so the workspace `xtask` can stamp the
34/// man page footer with the CLI crate's version rather than its own.
35pub const VERSION: &str = env!("CARGO_PKG_VERSION");
36
37const LONG_ABOUT: &str = "\
38Resolve a stable, collision-resistant host UUID across platforms, container \
39runtimes, cloud providers, and Kubernetes.
40
41host-identity walks a platform-appropriate chain of identity sources (env override, \
42/etc/machine-id, DMI, cloud metadata, Kubernetes pod UID, …) and returns the \
43first one that produces a credible identifier. Cloned-VM sentinels, empty \
44files, and systemd's literal `uninitialized` string are rejected rather than \
45silently hashed into a shared ID.
46
47Two environment variables pin identity explicitly when the automatic chain \
48gets it wrong. HOST_IDENTITY_FILE names a file whose contents are used as \
49the host identifier and takes precedence over every other source, including \
50HOST_IDENTITY. HOST_IDENTITY supplies the identifier inline and is consulted \
51next. Both work with the default chain and with explicit --sources.
52
53By default the chain uses only local sources. Pass --network to pull in \
54cloud-metadata and Kubernetes probes, which require an HTTP client and a \
55binary built with the `network` feature.";
56
57const EXAMPLES: &str = "\
58EXAMPLES:
59    Print the host UUID using the default local source chain:
60        host-identity
61
62    Include cloud-metadata and Kubernetes sources:
63        host-identity resolve --network
64
65    Build a custom chain from explicit source identifiers:
66        host-identity resolve --sources env-override,machine-id,dmi
67
68    Emit machine-readable output:
69        host-identity resolve --format json
70        host-identity audit --format json
71
72    Pin identity via environment override:
73        HOST_IDENTITY=11111111-2222-3333-4444-555555555555 host-identity
74
75    Pin identity via a file (takes precedence over HOST_IDENTITY):
76        HOST_IDENTITY_FILE=/etc/host-identity host-identity
77
78    List every source identifier compiled into this binary:
79        host-identity sources
80";
81
82/// Top-level command-line interface for the `host-identity` binary.
83#[derive(Parser)]
84#[command(
85    name = "host-identity",
86    version,
87    author,
88    about = "Resolve a stable host UUID across platforms, clouds, and Kubernetes",
89    long_about = LONG_ABOUT,
90    after_long_help = EXAMPLES,
91    args_conflicts_with_subcommands = true,
92)]
93pub struct Cli {
94    #[command(subcommand)]
95    command: Option<Command>,
96
97    /// Top-level flags apply only when no subcommand is given (they are
98    /// shorthand for `host-identity resolve ...`).
99    #[command(flatten)]
100    resolve: ResolveArgs,
101}
102
103#[derive(Subcommand)]
104enum Command {
105    /// Resolve the host identity and print it (default).
106    Resolve(ResolveArgs),
107    /// Walk every source without short-circuiting and report each outcome.
108    Audit(AuditArgs),
109    /// List every source identifier compiled into this binary.
110    Sources {
111        /// Emit JSON instead of one identifier per line.
112        #[arg(long)]
113        json: bool,
114    },
115}
116
117#[derive(Parser, Clone, Default)]
118struct ResolveArgs {
119    /// Output format.
120    #[arg(long, value_enum, default_value_t = Format::Plain)]
121    format: Format,
122
123    /// UUID wrap strategy.
124    #[arg(long, value_enum, default_value_t = WrapArg::V5)]
125    wrap: WrapArg,
126
127    /// Comma-separated source identifiers to build a custom chain
128    /// (see `host-identity sources`). Combine with `--network` to include
129    /// cloud-metadata sources in the chain.
130    #[arg(long, value_delimiter = ',')]
131    sources: Vec<String>,
132
133    /// Enable cloud-metadata and Kubernetes sources by supplying an HTTP
134    /// transport. Without `--sources` this adds them to the default chain;
135    /// with `--sources` it lets identifiers like `aws-imds` resolve.
136    /// Requires the binary to be built with the `network` feature.
137    #[arg(long)]
138    network: bool,
139
140    /// Per-request timeout, in milliseconds, for cloud-metadata and
141    /// Kubernetes HTTP probes. Only meaningful with `--network`. Off-cloud
142    /// hosts never answer these endpoints, so this directly bounds the
143    /// time spent waiting before falling through to the next source.
144    #[arg(long, value_name = "MS", value_parser = clap::value_parser!(u64).range(1..))]
145    network_timeout_ms: Option<u64>,
146}
147
148#[derive(Parser, Clone, Default)]
149struct AuditArgs {
150    #[command(flatten)]
151    resolve: ResolveArgs,
152}
153
154#[derive(ValueEnum, Clone, Copy, Default)]
155enum Format {
156    #[default]
157    Plain,
158    Summary,
159    Json,
160}
161
162#[derive(ValueEnum, Clone, Copy, Default)]
163enum WrapArg {
164    #[default]
165    V5,
166    V3,
167    Passthrough,
168}
169
170impl From<WrapArg> for Wrap {
171    fn from(w: WrapArg) -> Self {
172        match w {
173            WrapArg::V5 => Wrap::UuidV5Namespaced,
174            WrapArg::V3 => Wrap::UuidV3Nil,
175            WrapArg::Passthrough => Wrap::Passthrough,
176        }
177    }
178}
179
180/// Exit codes surfaced by the CLI. Scripts can branch on
181/// `Usage` (2) vs. `Runtime` (1) to distinguish a bad invocation
182/// from a host where no source produced an identity.
183const EXIT_USAGE: u8 = 2;
184
185/// Errors that `build_resolver` converts into an `EXIT_USAGE` exit.
186#[derive(Debug)]
187enum CliError {
188    Usage(anyhow::Error),
189    Runtime(anyhow::Error),
190}
191
192impl CliError {
193    fn exit_code(&self) -> ExitCode {
194        match self {
195            Self::Usage(_) => ExitCode::from(EXIT_USAGE),
196            Self::Runtime(_) => ExitCode::FAILURE,
197        }
198    }
199    fn into_inner(self) -> anyhow::Error {
200        match self {
201            Self::Usage(e) | Self::Runtime(e) => e,
202        }
203    }
204}
205
206fn usage<T>(msg: anyhow::Error) -> Result<T, CliError> {
207    Err(CliError::Usage(msg))
208}
209
210fn runtime_err<E: Into<anyhow::Error>>(e: E) -> CliError {
211    CliError::Runtime(e.into())
212}
213
214fn runtime<T>(msg: anyhow::Error) -> Result<T, CliError> {
215    Err(CliError::Runtime(msg))
216}
217
218/// Parse argv and run the CLI, returning the process exit code.
219#[must_use]
220pub fn run() -> ExitCode {
221    let cli = Cli::parse();
222    let result = match cli.command {
223        Some(Command::Resolve(args)) => run_resolve(&args),
224        Some(Command::Audit(args)) => run_audit(&args.resolve),
225        Some(Command::Sources { json }) => run_sources(json),
226        None => run_resolve(&cli.resolve),
227    };
228    match result {
229        Ok(()) => ExitCode::SUCCESS,
230        Err(err) => {
231            let code = err.exit_code();
232            eprintln!("host-identity: {:#}", err.into_inner());
233            code
234        }
235    }
236}
237
238/// Write to stdout, collapsing `BrokenPipe` into a clean exit.
239/// Without this, piping `host-identity audit | head` panics.
240fn write_and_flush(bytes: &[u8]) -> io::Result<()> {
241    let stdout = io::stdout();
242    let mut lock = stdout.lock();
243    match lock.write_all(bytes).and_then(|()| lock.flush()) {
244        Ok(()) => Ok(()),
245        Err(err) if err.kind() == io::ErrorKind::BrokenPipe => Ok(()),
246        Err(err) => Err(err),
247    }
248}
249
250fn build_resolver(args: &ResolveArgs) -> Result<Resolver, CliError> {
251    if args.network_timeout_ms.is_some() && !args.network {
252        return usage(anyhow!("`--network-timeout-ms` requires `--network`"));
253    }
254    let resolver = match (args.sources.is_empty(), args.network) {
255        (true, false) => Resolver::with_defaults(),
256        (true, true) => network_defaults(args.network_timeout_ms).map_err(CliError::Usage)?,
257        (false, false) => {
258            resolver_from_ids(&args.sources).map_err(|e| CliError::Usage(map_unknown(e)))?
259        }
260        (false, true) => resolver_from_ids_network(&args.sources, args.network_timeout_ms)
261            .map_err(CliError::Usage)?,
262    };
263
264    let mut resolver = resolver.with_wrap(Wrap::from(args.wrap));
265    if let Some(file_override) = host_identity_file_override() {
266        resolver = resolver.prepend(file_override);
267    }
268    Ok(resolver)
269}
270
271/// Read `HOST_IDENTITY_FILE` from the process environment and, if set
272/// to a non-empty path, return a [`FileOverride`] for it. The override
273/// is prepended by [`build_resolver`] so it outranks every other source,
274/// matching the documented precedence in `LONG_ABOUT`.
275fn host_identity_file_override() -> Option<FileOverride> {
276    file_override_from_env_value(std::env::var_os(HOST_IDENTITY_FILE_ENV).as_deref())
277}
278
279/// Pure helper: construct a [`FileOverride`] from a raw env-var value.
280/// Returns `None` when the value is absent or empty. A set-but-empty
281/// value is treated the same as unset so a script clearing the
282/// variable (`HOST_IDENTITY_FILE=`) disables the override rather than
283/// silently turning into `FileOverride::new("")` (which would probe a
284/// relative empty path).
285fn file_override_from_env_value(value: Option<&OsStr>) -> Option<FileOverride> {
286    let raw = value?;
287    if raw.is_empty() {
288        return None;
289    }
290    Some(FileOverride::new(PathBuf::from(raw)))
291}
292
293#[cfg(feature = "network")]
294#[allow(clippy::unnecessary_wraps)]
295fn network_defaults(timeout_ms: Option<u64>) -> Result<Resolver> {
296    Ok(Resolver::with_network_defaults(build_transport(timeout_ms)))
297}
298
299#[cfg(not(feature = "network"))]
300fn network_defaults(_timeout_ms: Option<u64>) -> Result<Resolver> {
301    Err(network_feature_disabled())
302}
303
304#[cfg(feature = "network")]
305fn resolver_from_ids_network(ids: &[String], timeout_ms: Option<u64>) -> Result<Resolver> {
306    host_identity::ids::resolver_from_ids_with_transport(ids, build_transport(timeout_ms))
307        .map_err(map_unknown)
308}
309
310#[cfg(not(feature = "network"))]
311fn resolver_from_ids_network(_ids: &[String], _timeout_ms: Option<u64>) -> Result<Resolver> {
312    Err(network_feature_disabled())
313}
314
315#[cfg(feature = "network")]
316fn build_transport(timeout_ms: Option<u64>) -> transport::UreqTransport {
317    let timeout = timeout_ms.map_or(
318        transport::DEFAULT_NETWORK_TIMEOUT,
319        std::time::Duration::from_millis,
320    );
321    transport::UreqTransport::with_timeout(timeout)
322}
323
324#[cfg(not(feature = "network"))]
325fn network_feature_disabled() -> anyhow::Error {
326    anyhow!("this build has no `network` feature; rebuild with `--features network`")
327}
328
329fn map_unknown(err: UnknownSourceError) -> anyhow::Error {
330    match err {
331        UnknownSourceError::Unknown(id) => anyhow!("unknown source identifier: `{id}`"),
332        UnknownSourceError::RequiresPath(id) => anyhow!(
333            "source `{id}` requires a caller-supplied path and cannot be built from an identifier",
334        ),
335        UnknownSourceError::RequiresTransport(id) => {
336            anyhow!("source `{id}` is a cloud source; pass `--network` to supply an HTTP transport")
337        }
338        UnknownSourceError::FeatureDisabled(id, feat) => anyhow!(
339            "source `{id}` requires the `{feat}` feature, which isn't enabled in this build",
340        ),
341    }
342}
343
344fn run_resolve(args: &ResolveArgs) -> Result<(), CliError> {
345    let resolver = build_resolver(args)?;
346    let id = resolver
347        .resolve()
348        .context("no source produced a host identity")
349        .map_err(CliError::Runtime)?;
350    print_host_id(&id, args.format).map_err(CliError::Runtime)
351}
352
353fn run_audit(args: &ResolveArgs) -> Result<(), CliError> {
354    let resolver = build_resolver(args)?;
355    let outcomes = resolver.resolve_all();
356    let mut buf = Vec::new();
357    match args.format {
358        Format::Json => {
359            let report: Vec<AuditEntry> = outcomes.iter().map(AuditEntry::from).collect();
360            serde_json::to_writer_pretty(&mut buf, &report).map_err(runtime_err)?;
361            buf.push(b'\n');
362        }
363        Format::Plain | Format::Summary => {
364            for (i, outcome) in outcomes.iter().enumerate() {
365                let kind = outcome.source();
366                let tail = match outcome {
367                    ResolveOutcome::Found(id) => id.summary().to_string(),
368                    ResolveOutcome::Skipped(_) => "(skipped)".to_owned(),
369                    ResolveOutcome::Errored(_, err) => format!("ERROR {err}"),
370                };
371                writeln!(buf, "{i:>2}. {kind:<28} -> {tail}").map_err(runtime_err)?;
372            }
373        }
374    }
375    write_and_flush(&buf).map_err(runtime_err)?;
376
377    // Exit non-zero (runtime) when every outcome errored or skipped —
378    // nothing to show for the walk, matching `run_resolve`'s contract.
379    if !outcomes
380        .iter()
381        .any(|o| matches!(o, ResolveOutcome::Found(_)))
382    {
383        return runtime(anyhow!("no source produced a host identity"));
384    }
385    Ok(())
386}
387
388fn run_sources(json: bool) -> Result<(), CliError> {
389    let ids = available_source_ids();
390    let mut buf = Vec::new();
391    if json {
392        let entries: Vec<SourceEntry> = ids
393            .iter()
394            .map(|id| SourceEntry {
395                id,
396                description: describe_id(id),
397            })
398            .collect();
399        serde_json::to_writer_pretty(&mut buf, &entries).map_err(runtime_err)?;
400        buf.push(b'\n');
401    } else {
402        // Source identifiers are ASCII; char count == byte count. Use
403        // `chars().count()` anyway so a future non-ASCII label doesn't
404        // silently desync the padding width.
405        let width = ids
406            .iter()
407            .map(|id| id.chars().count())
408            .max()
409            .unwrap_or_default();
410        for id in &ids {
411            writeln!(buf, "{id:<width$}  {}", describe_id(id), width = width)
412                .map_err(runtime_err)?;
413        }
414    }
415    write_and_flush(&buf).map_err(runtime_err)
416}
417
418fn describe_id(id: &str) -> &'static str {
419    SourceKind::from_id(id).map_or("", SourceKind::describe)
420}
421
422#[derive(Serialize)]
423struct SourceEntry {
424    id: &'static str,
425    description: &'static str,
426}
427
428fn print_host_id(id: &HostId, format: Format) -> Result<()> {
429    let mut buf = Vec::new();
430    match format {
431        Format::Plain => writeln!(buf, "{id}")?,
432        Format::Summary => writeln!(buf, "{}", id.summary())?,
433        Format::Json => {
434            let out = HostIdJson {
435                uuid: id.as_uuid().to_string(),
436                source: id.source().as_str(),
437                in_container: id.in_container(),
438            };
439            serde_json::to_writer_pretty(&mut buf, &out)?;
440            buf.push(b'\n');
441        }
442    }
443    write_and_flush(&buf)?;
444    Ok(())
445}
446
447#[derive(Serialize)]
448struct HostIdJson {
449    uuid: String,
450    source: &'static str,
451    in_container: bool,
452}
453
454#[derive(Serialize, Clone, Copy)]
455#[serde(rename_all = "lowercase")]
456enum AuditStatus {
457    Found,
458    Skipped,
459    Errored,
460}
461
462#[derive(Serialize)]
463struct AuditEntry {
464    source: &'static str,
465    status: AuditStatus,
466    uuid: Option<String>,
467    error: Option<String>,
468    in_container: Option<bool>,
469}
470
471impl From<&ResolveOutcome> for AuditEntry {
472    fn from(o: &ResolveOutcome) -> Self {
473        let source = o.source().as_str();
474        match o {
475            ResolveOutcome::Found(id) => Self {
476                source,
477                status: AuditStatus::Found,
478                uuid: Some(id.as_uuid().to_string()),
479                error: None,
480                in_container: Some(id.in_container()),
481            },
482            ResolveOutcome::Skipped(_) => Self {
483                source,
484                status: AuditStatus::Skipped,
485                uuid: None,
486                error: None,
487                in_container: None,
488            },
489            ResolveOutcome::Errored(_, err) => Self {
490                source,
491                status: AuditStatus::Errored,
492                uuid: None,
493                error: Some(err.to_string()),
494                in_container: None,
495            },
496        }
497    }
498}
499
500fn available_source_ids() -> Vec<&'static str> {
501    let mut ids = vec![
502        source_ids::ENV_OVERRIDE,
503        source_ids::FILE_OVERRIDE,
504        source_ids::MACHINE_ID,
505        source_ids::DBUS_MACHINE_ID,
506        source_ids::DMI,
507        source_ids::IO_PLATFORM_UUID,
508        source_ids::WINDOWS_MACHINE_GUID,
509        source_ids::FREEBSD_HOSTID,
510        source_ids::KENV_SMBIOS,
511        source_ids::BSD_KERN_HOSTID,
512        source_ids::ILLUMOS_HOSTID,
513    ];
514    #[cfg(feature = "container")]
515    {
516        ids.push(source_ids::CONTAINER);
517        ids.push(source_ids::LXC);
518    }
519    #[cfg(feature = "network")]
520    {
521        ids.extend_from_slice(&[
522            source_ids::AWS_IMDS,
523            source_ids::GCP_METADATA,
524            source_ids::AZURE_IMDS,
525            source_ids::DIGITAL_OCEAN_METADATA,
526            source_ids::HETZNER_METADATA,
527            source_ids::OCI_METADATA,
528            source_ids::KUBERNETES_POD_UID,
529            source_ids::KUBERNETES_SERVICE_ACCOUNT,
530            source_ids::KUBERNETES_DOWNWARD_API,
531        ]);
532    }
533    ids.sort_unstable();
534    ids
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn wrap_arg_maps_every_variant_to_library_wrap() {
543        assert!(matches!(Wrap::from(WrapArg::V5), Wrap::UuidV5Namespaced));
544        assert!(matches!(Wrap::from(WrapArg::V3), Wrap::UuidV3Nil));
545        assert!(matches!(
546            Wrap::from(WrapArg::Passthrough),
547            Wrap::Passthrough
548        ));
549    }
550
551    #[test]
552    fn available_source_ids_is_sorted_and_deduplicated() {
553        let ids = available_source_ids();
554        assert!(
555            ids.windows(2).all(|w| w[0] < w[1]),
556            "ids must be strictly sorted"
557        );
558        assert!(ids.contains(&source_ids::MACHINE_ID));
559        assert!(ids.contains(&source_ids::DMI));
560    }
561
562    #[test]
563    #[cfg(feature = "container")]
564    fn available_source_ids_includes_container_when_feature_enabled() {
565        assert!(available_source_ids().contains(&source_ids::CONTAINER));
566        assert!(available_source_ids().contains(&source_ids::LXC));
567    }
568
569    #[test]
570    fn build_resolver_defaults_when_no_flags_given() {
571        let args = ResolveArgs::default();
572        let resolver = build_resolver(&args).expect("defaults build");
573        assert!(
574            resolver
575                .source_kinds()
576                .contains(&host_identity::SourceKind::EnvOverride),
577            "default chain must include env-override",
578        );
579    }
580
581    #[test]
582    fn build_resolver_uses_ids_chain_when_sources_set() {
583        let args = ResolveArgs {
584            sources: vec!["env-override".into(), "machine-id".into()],
585            ..Default::default()
586        };
587        let resolver = build_resolver(&args).expect("ids build");
588        let kinds = resolver.source_kinds();
589        assert_eq!(kinds.len(), 2);
590        assert_eq!(kinds[0], host_identity::SourceKind::EnvOverride);
591        assert_eq!(kinds[1], host_identity::SourceKind::MachineId);
592    }
593
594    #[test]
595    fn build_resolver_rejects_unknown_source_id() {
596        let args = ResolveArgs {
597            sources: vec!["definitely-not-a-source".into()],
598            ..Default::default()
599        };
600        let err = build_resolver(&args).expect_err("unknown id must fail");
601        assert!(
602            err.into_inner()
603                .to_string()
604                .contains("unknown source identifier")
605        );
606    }
607
608    #[test]
609    #[cfg(feature = "network")]
610    fn build_resolver_network_defaults_includes_cloud_sources() {
611        let args = ResolveArgs {
612            network: true,
613            ..Default::default()
614        };
615        let resolver = build_resolver(&args).expect("network defaults build");
616        assert!(
617            resolver
618                .source_kinds()
619                .contains(&host_identity::SourceKind::AwsImds),
620            "--network should add cloud sources to the default chain",
621        );
622    }
623
624    #[test]
625    #[cfg(feature = "network")]
626    fn build_resolver_network_plus_ids_resolves_cloud_identifiers() {
627        let args = ResolveArgs {
628            sources: vec!["aws-imds".into()],
629            network: true,
630            ..Default::default()
631        };
632        let resolver = build_resolver(&args).expect("network + ids build");
633        assert_eq!(
634            resolver.source_kinds(),
635            vec![host_identity::SourceKind::AwsImds]
636        );
637    }
638
639    #[test]
640    #[cfg(not(feature = "network"))]
641    fn build_resolver_network_without_feature_errors() {
642        let args = ResolveArgs {
643            network: true,
644            ..Default::default()
645        };
646        let err = build_resolver(&args).expect_err("--network must fail without feature");
647        assert!(err.into_inner().to_string().contains("`network` feature"));
648    }
649
650    #[test]
651    fn build_resolver_rejects_network_timeout_without_network() {
652        let args = ResolveArgs {
653            network_timeout_ms: Some(500),
654            ..Default::default()
655        };
656        let err = build_resolver(&args).expect_err("must reject timeout without --network");
657        assert!(
658            err.into_inner()
659                .to_string()
660                .contains("requires `--network`")
661        );
662    }
663
664    #[test]
665    fn map_unknown_formats_each_variant_distinctly() {
666        let cases = [
667            (
668                UnknownSourceError::Unknown("weird".to_owned()),
669                "unknown source identifier",
670            ),
671            (
672                UnknownSourceError::RequiresPath("file-override"),
673                "caller-supplied path",
674            ),
675            (
676                UnknownSourceError::RequiresTransport("aws-imds"),
677                "pass `--network`",
678            ),
679            (
680                UnknownSourceError::FeatureDisabled("aws-imds", "aws"),
681                "isn't enabled in this build",
682            ),
683        ];
684        for (err, expected_fragment) in cases {
685            let msg = map_unknown(err).to_string();
686            assert!(
687                msg.contains(expected_fragment),
688                "message {msg:?} missing fragment {expected_fragment:?}",
689            );
690        }
691    }
692
693    #[test]
694    fn file_override_from_env_value_handles_absent_empty_and_set() {
695        assert!(file_override_from_env_value(None).is_none());
696        assert!(file_override_from_env_value(Some(OsStr::new(""))).is_none());
697        let fo = file_override_from_env_value(Some(OsStr::new("/tmp/host-id")))
698            .expect("non-empty value must yield a FileOverride");
699        assert_eq!(fo.path(), std::path::Path::new("/tmp/host-id"));
700    }
701
702    #[test]
703    fn host_id_json_schema_is_stable() {
704        // Pins the `--format json` schema for `host-identity resolve`. Any field
705        // rename or case change breaks downstream script parsers; this
706        // snapshot catches that at test time.
707        let sample = HostIdJson {
708            uuid: "11111111-2222-3333-4444-555555555555".to_owned(),
709            source: "machine-id",
710            in_container: false,
711        };
712        let json = serde_json::to_value(&sample).unwrap();
713        let obj = json.as_object().unwrap();
714        assert_eq!(obj.len(), 3);
715        assert_eq!(obj["uuid"], "11111111-2222-3333-4444-555555555555");
716        assert_eq!(obj["source"], "machine-id");
717        assert_eq!(obj["in_container"], false);
718    }
719
720    #[test]
721    fn audit_entry_schema_is_stable_for_every_status() {
722        use host_identity::sources::FnSource;
723        let found_src = FnSource::new(SourceKind::custom("ok"), || Ok(Some("raw".into())));
724        let err_src = FnSource::new(SourceKind::custom("bad"), || {
725            Err(host_identity::Error::Platform {
726                source_kind: SourceKind::custom("bad"),
727                reason: "synthetic".into(),
728            })
729        });
730        let skip_src = FnSource::new(SourceKind::custom("skip"), || Ok(None));
731        let outcomes = Resolver::new()
732            .push(found_src)
733            .push(err_src)
734            .push(skip_src)
735            .resolve_all();
736        let entries: Vec<AuditEntry> = outcomes.iter().map(AuditEntry::from).collect();
737        let json = serde_json::to_value(&entries).unwrap();
738        let arr = json.as_array().unwrap();
739        assert_eq!(arr.len(), 3);
740        assert_eq!(arr[0]["status"], "found");
741        assert!(arr[0]["uuid"].is_string());
742        assert_eq!(arr[0]["error"], serde_json::Value::Null);
743        assert_eq!(arr[1]["status"], "errored");
744        assert!(arr[1]["error"].as_str().unwrap().contains("synthetic"));
745        assert_eq!(arr[1]["uuid"], serde_json::Value::Null);
746        assert_eq!(arr[2]["status"], "skipped");
747        // Every entry shares the same key set.
748        for entry in arr {
749            let keys: Vec<_> = entry.as_object().unwrap().keys().collect();
750            assert_eq!(keys.len(), 5);
751        }
752    }
753
754    #[test]
755    #[cfg(feature = "network")]
756    fn available_source_ids_includes_every_cloud_and_k8s_source() {
757        let ids = available_source_ids();
758        for id in [
759            source_ids::AWS_IMDS,
760            source_ids::GCP_METADATA,
761            source_ids::AZURE_IMDS,
762            source_ids::DIGITAL_OCEAN_METADATA,
763            source_ids::HETZNER_METADATA,
764            source_ids::OCI_METADATA,
765            source_ids::KUBERNETES_POD_UID,
766            source_ids::KUBERNETES_SERVICE_ACCOUNT,
767            source_ids::KUBERNETES_DOWNWARD_API,
768        ] {
769            assert!(ids.contains(&id), "missing {id}");
770        }
771    }
772}