Skip to main content

rocketmq_observability/metrics/
release_identity.rs

1// Copyright 2023 The RocketMQ Rust Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Validated, low-cardinality release identity metrics.
16
17use std::ffi::OsStr;
18use std::net::SocketAddr;
19#[cfg(feature = "otel-metrics")]
20use std::sync::atomic::AtomicBool;
21#[cfg(feature = "otel-metrics")]
22use std::sync::atomic::Ordering;
23#[cfg(feature = "otel-metrics")]
24use std::sync::Arc;
25
26use thiserror::Error;
27
28const MAX_STABLE_LABEL_LEN: usize = 63;
29const GIT_COMMIT_LEN: usize = 40;
30const DEFAULT_LOCAL_COMMIT: &str = "0000000000000000000000000000000000000000";
31const DEFAULT_LOCAL_NONCE: &str = "local";
32
33/// Environment variable carrying the exact source revision.
34pub const RELEASE_COMMIT_ENV: &str = "ROCKETMQ_RELEASE_COMMIT";
35/// Environment variable distinguishing one rollout of a source revision.
36pub const RELEASE_NONCE_ENV: &str = "ROCKETMQ_RELEASE_NONCE";
37/// Environment variable enabling or disabling metrics.
38pub const METRICS_ENABLED_ENV: &str = "ROCKETMQ_METRICS_ENABLED";
39/// Environment variable selecting the metrics exporter.
40pub const METRICS_EXPORTER_ENV: &str = "ROCKETMQ_METRICS_EXPORTER";
41/// Environment variable selecting the Prometheus listener address.
42pub const METRICS_BIND_ADDR_ENV: &str = "ROCKETMQ_METRICS_BIND_ADDR";
43/// Environment variable selecting the Prometheus scrape path.
44pub const METRICS_PATH_ENV: &str = "ROCKETMQ_METRICS_PATH";
45
46/// A validated release identity suitable for metric labels.
47///
48/// All three values are deliberately bounded to keep release metadata stable
49/// and low-cardinality. The nonce identifies one rollout instance and must be
50/// supplied by the composition root; this type never generates process-global
51/// state.
52#[derive(Clone, Debug, PartialEq, Eq, Hash)]
53pub struct ValidatedReleaseIdentity {
54    service: String,
55    commit: String,
56    nonce: String,
57}
58
59impl ValidatedReleaseIdentity {
60    /// Validates and constructs a release identity.
61    ///
62    /// `commit` must be a full, lowercase 40-character Git object id. `service`
63    /// and `nonce` must contain 1 to 63 lowercase ASCII letters, digits, or
64    /// interior hyphens, and must begin and end with an ASCII letter or digit.
65    ///
66    /// # Errors
67    ///
68    /// Returns [`ReleaseIdentityError`] when any field is not in its canonical,
69    /// bounded form.
70    pub fn try_new(
71        service: impl Into<String>,
72        commit: impl Into<String>,
73        nonce: impl Into<String>,
74    ) -> Result<Self, ReleaseIdentityError> {
75        let service = service.into();
76        let commit = commit.into();
77        let nonce = nonce.into();
78
79        if !is_stable_label(&service) {
80            return Err(ReleaseIdentityError::InvalidService);
81        }
82        if !is_full_lowercase_commit(&commit) {
83            return Err(ReleaseIdentityError::InvalidCommit);
84        }
85        if !is_stable_label(&nonce) {
86            return Err(ReleaseIdentityError::InvalidNonce);
87        }
88
89        Ok(Self { service, commit, nonce })
90    }
91
92    /// Returns the stable service identifier.
93    pub fn service(&self) -> &str {
94        &self.service
95    }
96
97    /// Returns the full lowercase Git commit id.
98    pub fn commit(&self) -> &str {
99        &self.commit
100    }
101
102    /// Returns the rollout nonce.
103    pub fn nonce(&self) -> &str {
104        &self.nonce
105    }
106
107    #[cfg(feature = "otel-metrics")]
108    fn attributes(&self) -> [opentelemetry::KeyValue; 3] {
109        [
110            opentelemetry::KeyValue::new(crate::semantic::labels::SERVICE, self.service.clone()),
111            opentelemetry::KeyValue::new(crate::semantic::labels::RELEASE_COMMIT, self.commit.clone()),
112            opentelemetry::KeyValue::new(crate::semantic::labels::RELEASE_NONCE, self.nonce.clone()),
113        ]
114    }
115}
116
117/// Identifies the release identity field that failed validation.
118#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
119pub enum ReleaseIdentityError {
120    /// The service name is not a canonical stable label.
121    #[error("service must be 1..=63 lowercase ASCII letters, digits, or interior hyphens")]
122    InvalidService,
123    /// The commit is not a full lowercase Git object id.
124    #[error("commit must be exactly 40 lowercase hexadecimal characters")]
125    InvalidCommit,
126    /// The rollout nonce is not a canonical stable label.
127    #[error("nonce must be 1..=63 lowercase ASCII letters, digits, or interior hyphens")]
128    InvalidNonce,
129}
130
131/// Validated process-root inputs for release identity and metrics bootstrap.
132///
133/// Parsing is intentionally separated from environment access so service
134/// composition roots can validate deterministic values in tests and fail
135/// closed before advertising readiness.
136#[derive(Clone, Debug, PartialEq, Eq)]
137pub struct ProcessTelemetryConfig {
138    release_identity: ValidatedReleaseIdentity,
139    metrics_enabled: bool,
140    metrics_exporter: crate::MetricsExporter,
141    prometheus_bind_addr: SocketAddr,
142    prometheus_host: String,
143    prometheus_port: u16,
144    prometheus_path: String,
145}
146
147impl ProcessTelemetryConfig {
148    /// Parses process telemetry inputs without reading global process state.
149    ///
150    /// A missing runtime commit uses a real build-time
151    /// `ROCKETMQ_BUILD_COMMIT`. When no real build revision was embedded, the
152    /// all-zero sentinel remains available only as the local host-development
153    /// default. An explicit all-zero runtime commit is rejected, and an
154    /// explicit runtime commit must match a real embedded build commit. Other
155    /// missing values select `local` nonce, disabled metrics, loopback binding,
156    /// and `/metrics`. An explicitly enabled exporter must be non-disabled;
157    /// explicitly disabling metrics normalizes the exporter to `Disable`.
158    ///
159    /// # Errors
160    ///
161    /// Returns [`ProcessTelemetryConfigError`] for an invalid release identity,
162    /// boolean, exporter, bind address, path, or inconsistent enabled/exporter
163    /// selection.
164    #[allow(
165        clippy::too_many_arguments,
166        reason = "mirrors the seven explicit process-root inputs"
167    )]
168    pub fn try_from_values(
169        service: impl Into<String>,
170        commit: Option<&str>,
171        nonce: Option<&str>,
172        metrics_enabled: Option<&str>,
173        exporter: Option<&str>,
174        bind_addr: Option<&str>,
175        path: Option<&str>,
176    ) -> Result<Self, ProcessTelemetryConfigError> {
177        Self::try_from_observability_and_values(
178            service,
179            &crate::ObservabilityConfig::default(),
180            commit.map(OsStr::new),
181            nonce.map(OsStr::new),
182            metrics_enabled.map(OsStr::new),
183            exporter.map(OsStr::new),
184            bind_addr.map(OsStr::new),
185            path.map(OsStr::new),
186        )
187    }
188
189    /// Parses process telemetry inputs over an existing observability base.
190    ///
191    /// Missing metrics values preserve the corresponding base configuration.
192    ///
193    /// # Errors
194    ///
195    /// Returns [`ProcessTelemetryConfigError`] when an explicitly supplied
196    /// value is non-Unicode or otherwise invalid.
197    #[allow(
198        clippy::too_many_arguments,
199        reason = "mirrors the seven explicit process-root inputs"
200    )]
201    pub fn try_from_observability_and_values(
202        service: impl Into<String>,
203        base: &crate::ObservabilityConfig,
204        commit: Option<&OsStr>,
205        nonce: Option<&OsStr>,
206        metrics_enabled: Option<&OsStr>,
207        exporter: Option<&OsStr>,
208        bind_addr: Option<&OsStr>,
209        path: Option<&OsStr>,
210    ) -> Result<Self, ProcessTelemetryConfigError> {
211        let commit = os_value(RELEASE_COMMIT_ENV, commit)?;
212        let nonce = os_value(RELEASE_NONCE_ENV, nonce)?;
213        let metrics_enabled_value = os_value(METRICS_ENABLED_ENV, metrics_enabled)?;
214        let exporter = os_value(METRICS_EXPORTER_ENV, exporter)?;
215        let bind_addr = os_value(METRICS_BIND_ADDR_ENV, bind_addr)?;
216        let path = os_value(METRICS_PATH_ENV, path)?;
217
218        let commit = resolve_release_commit(commit, option_env!("ROCKETMQ_BUILD_COMMIT"))?;
219        let release_identity =
220            ValidatedReleaseIdentity::try_new(service, commit, nonce.unwrap_or(DEFAULT_LOCAL_NONCE))?;
221        let metrics_enabled = match metrics_enabled_value {
222            Some(value) => parse_metrics_enabled(Some(value))?,
223            None => base.metrics.enabled,
224        };
225        let mut metrics_exporter = match exporter {
226            Some(value) => parse_metrics_exporter(Some(value))?,
227            None => base.metrics.exporter,
228        };
229        if metrics_enabled_value == Some("false") {
230            metrics_exporter = crate::MetricsExporter::Disable;
231        }
232        validate_metrics_selection(metrics_enabled, metrics_exporter)?;
233
234        let bind_addr = match bind_addr {
235            Some(value) => value
236                .parse::<SocketAddr>()
237                .map_err(|_| ProcessTelemetryConfigError::InvalidPrometheusBindAddress)?,
238            None => SocketAddr::new(
239                crate::resolver::parse_prometheus_host(&base.prometheus.host)
240                    .ok_or(ProcessTelemetryConfigError::InvalidPrometheusBindAddress)?,
241                base.prometheus.port,
242            ),
243        };
244        if bind_addr.port() == 0 {
245            return Err(ProcessTelemetryConfigError::InvalidPrometheusBindAddress);
246        }
247
248        let prometheus_path = path.unwrap_or(&base.prometheus.path);
249        if !crate::resolver::is_canonical_prometheus_path(prometheus_path) {
250            return Err(ProcessTelemetryConfigError::InvalidPrometheusPath);
251        }
252
253        Ok(Self {
254            release_identity,
255            metrics_enabled,
256            metrics_exporter,
257            prometheus_bind_addr: bind_addr,
258            prometheus_host: match bind_addr.ip() {
259                std::net::IpAddr::V4(address) => address.to_string(),
260                std::net::IpAddr::V6(address) => format!("[{address}]"),
261            },
262            prometheus_port: bind_addr.port(),
263            prometheus_path: prometheus_path.to_owned(),
264        })
265    }
266
267    /// Reads and validates the supported process environment variables.
268    ///
269    /// # Errors
270    ///
271    /// Returns [`ProcessTelemetryConfigError`] when an environment value is not
272    /// Unicode or fails the same validation as [`Self::try_from_values`].
273    pub fn from_process_env(service: impl Into<String>) -> Result<Self, ProcessTelemetryConfigError> {
274        let commit = read_process_env(RELEASE_COMMIT_ENV)?;
275        let nonce = read_process_env(RELEASE_NONCE_ENV)?;
276        let metrics_enabled = read_process_env(METRICS_ENABLED_ENV)?;
277        let exporter = read_process_env(METRICS_EXPORTER_ENV)?;
278        let bind_addr = read_process_env(METRICS_BIND_ADDR_ENV)?;
279        let path = read_process_env(METRICS_PATH_ENV)?;
280
281        Self::try_from_values(
282            service,
283            commit.as_deref(),
284            nonce.as_deref(),
285            metrics_enabled.as_deref(),
286            exporter.as_deref(),
287            bind_addr.as_deref(),
288            path.as_deref(),
289        )
290    }
291
292    /// Returns the validated release identity.
293    pub fn release_identity(&self) -> &ValidatedReleaseIdentity {
294        &self.release_identity
295    }
296
297    /// Returns whether metrics are enabled for this process.
298    pub const fn metrics_enabled(&self) -> bool {
299        self.metrics_enabled
300    }
301
302    /// Returns the selected metrics exporter.
303    pub const fn metrics_exporter(&self) -> crate::MetricsExporter {
304        self.metrics_exporter
305    }
306
307    /// Returns the Prometheus listener that will actually be bound by this process.
308    pub fn prometheus_listener_addr(&self) -> Option<SocketAddr> {
309        (self.metrics_enabled && self.metrics_exporter == crate::MetricsExporter::Prometheus)
310            .then_some(self.prometheus_bind_addr)
311    }
312
313    /// Returns the validated Prometheus listener host.
314    pub fn prometheus_host(&self) -> &str {
315        &self.prometheus_host
316    }
317
318    /// Returns the validated Prometheus listener port.
319    pub const fn prometheus_port(&self) -> u16 {
320        self.prometheus_port
321    }
322
323    /// Returns the canonical Prometheus scrape path.
324    pub fn prometheus_path(&self) -> &str {
325        &self.prometheus_path
326    }
327
328    /// Applies the validated process-root values to observability configuration.
329    ///
330    /// Enabling metrics necessarily enables the observability runtime. Disabling
331    /// metrics does not disable independently configured traces or logs.
332    pub fn apply_to(&self, config: &mut crate::ObservabilityConfig) {
333        config.service_name.clone_from(&self.release_identity.service);
334        config.metrics.enabled = self.metrics_enabled;
335        config.metrics.exporter = self.metrics_exporter;
336        config.prometheus.host.clone_from(&self.prometheus_host);
337        config.prometheus.port = self.prometheus_port;
338        config.prometheus.path.clone_from(&self.prometheus_path);
339        config.enabled = config.metrics.enabled || config.traces.enabled || config.logs.enabled;
340    }
341}
342
343fn os_value<'a>(name: &'static str, value: Option<&'a OsStr>) -> Result<Option<&'a str>, ProcessTelemetryConfigError> {
344    value
345        .map(|value| {
346            value
347                .to_str()
348                .ok_or(ProcessTelemetryConfigError::NonUnicodeEnvironment(name))
349        })
350        .transpose()
351}
352
353/// Failure while validating process telemetry inputs.
354#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)]
355pub enum ProcessTelemetryConfigError {
356    /// A release identity field is invalid.
357    #[error(transparent)]
358    ReleaseIdentity(#[from] ReleaseIdentityError),
359    /// The runtime commit is the all-zero null identity.
360    #[error("ROCKETMQ_RELEASE_COMMIT must not be the all-zero null commit")]
361    NullReleaseCommit,
362    /// The runtime commit disagrees with the commit embedded in the binary.
363    #[error("ROCKETMQ_RELEASE_COMMIT must match the embedded ROCKETMQ_BUILD_COMMIT")]
364    ReleaseCommitMismatch,
365    /// The metrics-enabled value is not the canonical `true` or `false`.
366    #[error("ROCKETMQ_METRICS_ENABLED must be either true or false")]
367    InvalidMetricsEnabled,
368    /// The metrics exporter name is unsupported.
369    #[error("ROCKETMQ_METRICS_EXPORTER must be disable, otlp_grpc, prometheus, or log")]
370    InvalidMetricsExporter,
371    /// The enabled flag and exporter selection disagree.
372    #[error("enabled metrics require a non-disabled exporter and disabled metrics require the disable exporter")]
373    InconsistentMetricsSelection,
374    /// The Prometheus address is not a nonzero IP socket address.
375    #[error("ROCKETMQ_METRICS_BIND_ADDR must be an IP address and nonzero port")]
376    InvalidPrometheusBindAddress,
377    /// The Prometheus path is not canonical and bounded.
378    #[error("ROCKETMQ_METRICS_PATH must be a canonical absolute path of at most 128 ASCII characters")]
379    InvalidPrometheusPath,
380    /// An explicitly supplied environment value is not Unicode.
381    #[error("{0} must contain valid Unicode")]
382    NonUnicodeEnvironment(&'static str),
383}
384
385fn parse_metrics_enabled(value: Option<&str>) -> Result<bool, ProcessTelemetryConfigError> {
386    match value {
387        None | Some("false") => Ok(false),
388        Some("true") => Ok(true),
389        Some(_) => Err(ProcessTelemetryConfigError::InvalidMetricsEnabled),
390    }
391}
392
393fn resolve_release_commit<'a>(
394    runtime_commit: Option<&'a str>,
395    build_commit: Option<&'a str>,
396) -> Result<&'a str, ProcessTelemetryConfigError> {
397    if let Some(runtime_commit) = runtime_commit {
398        if runtime_commit == DEFAULT_LOCAL_COMMIT {
399            return Err(ProcessTelemetryConfigError::NullReleaseCommit);
400        }
401        if !is_full_lowercase_commit(runtime_commit) {
402            return Err(ReleaseIdentityError::InvalidCommit.into());
403        }
404    }
405
406    let embedded_commit = build_commit.filter(|commit| is_real_commit(commit));
407    match (runtime_commit, embedded_commit) {
408        (Some(runtime_commit), Some(embedded_commit)) if runtime_commit != embedded_commit => {
409            Err(ProcessTelemetryConfigError::ReleaseCommitMismatch)
410        }
411        (Some(runtime_commit), _) => Ok(runtime_commit),
412        (None, Some(embedded_commit)) => Ok(embedded_commit),
413        (None, None) => Ok(DEFAULT_LOCAL_COMMIT),
414    }
415}
416
417fn parse_metrics_exporter(value: Option<&str>) -> Result<crate::MetricsExporter, ProcessTelemetryConfigError> {
418    match value {
419        None | Some("disable") => Ok(crate::MetricsExporter::Disable),
420        Some("otlp_grpc") => Ok(crate::MetricsExporter::OtlpGrpc),
421        Some("prometheus") => Ok(crate::MetricsExporter::Prometheus),
422        Some("log") => Ok(crate::MetricsExporter::Log),
423        Some(_) => Err(ProcessTelemetryConfigError::InvalidMetricsExporter),
424    }
425}
426
427fn validate_metrics_selection(
428    enabled: bool,
429    exporter: crate::MetricsExporter,
430) -> Result<(), ProcessTelemetryConfigError> {
431    if enabled == exporter.is_enabled() {
432        Ok(())
433    } else {
434        Err(ProcessTelemetryConfigError::InconsistentMetricsSelection)
435    }
436}
437
438fn read_process_env(name: &'static str) -> Result<Option<String>, ProcessTelemetryConfigError> {
439    crate::resolver::read_telemetry_environment_value(name)
440        .map(|value| {
441            value
442                .into_string()
443                .map_err(|_| ProcessTelemetryConfigError::NonUnicodeEnvironment(name))
444        })
445        .transpose()
446}
447
448fn is_full_lowercase_commit(value: &str) -> bool {
449    value.len() == GIT_COMMIT_LEN
450        && value
451            .as_bytes()
452            .iter()
453            .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
454}
455
456fn is_real_commit(value: &str) -> bool {
457    value != DEFAULT_LOCAL_COMMIT && is_full_lowercase_commit(value)
458}
459
460fn is_stable_label(value: &str) -> bool {
461    if value.is_empty() || value.len() > MAX_STABLE_LABEL_LEN {
462        return false;
463    }
464
465    let bytes = value.as_bytes();
466    bytes.first().is_some_and(u8::is_ascii_alphanumeric)
467        && bytes.last().is_some_and(u8::is_ascii_alphanumeric)
468        && bytes
469            .iter()
470            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
471}
472
473/// Outcome of registering a release identity metric.
474#[cfg(feature = "otel-metrics")]
475#[derive(Clone, Copy, Debug, PartialEq, Eq)]
476pub enum ReleaseIdentityRegistrationStatus {
477    /// This instance registered its identity on the supplied meter.
478    Registered,
479    /// This instance, or one of its clones, had already registered.
480    AlreadyRegistered,
481}
482
483/// Instance-scoped registration for the release identity gauge.
484///
485/// Clones share only this registration's local state. No global meter provider
486/// or process-wide registration cell is read or mutated.
487#[cfg(feature = "otel-metrics")]
488#[derive(Clone, Debug)]
489pub struct ReleaseIdentityRegistration {
490    inner: Arc<ReleaseIdentityRegistrationInner>,
491}
492
493#[cfg(feature = "otel-metrics")]
494#[derive(Debug)]
495struct ReleaseIdentityRegistrationInner {
496    identity: ValidatedReleaseIdentity,
497    registered: AtomicBool,
498    instrument: parking_lot::Mutex<Option<opentelemetry::metrics::Gauge<u64>>>,
499}
500
501#[cfg(feature = "otel-metrics")]
502impl ReleaseIdentityRegistration {
503    /// Creates an unregistered, instance-scoped release identity.
504    pub fn new(identity: ValidatedReleaseIdentity) -> Self {
505        Self {
506            inner: Arc::new(ReleaseIdentityRegistrationInner {
507                identity,
508                registered: AtomicBool::new(false),
509                instrument: parking_lot::Mutex::new(None),
510            }),
511        }
512    }
513
514    /// Returns this registration's validated identity.
515    pub fn identity(&self) -> &ValidatedReleaseIdentity {
516        &self.inner.identity
517    }
518
519    /// Returns whether this instance, or one of its clones, registered.
520    pub fn is_registered(&self) -> bool {
521        self.inner.registered.load(Ordering::Acquire)
522    }
523
524    /// Registers exactly one release identity series on an explicit meter.
525    ///
526    /// A second call on this instance or any clone is idempotent. Separately
527    /// constructed instances retain independent registration state.
528    pub(crate) fn register(&self, meter: &opentelemetry::metrics::Meter) -> ReleaseIdentityRegistrationStatus {
529        if self
530            .inner
531            .registered
532            .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
533            .is_err()
534        {
535            return ReleaseIdentityRegistrationStatus::AlreadyRegistered;
536        }
537
538        let gauge = meter
539            .u64_gauge(crate::semantic::metrics::RELEASE_INFO)
540            .with_description("RocketMQ service build and rollout identity")
541            .with_unit("1")
542            .build();
543        gauge.record(1, &self.inner.identity.attributes());
544        *self.inner.instrument.lock() = Some(gauge);
545
546        ReleaseIdentityRegistrationStatus::Registered
547    }
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn stable_label_accepts_only_canonical_bounded_values() {
556        assert!(is_stable_label("a"));
557        assert!(is_stable_label("rocketmq-broker-01"));
558        assert!(is_stable_label(&"a".repeat(MAX_STABLE_LABEL_LEN)));
559
560        assert!(!is_stable_label(""));
561        assert!(!is_stable_label("-broker"));
562        assert!(!is_stable_label("broker-"));
563        assert!(!is_stable_label("Rocketmq-broker"));
564        assert!(!is_stable_label("rocketmq_broker"));
565        assert!(!is_stable_label(&"a".repeat(MAX_STABLE_LABEL_LEN + 1)));
566    }
567
568    #[test]
569    fn commit_requires_full_lowercase_hex() {
570        assert!(is_full_lowercase_commit("0123456789abcdef0123456789abcdef01234567"));
571        assert!(!is_full_lowercase_commit("0123456789ABCDEF0123456789ABCDEF01234567"));
572        assert!(!is_full_lowercase_commit("g123456789abcdef0123456789abcdef01234567"));
573        assert!(!is_full_lowercase_commit("0123456789abcdef0123456789abcdef0123456"));
574    }
575
576    #[test]
577    fn local_host_build_without_real_embedded_commit_keeps_local_default() {
578        assert_eq!(resolve_release_commit(None, None), Ok(DEFAULT_LOCAL_COMMIT));
579        assert_eq!(
580            resolve_release_commit(None, Some(DEFAULT_LOCAL_COMMIT)),
581            Ok(DEFAULT_LOCAL_COMMIT)
582        );
583        assert_eq!(
584            resolve_release_commit(None, Some("not-a-build-commit")),
585            Ok(DEFAULT_LOCAL_COMMIT)
586        );
587    }
588
589    #[test]
590    fn real_embedded_commit_binds_runtime_identity() {
591        const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
592        const OTHER_COMMIT: &str = "89abcdef0123456789abcdef0123456789abcdef";
593
594        assert_eq!(resolve_release_commit(None, Some(BUILD_COMMIT)), Ok(BUILD_COMMIT));
595        assert_eq!(
596            resolve_release_commit(Some(BUILD_COMMIT), Some(BUILD_COMMIT)),
597            Ok(BUILD_COMMIT)
598        );
599        assert_eq!(
600            resolve_release_commit(Some(OTHER_COMMIT), Some(BUILD_COMMIT)),
601            Err(ProcessTelemetryConfigError::ReleaseCommitMismatch)
602        );
603    }
604
605    #[test]
606    fn explicit_null_runtime_commit_fails_closed() {
607        const BUILD_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567";
608
609        assert_eq!(
610            resolve_release_commit(Some(DEFAULT_LOCAL_COMMIT), None),
611            Err(ProcessTelemetryConfigError::NullReleaseCommit)
612        );
613        assert_eq!(
614            resolve_release_commit(Some(DEFAULT_LOCAL_COMMIT), Some(BUILD_COMMIT)),
615            Err(ProcessTelemetryConfigError::NullReleaseCommit)
616        );
617    }
618}