Skip to main content

sofie_values/
log_fields.rs

1//! A sensitive value hands over only its mask, so it cannot reach a log unmasked.
2
3use std::borrow::Cow;
4
5use sofie_core::Instant;
6
7use crate::{Currency, Email, Money, Secret};
8
9#[derive(Clone, Debug, PartialEq)]
10pub enum LogValue<'a> {
11    Integer(i64),
12    Float(f64),
13    Bool(bool),
14    Text(Cow<'a, str>),
15}
16
17pub trait LogFields {
18    /// `None` as the member name logs the value whole.
19    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>));
20}
21
22/// Written by `#[derive(Aggregate)]` for `#[sensitive]` fields, which log only `***`.
23pub trait SensitiveFields {
24    const SENSITIVE_FIELDS: &'static [&'static str];
25}
26
27impl<T: SensitiveFields + ?Sized> SensitiveFields for &T {
28    const SENSITIVE_FIELDS: &'static [&'static str] = T::SENSITIVE_FIELDS;
29}
30
31impl<T: SensitiveFields + ?Sized> SensitiveFields for &mut T {
32    const SENSITIVE_FIELDS: &'static [&'static str] = T::SENSITIVE_FIELDS;
33}
34
35macro_rules! log_whole {
36    ($($value:ty),+ => |$self_:ident| $logged:expr) => {$(
37        impl LogFields for $value {
38            fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
39                let $self_ = self;
40                field(None, $logged);
41            }
42        }
43    )+};
44}
45
46log_whole!(i64 => |number| LogValue::Integer(*number));
47log_whole!(i16, i32, u16, u32 => |number| LogValue::Integer(i64::from(*number)));
48log_whole!(bool => |flag| LogValue::Bool(*flag));
49log_whole!(String => |text| LogValue::Text(Cow::Borrowed(text)));
50log_whole!(Currency => |code| LogValue::Text(Cow::Borrowed(code.as_str())));
51log_whole!(Instant => |instant| LogValue::Text(Cow::Owned(instant.to_string())));
52log_whole!(Email => |email| masked(email));
53
54fn masked(value: &impl std::fmt::Debug) -> LogValue<'static> {
55    LogValue::Text(Cow::Owned(format!("{value:?}")))
56}
57
58impl<T> LogFields for Secret<T> {
59    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
60        field(None, masked(self));
61    }
62}
63
64impl<V: crate::SurfacesFromInner<Inner: LogFields> + std::fmt::Debug> LogFields for V {
65    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
66        if <V as crate::SurfacesFromInner>::SENSITIVE {
67            field(None, masked(self));
68        } else {
69            self.inner().log_fields(field);
70        }
71    }
72}
73
74impl<T: LogFields> LogFields for Option<T> {
75    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
76        if let Some(value) = self {
77            value.log_fields(field);
78        }
79    }
80}
81
82impl LogFields for Money {
83    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
84        field(Some("minor"), LogValue::Integer(self.minor()));
85        field(
86            Some("currency"),
87            LogValue::Text(Cow::Borrowed(self.currency().as_str())),
88        );
89    }
90}
91
92#[cfg(feature = "time")]
93log_whole!(crate::Date => |day| LogValue::Text(Cow::Owned(day.to_string())));
94#[cfg(feature = "time")]
95log_whole!(crate::TimeZone => |zone| LogValue::Text(Cow::Borrowed(zone.name())));
96#[cfg(feature = "time")]
97log_whole!(crate::TimeOfDay => |time| LogValue::Text(Cow::Owned(time.to_string())));
98#[cfg(feature = "time")]
99log_whole!(crate::Weekday => |weekday| LogValue::Text(Cow::Borrowed(sofie_core::Variants::name(*weekday))));
100#[cfg(feature = "phone")]
101log_whole!(crate::PhoneNumber => |phone| masked(phone));
102#[cfg(feature = "person")]
103log_whole!(crate::PersonName => |name| masked(name));
104#[cfg(feature = "percentage")]
105log_whole!(crate::Percentage => |share| LogValue::Integer(share.basis_points()));
106#[cfg(feature = "web")]
107log_whole!(crate::Slug, crate::Locale, crate::Url => |text| LogValue::Text(Cow::Borrowed(text.as_str())));
108
109#[cfg(feature = "time")]
110impl LogFields for crate::LocalDateTime {
111    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
112        field(
113            Some("local"),
114            LogValue::Text(Cow::Owned(self.local().to_string())),
115        );
116        field(
117            Some("zone"),
118            LogValue::Text(Cow::Borrowed(self.zone().name())),
119        );
120    }
121}
122
123/// Two decimals, about a kilometre: a full-precision position is personal, an area is not.
124#[cfg(feature = "geo")]
125impl LogFields for crate::Coordinate {
126    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
127        let rounded = |degrees: f64| LogValue::Float((degrees * 100.0).round() / 100.0);
128        field(Some("latitude"), rounded(self.latitude()));
129        field(Some("longitude"), rounded(self.longitude()));
130    }
131}
132
133#[cfg(feature = "measure")]
134impl<U: crate::Unit> LogFields for crate::Measurement<U> {
135    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
136        field(
137            Some("quantity"),
138            LogValue::Text(Cow::Owned(self.quantity())),
139        );
140        field(
141            Some("unit"),
142            LogValue::Text(Cow::Borrowed(self.unit().name())),
143        );
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn logged(value: &impl LogFields) -> Vec<(Option<&'static str>, String)> {
152        let mut fields = Vec::new();
153        value.log_fields(&mut |member, value| fields.push((member, format!("{value:?}"))));
154        fields
155    }
156
157    #[test]
158    fn money_logs_its_minor_units_and_currency() {
159        let total = Money::new(14990, Currency::parse("EUR").unwrap());
160        assert_eq!(
161            logged(&total),
162            [
163                (Some("minor"), "Integer(14990)".to_owned()),
164                (Some("currency"), "Text(\"EUR\")".to_owned())
165            ]
166        );
167    }
168
169    #[cfg(feature = "geo")]
170    #[test]
171    fn a_coordinate_logs_rounded_to_about_a_kilometre() {
172        let home = crate::Coordinate::try_new(41.008_237, -28.978_359).unwrap();
173        assert_eq!(
174            logged(&home),
175            [
176                (Some("latitude"), "Float(41.01)".to_owned()),
177                (Some("longitude"), "Float(-28.98)".to_owned())
178            ]
179        );
180    }
181
182    struct Pin(String);
183
184    impl std::fmt::Debug for Pin {
185        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186            sofie_core::value::write_masked(formatter, "Pin")
187        }
188    }
189
190    impl sofie_core::Value for Pin {
191        type Inner = String;
192
193        fn inner(&self) -> &String {
194            &self.0
195        }
196
197        fn try_from_inner(inner: String) -> Result<Self, impl std::fmt::Display> {
198            Ok::<_, std::convert::Infallible>(Self(inner))
199        }
200    }
201
202    impl crate::SurfacesFromInner for Pin {
203        const SENSITIVE: bool = true;
204    }
205
206    #[test]
207    fn a_sensitive_derived_value_logs_its_mask_and_filters_whole() {
208        assert_eq!(
209            logged(&Pin("1234".to_owned())),
210            [(None, "Text(\"Pin(***)\")".to_owned())]
211        );
212        #[cfg(feature = "filter")]
213        assert_eq!(<Pin as crate::Filter>::OPERATORS, crate::EXACT);
214    }
215
216    #[test]
217    fn sensitive_values_log_only_their_mask() {
218        let email = Email::parse("jane@example.com").unwrap();
219        assert_eq!(
220            logged(&email),
221            [(None, "Text(\"j***@example.com\")".to_owned())]
222        );
223        let key = Secret::new("sk_live_1".to_owned());
224        assert_eq!(logged(&key), [(None, "Text(\"Secret(***)\")".to_owned())]);
225        assert_eq!(logged(&None::<Email>), []);
226    }
227}