1use 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 fn log_fields(&self, field: &mut dyn FnMut(Option<&'static str>, LogValue<'_>));
20}
21
22pub 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#[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#[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}