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 => |text| LogValue::Text(Cow::Borrowed(text.as_str())));
108#[cfg(feature = "web")]
109log_whole!(crate::Url => |url| LogValue::Text(Cow::Borrowed(origin(url.as_str()))));
110
111/// Scheme and host with its port; a path or query can carry a secret such as `?token=`.
112#[cfg(feature = "web")]
113fn origin(url: &str) -> &str {
114    let host_start = url.find("://").map_or(0, |separator| separator + 3);
115    let host_end = url[host_start..]
116        .find(['/', '?', '#'])
117        .map_or(url.len(), |end| host_start + end);
118    &url[..host_end]
119}
120
121#[cfg(feature = "time")]
122impl LogFields for crate::LocalDateTime {
123    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
124        field(
125            Some("local"),
126            LogValue::Text(Cow::Owned(self.local().to_string())),
127        );
128        field(
129            Some("zone"),
130            LogValue::Text(Cow::Borrowed(self.zone().name())),
131        );
132    }
133}
134
135/// Two decimals, about a kilometre: a full-precision position is personal, an area is not.
136#[cfg(feature = "geo")]
137impl LogFields for crate::Coordinate {
138    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
139        let rounded = |degrees: f64| LogValue::Float((degrees * 100.0).round() / 100.0);
140        field(Some("latitude"), rounded(self.latitude()));
141        field(Some("longitude"), rounded(self.longitude()));
142    }
143}
144
145#[cfg(feature = "measure")]
146impl<U: crate::Unit> LogFields for crate::Measurement<U> {
147    fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>)) {
148        field(
149            Some("quantity"),
150            LogValue::Text(Cow::Owned(self.quantity())),
151        );
152        field(
153            Some("unit"),
154            LogValue::Text(Cow::Borrowed(self.unit().name())),
155        );
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    fn logged(value: &impl LogFields) -> Vec<(Option<&'static str>, String)> {
164        let mut fields = Vec::new();
165        value.log_fields(&mut |member, value| fields.push((member, format!("{value:?}"))));
166        fields
167    }
168
169    #[test]
170    fn money_logs_its_minor_units_and_currency() {
171        let total = Money::new(14990, Currency::parse("EUR").unwrap());
172        assert_eq!(
173            logged(&total),
174            [
175                (Some("minor"), "Integer(14990)".to_owned()),
176                (Some("currency"), "Text(\"EUR\")".to_owned())
177            ]
178        );
179    }
180
181    #[cfg(feature = "geo")]
182    #[test]
183    fn a_coordinate_logs_rounded_to_about_a_kilometre() {
184        let home = crate::Coordinate::try_new(41.008_237, -28.978_359).unwrap();
185        assert_eq!(
186            logged(&home),
187            [
188                (Some("latitude"), "Float(41.01)".to_owned()),
189                (Some("longitude"), "Float(-28.98)".to_owned())
190            ]
191        );
192    }
193
194    struct Pin(String);
195
196    impl std::fmt::Debug for Pin {
197        fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198            sofie_core::value::write_masked(formatter, "Pin")
199        }
200    }
201
202    impl sofie_core::Value for Pin {
203        type Inner = String;
204
205        fn inner(&self) -> &String {
206            &self.0
207        }
208
209        fn try_from_inner(inner: String) -> Result<Self, impl std::fmt::Display> {
210            Ok::<_, std::convert::Infallible>(Self(inner))
211        }
212    }
213
214    impl crate::SurfacesFromInner for Pin {
215        const SENSITIVE: bool = true;
216    }
217
218    #[test]
219    fn a_sensitive_derived_value_logs_its_mask_and_filters_whole() {
220        assert_eq!(
221            logged(&Pin("1234".to_owned())),
222            [(None, "Text(\"Pin(***)\")".to_owned())]
223        );
224        #[cfg(feature = "filter")]
225        assert_eq!(<Pin as crate::Filter>::OPERATORS, crate::EXACT);
226    }
227
228    #[cfg(feature = "web")]
229    #[test]
230    fn a_url_logs_only_its_scheme_and_host() {
231        for (address, origin) in [
232            (
233                "https://Example.com:8443/reset/abc?token=s3cret#x",
234                "https://example.com:8443",
235            ),
236            ("http://example.com?token=s3cret", "http://example.com"),
237            ("https://example.com", "https://example.com"),
238        ] {
239            let url = crate::Url::parse(address).unwrap();
240            assert_eq!(logged(&url), [(None, format!("Text({origin:?})"))]);
241        }
242    }
243
244    #[test]
245    fn sensitive_values_log_only_their_mask() {
246        let email = Email::parse("jane@example.com").unwrap();
247        assert_eq!(
248            logged(&email),
249            [(None, "Text(\"j***@example.com\")".to_owned())]
250        );
251        let key = Secret::new("sk_live_1".to_owned());
252        assert_eq!(logged(&key), [(None, "Text(\"Secret(***)\")".to_owned())]);
253        assert_eq!(logged(&None::<Email>), []);
254    }
255}