Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 1 | // Copyright 2020, The Android Open Source Project |
| 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 | |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 15 | //! This module implements the Android Protected Confirmation (APC) service as defined |
| 16 | //! in the android.security.apc AIDL spec. |
| 17 | |
| 18 | use std::{ |
| 19 | cmp::PartialEq, |
| 20 | collections::HashMap, |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 21 | sync::{mpsc::Sender, Arc, Mutex}, |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 22 | }; |
| 23 | |
Janis Danisevskis | ea03cff | 2021-12-16 08:10:17 -0800 | [diff] [blame] | 24 | use crate::error::anyhow_error_to_cstring; |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 25 | use crate::ks_err; |
Hasini Gunasinghe | 5a893e8 | 2021-05-05 14:32:32 +0000 | [diff] [blame] | 26 | use crate::utils::{compat_2_response_code, ui_opts_2_compat, watchdog as wd}; |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 27 | use android_security_apc::aidl::android::security::apc::{ |
| 28 | IConfirmationCallback::IConfirmationCallback, |
| 29 | IProtectedConfirmation::{BnProtectedConfirmation, IProtectedConfirmation}, |
| 30 | ResponseCode::ResponseCode, |
| 31 | }; |
| 32 | use android_security_apc::binder::{ |
Andrew Walbran | de45c8b | 2021-04-13 14:42:38 +0000 | [diff] [blame] | 33 | BinderFeatures, ExceptionCode, Interface, Result as BinderResult, SpIBinder, |
| 34 | Status as BinderStatus, Strong, ThreadState, |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 35 | }; |
| 36 | use anyhow::{Context, Result}; |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 37 | use keystore2_apc_compat::ApcHal; |
| 38 | use keystore2_selinux as selinux; |
| 39 | use std::time::{Duration, Instant}; |
| 40 | |
| 41 | /// This is the main APC error type, it wraps binder exceptions and the |
| 42 | /// APC ResponseCode. |
Chris Wailes | 263de9f | 2022-08-11 15:00:51 -0700 | [diff] [blame] | 43 | #[derive(Debug, thiserror::Error, PartialEq, Eq)] |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 44 | pub enum Error { |
| 45 | /// Wraps an Android Protected Confirmation (APC) response code as defined by the |
| 46 | /// android.security.apc AIDL interface specification. |
| 47 | #[error("Error::Rc({0:?})")] |
| 48 | Rc(ResponseCode), |
| 49 | /// Wraps a Binder exception code other than a service specific exception. |
| 50 | #[error("Binder exception code {0:?}, {1:?}")] |
| 51 | Binder(ExceptionCode, i32), |
| 52 | } |
| 53 | |
| 54 | impl Error { |
| 55 | /// Short hand for `Error::Rc(ResponseCode::SYSTEM_ERROR)` |
| 56 | pub fn sys() -> Self { |
| 57 | Error::Rc(ResponseCode::SYSTEM_ERROR) |
| 58 | } |
| 59 | |
| 60 | /// Short hand for `Error::Rc(ResponseCode::OPERATION_PENDING)` |
| 61 | pub fn pending() -> Self { |
| 62 | Error::Rc(ResponseCode::OPERATION_PENDING) |
| 63 | } |
| 64 | |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 65 | /// Short hand for `Error::Rc(ResponseCode::IGNORED)` |
| 66 | pub fn ignored() -> Self { |
| 67 | Error::Rc(ResponseCode::IGNORED) |
| 68 | } |
| 69 | |
| 70 | /// Short hand for `Error::Rc(ResponseCode::UNIMPLEMENTED)` |
| 71 | pub fn unimplemented() -> Self { |
| 72 | Error::Rc(ResponseCode::UNIMPLEMENTED) |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | /// This function should be used by confirmation service calls to translate error conditions |
| 77 | /// into service specific exceptions. |
| 78 | /// |
| 79 | /// All error conditions get logged by this function. |
| 80 | /// |
| 81 | /// `Error::Rc(x)` variants get mapped onto a service specific error code of `x`. |
| 82 | /// `selinux::Error::perm()` is mapped on `ResponseCode::PERMISSION_DENIED`. |
| 83 | /// |
| 84 | /// All non `Error` error conditions get mapped onto ResponseCode::SYSTEM_ERROR`. |
| 85 | /// |
| 86 | /// `handle_ok` will be called if `result` is `Ok(value)` where `value` will be passed |
| 87 | /// as argument to `handle_ok`. `handle_ok` must generate a `BinderResult<T>`, but it |
| 88 | /// typically returns Ok(value). |
| 89 | pub fn map_or_log_err<T, U, F>(result: Result<U>, handle_ok: F) -> BinderResult<T> |
| 90 | where |
| 91 | F: FnOnce(U) -> BinderResult<T>, |
| 92 | { |
| 93 | result.map_or_else( |
| 94 | |e| { |
| 95 | log::error!("{:#?}", e); |
| 96 | let root_cause = e.root_cause(); |
| 97 | let rc = match root_cause.downcast_ref::<Error>() { |
| 98 | Some(Error::Rc(rcode)) => rcode.0, |
| 99 | Some(Error::Binder(_, _)) => ResponseCode::SYSTEM_ERROR.0, |
| 100 | None => match root_cause.downcast_ref::<selinux::Error>() { |
| 101 | Some(selinux::Error::PermissionDenied) => ResponseCode::PERMISSION_DENIED.0, |
| 102 | _ => ResponseCode::SYSTEM_ERROR.0, |
| 103 | }, |
| 104 | }; |
Janis Danisevskis | ea03cff | 2021-12-16 08:10:17 -0800 | [diff] [blame] | 105 | Err(BinderStatus::new_service_specific_error( |
| 106 | rc, |
| 107 | anyhow_error_to_cstring(&e).as_deref(), |
| 108 | )) |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 109 | }, |
| 110 | handle_ok, |
| 111 | ) |
| 112 | } |
| 113 | |
| 114 | /// Rate info records how many failed attempts a client has made to display a protected |
| 115 | /// confirmation prompt. Clients are penalized for attempts that get declined by the user |
| 116 | /// or attempts that get aborted by the client itself. |
| 117 | /// |
| 118 | /// After the third failed attempt the client has to cool down for 30 seconds before it |
| 119 | /// it can retry. After the sixth failed attempt, the time doubles with every failed attempt |
| 120 | /// until it goes into saturation at 24h. |
| 121 | /// |
| 122 | /// A successful user prompt resets the counter. |
| 123 | #[derive(Debug, Clone)] |
| 124 | struct RateInfo { |
| 125 | counter: u32, |
| 126 | timestamp: Instant, |
| 127 | } |
| 128 | |
| 129 | impl RateInfo { |
| 130 | const ONE_DAY: Duration = Duration::from_secs(60u64 * 60u64 * 24u64); |
| 131 | |
| 132 | fn get_remaining_back_off(&self) -> Option<Duration> { |
| 133 | let back_off = match self.counter { |
| 134 | // The first three attempts come without penalty. |
| 135 | 0..=2 => return None, |
| 136 | // The next three attempts are are penalized with 30 seconds back off time. |
| 137 | 3..=5 => Duration::from_secs(30), |
| 138 | // After that we double the back off time the with every additional attempt |
| 139 | // until we reach 1024m (~17h). |
| 140 | 6..=16 => Duration::from_secs(60) |
| 141 | .checked_mul(1u32 << (self.counter - 6)) |
| 142 | .unwrap_or(Self::ONE_DAY), |
| 143 | // After that we cap of at 24h between attempts. |
| 144 | _ => Self::ONE_DAY, |
| 145 | }; |
| 146 | let elapsed = self.timestamp.elapsed(); |
| 147 | // This does exactly what we want. |
| 148 | // `back_off - elapsed` is the remaining back off duration or None if elapsed is larger |
| 149 | // than back_off. Also, this operation cannot overflow as long as elapsed is less than |
| 150 | // back_off, which is all that we care about. |
| 151 | back_off.checked_sub(elapsed) |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | impl Default for RateInfo { |
| 156 | fn default() -> Self { |
| 157 | Self { counter: 0u32, timestamp: Instant::now() } |
| 158 | } |
| 159 | } |
| 160 | |
| 161 | /// The APC session state represents the state of an APC session. |
| 162 | struct ApcSessionState { |
| 163 | /// A reference to the APC HAL backend. |
| 164 | hal: Arc<ApcHal>, |
| 165 | /// The client callback object. |
| 166 | cb: SpIBinder, |
| 167 | /// The uid of the owner of this APC session. |
| 168 | uid: u32, |
| 169 | /// The time when this session was started. |
| 170 | start: Instant, |
| 171 | /// This is set when the client calls abort. |
| 172 | /// This is used by the rate limiting logic to determine |
| 173 | /// if the client needs to be penalized for this attempt. |
| 174 | client_aborted: bool, |
| 175 | } |
| 176 | |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 177 | struct ApcState { |
| 178 | session: Option<ApcSessionState>, |
| 179 | rate_limiting: HashMap<u32, RateInfo>, |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 180 | confirmation_token_sender: Sender<Vec<u8>>, |
| 181 | } |
| 182 | |
| 183 | impl ApcState { |
| 184 | fn new(confirmation_token_sender: Sender<Vec<u8>>) -> Self { |
| 185 | Self { session: None, rate_limiting: Default::default(), confirmation_token_sender } |
| 186 | } |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 187 | } |
| 188 | |
| 189 | /// Implementation of the APC service. |
| 190 | pub struct ApcManager { |
| 191 | state: Arc<Mutex<ApcState>>, |
| 192 | } |
| 193 | |
| 194 | impl Interface for ApcManager {} |
| 195 | |
| 196 | impl ApcManager { |
| 197 | /// Create a new instance of the Android Protected Confirmation service. |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 198 | pub fn new_native_binder( |
| 199 | confirmation_token_sender: Sender<Vec<u8>>, |
Stephen Crane | 221bbb5 | 2020-12-16 15:52:10 -0800 | [diff] [blame] | 200 | ) -> Result<Strong<dyn IProtectedConfirmation>> { |
Andrew Walbran | de45c8b | 2021-04-13 14:42:38 +0000 | [diff] [blame] | 201 | Ok(BnProtectedConfirmation::new_binder( |
| 202 | Self { state: Arc::new(Mutex::new(ApcState::new(confirmation_token_sender))) }, |
| 203 | BinderFeatures { set_requesting_sid: true, ..BinderFeatures::default() }, |
| 204 | )) |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 205 | } |
| 206 | |
| 207 | fn result( |
| 208 | state: Arc<Mutex<ApcState>>, |
| 209 | rc: u32, |
| 210 | data_confirmed: Option<&[u8]>, |
| 211 | confirmation_token: Option<&[u8]>, |
| 212 | ) { |
| 213 | let mut state = state.lock().unwrap(); |
| 214 | let (callback, uid, start, client_aborted) = match state.session.take() { |
| 215 | None => return, // Nothing to do |
| 216 | Some(ApcSessionState { cb: callback, uid, start, client_aborted, .. }) => { |
| 217 | (callback, uid, start, client_aborted) |
| 218 | } |
| 219 | }; |
| 220 | |
| 221 | let rc = compat_2_response_code(rc); |
| 222 | |
| 223 | // Update rate limiting information. |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 224 | match (rc, client_aborted, confirmation_token) { |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 225 | // If the user confirmed the dialog. |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 226 | (ResponseCode::OK, _, Some(confirmation_token)) => { |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 227 | // Reset counter. |
| 228 | state.rate_limiting.remove(&uid); |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 229 | // Send confirmation token to the enforcement module. |
| 230 | if let Err(e) = state.confirmation_token_sender.send(confirmation_token.to_vec()) { |
| 231 | log::error!("Got confirmation token, but receiver would not have it. {:?}", e); |
| 232 | } |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 233 | } |
| 234 | // If cancelled by the user or if aborted by the client. |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 235 | (ResponseCode::CANCELLED, _, _) | (ResponseCode::ABORTED, true, _) => { |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 236 | // Penalize. |
Chris Wailes | 53a22af | 2023-07-12 17:02:47 -0700 | [diff] [blame] | 237 | let rate_info = state.rate_limiting.entry(uid).or_default(); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 238 | rate_info.counter += 1; |
| 239 | rate_info.timestamp = start; |
| 240 | } |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 241 | (ResponseCode::OK, _, None) => { |
| 242 | log::error!( |
| 243 | "Confirmation prompt was successful but no confirmation token was returned." |
| 244 | ); |
| 245 | } |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 246 | // In any other case this try does not count at all. |
| 247 | _ => {} |
| 248 | } |
| 249 | drop(state); |
| 250 | |
| 251 | if let Ok(listener) = callback.into_interface::<dyn IConfirmationCallback>() { |
| 252 | if let Err(e) = listener.onCompleted(rc, data_confirmed) { |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 253 | log::error!("Reporting completion to client failed {:?}", e) |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 254 | } |
| 255 | } else { |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 256 | log::error!("SpIBinder is not a IConfirmationCallback."); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 257 | } |
| 258 | } |
| 259 | |
| 260 | fn present_prompt( |
| 261 | &self, |
Andrei Homescu | 0a8291b | 2021-03-24 02:36:43 +0000 | [diff] [blame] | 262 | listener: &binder::Strong<dyn IConfirmationCallback>, |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 263 | prompt_text: &str, |
| 264 | extra_data: &[u8], |
| 265 | locale: &str, |
| 266 | ui_option_flags: i32, |
| 267 | ) -> Result<()> { |
| 268 | let mut state = self.state.lock().unwrap(); |
| 269 | if state.session.is_some() { |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 270 | return Err(Error::pending()).context(ks_err!("APC Session pending.")); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 271 | } |
| 272 | |
| 273 | // Perform rate limiting. |
| 274 | let uid = ThreadState::get_calling_uid(); |
| 275 | match state.rate_limiting.get(&uid) { |
| 276 | None => {} |
| 277 | Some(rate_info) => { |
| 278 | if let Some(back_off) = rate_info.get_remaining_back_off() { |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 279 | return Err(Error::sys()).context(ks_err!( |
| 280 | "APC Cooling down. Remaining back-off: {}s", |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 281 | back_off.as_secs() |
| 282 | )); |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | |
| 287 | let hal = ApcHal::try_get_service(); |
| 288 | let hal = match hal { |
| 289 | None => { |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 290 | return Err(Error::unimplemented()).context(ks_err!("APC not supported.")); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 291 | } |
| 292 | Some(h) => Arc::new(h), |
| 293 | }; |
| 294 | |
| 295 | let ui_opts = ui_opts_2_compat(ui_option_flags); |
| 296 | |
| 297 | let state_clone = self.state.clone(); |
| 298 | hal.prompt_user_confirmation( |
| 299 | prompt_text, |
| 300 | extra_data, |
| 301 | locale, |
| 302 | ui_opts, |
Janis Danisevskis | b1673db | 2021-02-08 18:11:57 -0800 | [diff] [blame] | 303 | move |rc, data_confirmed, confirmation_token| { |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 304 | Self::result(state_clone, rc, data_confirmed, confirmation_token) |
| 305 | }, |
| 306 | ) |
| 307 | .map_err(|rc| Error::Rc(compat_2_response_code(rc))) |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 308 | .context(ks_err!("APC Failed to present prompt."))?; |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 309 | state.session = Some(ApcSessionState { |
| 310 | hal, |
| 311 | cb: listener.as_binder(), |
| 312 | uid, |
| 313 | start: Instant::now(), |
| 314 | client_aborted: false, |
| 315 | }); |
| 316 | Ok(()) |
| 317 | } |
| 318 | |
Andrei Homescu | 0a8291b | 2021-03-24 02:36:43 +0000 | [diff] [blame] | 319 | fn cancel_prompt(&self, listener: &binder::Strong<dyn IConfirmationCallback>) -> Result<()> { |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 320 | let mut state = self.state.lock().unwrap(); |
| 321 | let hal = match &mut state.session { |
| 322 | None => { |
| 323 | return Err(Error::ignored()) |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 324 | .context(ks_err!("Attempt to cancel non existing session. Ignoring.")); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 325 | } |
| 326 | Some(session) => { |
| 327 | if session.cb != listener.as_binder() { |
Shaquille Johnson | 9da2e1c | 2022-09-19 12:39:01 +0000 | [diff] [blame] | 328 | return Err(Error::ignored()).context(ks_err!( |
| 329 | "Attempt to cancel session not belonging to caller. Ignoring." |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 330 | )); |
| 331 | } |
| 332 | session.client_aborted = true; |
| 333 | session.hal.clone() |
| 334 | } |
| 335 | }; |
| 336 | drop(state); |
| 337 | hal.abort(); |
| 338 | Ok(()) |
| 339 | } |
| 340 | |
| 341 | fn is_supported() -> Result<bool> { |
| 342 | Ok(ApcHal::try_get_service().is_some()) |
| 343 | } |
| 344 | } |
| 345 | |
| 346 | impl IProtectedConfirmation for ApcManager { |
| 347 | fn presentPrompt( |
| 348 | &self, |
Andrei Homescu | 0a8291b | 2021-03-24 02:36:43 +0000 | [diff] [blame] | 349 | listener: &binder::Strong<dyn IConfirmationCallback>, |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 350 | prompt_text: &str, |
| 351 | extra_data: &[u8], |
| 352 | locale: &str, |
| 353 | ui_option_flags: i32, |
| 354 | ) -> BinderResult<()> { |
Hasini Gunasinghe | 5a893e8 | 2021-05-05 14:32:32 +0000 | [diff] [blame] | 355 | // presentPrompt can take more time than other operations. |
| 356 | let _wp = wd::watch_millis("IProtectedConfirmation::presentPrompt", 3000); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 357 | map_or_log_err( |
| 358 | self.present_prompt(listener, prompt_text, extra_data, locale, ui_option_flags), |
| 359 | Ok, |
| 360 | ) |
| 361 | } |
Andrei Homescu | 0a8291b | 2021-03-24 02:36:43 +0000 | [diff] [blame] | 362 | fn cancelPrompt( |
| 363 | &self, |
| 364 | listener: &binder::Strong<dyn IConfirmationCallback>, |
| 365 | ) -> BinderResult<()> { |
David Drysdale | 541846b | 2024-05-23 13:16:07 +0100 | [diff] [blame^] | 366 | let _wp = wd::watch("IProtectedConfirmation::cancelPrompt"); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 367 | map_or_log_err(self.cancel_prompt(listener), Ok) |
| 368 | } |
| 369 | fn isSupported(&self) -> BinderResult<bool> { |
David Drysdale | 541846b | 2024-05-23 13:16:07 +0100 | [diff] [blame^] | 370 | let _wp = wd::watch("IProtectedConfirmation::isSupported"); |
Janis Danisevskis | 7a1cf38 | 2020-11-20 11:22:14 -0800 | [diff] [blame] | 371 | map_or_log_err(Self::is_supported(), Ok) |
| 372 | } |
| 373 | } |