blob: 8efc58d7f88b969f3da3229db6cffe2deff9b1ee [file] [log] [blame]
Alan Stokesea1f0462024-02-19 16:25:47 +00001// Copyright 2024 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
David Drysdale79af2662024-02-19 14:50:31 +000015use android_hardware_security_secretkeeper::aidl::android::hardware::security::secretkeeper::{
16 ISecretkeeper::ISecretkeeper, SecretId::SecretId,
Alan Stokesea1f0462024-02-19 16:25:47 +000017};
David Drysdale1138fa02024-03-19 13:06:23 +000018use android_system_virtualizationmaintenance::aidl::android::system::virtualizationmaintenance;
19use anyhow::{anyhow, Context, Result};
20use binder::Strong;
David Drysdale79af2662024-02-19 14:50:31 +000021use log::{error, info, warn};
David Drysdale1138fa02024-03-19 13:06:23 +000022use virtualizationmaintenance::IVirtualizationReconciliationCallback::IVirtualizationReconciliationCallback;
Alan Stokesea1f0462024-02-19 16:25:47 +000023
David Drysdale79af2662024-02-19 14:50:31 +000024mod vmdb;
25use vmdb::{VmId, VmIdDb};
26
David Drysdale1138fa02024-03-19 13:06:23 +000027/// Indicate whether an app ID belongs to a system core component.
28fn core_app_id(app_id: i32) -> bool {
29 app_id < 10000
30}
31
David Drysdale79af2662024-02-19 14:50:31 +000032/// Interface name for the Secretkeeper HAL.
33const SECRETKEEPER_SERVICE: &str = "android.hardware.security.secretkeeper.ISecretkeeper/default";
34
35/// Directory in which to write persistent state.
36const PERSISTENT_DIRECTORY: &str = "/data/misc/apexdata/com.android.virt";
37
38/// Maximum number of VM IDs to delete at once. Needs to be smaller than both the maximum
39/// number of SQLite parameters (999) and also small enough that an ISecretkeeper::deleteIds
40/// parcel fits within max AIDL message size.
41const DELETE_MAX_BATCH_SIZE: usize = 100;
42
David Drysdale825c90f2024-03-26 12:45:29 +000043/// Maximum number of VM IDs that a single app can have.
44const MAX_VM_IDS_PER_APP: usize = 400;
45
David Drysdale79af2662024-02-19 14:50:31 +000046/// State related to VM secrets.
47pub struct State {
48 sk: binder::Strong<dyn ISecretkeeper>,
49 /// Database of VM IDs,
50 vm_id_db: VmIdDb,
51 batch_size: usize,
Alan Stokesea1f0462024-02-19 16:25:47 +000052}
53
David Drysdale79af2662024-02-19 14:50:31 +000054impl State {
55 pub fn new() -> Option<Self> {
56 let sk = match Self::find_sk() {
57 Some(sk) => sk,
58 None => {
59 warn!("failed to find a Secretkeeper instance; skipping secret management");
60 return None;
61 }
62 };
63 let (vm_id_db, created) = match VmIdDb::new(PERSISTENT_DIRECTORY) {
64 Ok(v) => v,
65 Err(e) => {
66 error!("skipping secret management, failed to connect to database: {e:?}");
67 return None;
68 }
69 };
70 if created {
71 // If the database did not previously exist, then this appears to be the first run of
72 // `virtualizationservice` since device setup or factory reset. In case of the latter,
73 // delete any secrets that may be left over from before reset, thus ensuring that the
74 // local database state matches that of the TA (i.e. empty).
75 warn!("no existing VM ID DB; clearing any previous secrets to match fresh DB");
76 if let Err(e) = sk.deleteAll() {
77 error!("failed to delete previous secrets, dropping database: {e:?}");
78 vm_id_db.delete_db_file(PERSISTENT_DIRECTORY);
79 return None;
80 }
81 } else {
82 info!("re-using existing VM ID DB");
83 }
84 Some(Self { sk, vm_id_db, batch_size: DELETE_MAX_BATCH_SIZE })
Alan Stokesea1f0462024-02-19 16:25:47 +000085 }
86
David Drysdale79af2662024-02-19 14:50:31 +000087 fn find_sk() -> Option<binder::Strong<dyn ISecretkeeper>> {
88 if let Ok(true) = binder::is_declared(SECRETKEEPER_SERVICE) {
89 match binder::get_interface(SECRETKEEPER_SERVICE) {
90 Ok(sk) => Some(sk),
91 Err(e) => {
92 error!("failed to connect to {SECRETKEEPER_SERVICE}: {e:?}");
93 None
94 }
95 }
96 } else {
97 info!("instance {SECRETKEEPER_SERVICE} not declared");
98 None
99 }
100 }
101
David Drysdale3aa62b32024-03-25 12:31:48 +0000102 /// Record a new VM ID. If there is an existing owner (user_id, app_id) for the VM ID,
103 /// it will be replaced.
David Drysdalee64de8e2024-02-29 11:54:29 +0000104 pub fn add_id(&mut self, vm_id: &VmId, user_id: u32, app_id: u32) -> Result<()> {
105 let user_id: i32 = user_id.try_into().context(format!("user_id {user_id} out of range"))?;
106 let app_id: i32 = app_id.try_into().context(format!("app_id {app_id} out of range"))?;
David Drysdale825c90f2024-03-26 12:45:29 +0000107
108 // To prevent unbounded growth of VM IDs (and the associated state) for an app, limit the
109 // number of VM IDs per app.
110 let count = self
111 .vm_id_db
112 .count_vm_ids_for_app(user_id, app_id)
113 .context("failed to determine VM count")?;
114 if count >= MAX_VM_IDS_PER_APP {
115 // The owner has too many VM IDs, so delete the oldest IDs so that the new VM ID
116 // creation can progress/succeed.
117 let purge = 1 + count - MAX_VM_IDS_PER_APP;
118 let old_vm_ids = self
119 .vm_id_db
120 .oldest_vm_ids_for_app(user_id, app_id, purge)
121 .context("failed to find oldest VM IDs")?;
122 error!("Deleting {purge} of {count} VM IDs for user_id={user_id}, app_id={app_id}");
123 self.delete_ids(&old_vm_ids);
124 }
David Drysdalee64de8e2024-02-29 11:54:29 +0000125 self.vm_id_db.add_vm_id(vm_id, user_id, app_id)
126 }
127
David Drysdale79af2662024-02-19 14:50:31 +0000128 /// Delete the VM IDs associated with Android user ID `user_id`.
129 pub fn delete_ids_for_user(&mut self, user_id: i32) -> Result<()> {
130 let vm_ids = self.vm_id_db.vm_ids_for_user(user_id)?;
131 info!(
132 "delete_ids_for_user(user_id={user_id}) triggers deletion of {} secrets",
133 vm_ids.len()
134 );
135 self.delete_ids(&vm_ids);
136 Ok(())
137 }
138
139 /// Delete the VM IDs associated with `(user_id, app_id)`.
140 pub fn delete_ids_for_app(&mut self, user_id: i32, app_id: i32) -> Result<()> {
141 let vm_ids = self.vm_id_db.vm_ids_for_app(user_id, app_id)?;
142 info!(
143 "delete_ids_for_app(user_id={user_id}, app_id={app_id}) removes {} secrets",
144 vm_ids.len()
145 );
146 self.delete_ids(&vm_ids);
147 Ok(())
148 }
149
150 /// Delete the provided VM IDs from both Secretkeeper and the database.
151 pub fn delete_ids(&mut self, mut vm_ids: &[VmId]) {
152 while !vm_ids.is_empty() {
153 let len = std::cmp::min(vm_ids.len(), self.batch_size);
154 let batch = &vm_ids[..len];
155 self.delete_ids_batch(batch);
156 vm_ids = &vm_ids[len..];
157 }
158 }
159
160 /// Delete a batch of VM IDs from both Secretkeeper and the database. The batch is assumed
161 /// to be smaller than both:
162 /// - the corresponding limit for number of database parameters
163 /// - the corresponding limit for maximum size of a single AIDL message for `ISecretkeeper`.
164 fn delete_ids_batch(&mut self, vm_ids: &[VmId]) {
165 let secret_ids: Vec<SecretId> = vm_ids.iter().map(|id| SecretId { id: *id }).collect();
166 if let Err(e) = self.sk.deleteIds(&secret_ids) {
167 error!("failed to delete all secrets from Secretkeeper: {e:?}");
168 }
169 if let Err(e) = self.vm_id_db.delete_vm_ids(vm_ids) {
170 error!("failed to remove secret IDs from database: {e:?}");
171 }
172 }
David Drysdale1138fa02024-03-19 13:06:23 +0000173
174 /// Perform reconciliation to allow for possibly missed notifications of user or app removal.
175 pub fn reconcile(
176 &mut self,
177 callback: &Strong<dyn IVirtualizationReconciliationCallback>,
178 ) -> Result<()> {
179 // First, retrieve all (user_id, app_id) pairs that own a VM.
180 let owners = self.vm_id_db.get_all_owners().context("failed to retrieve owners from DB")?;
181 if owners.is_empty() {
182 info!("no VM owners, nothing to do");
183 return Ok(());
184 }
185
186 // Look for absent users.
187 let mut users: Vec<i32> = owners.iter().map(|(u, _a)| *u).collect();
188 users.sort();
189 users.dedup();
190 let users_exist = callback
191 .doUsersExist(&users)
192 .context(format!("failed to determine if {} users exist", users.len()))?;
193 if users_exist.len() != users.len() {
194 error!("callback returned {} bools for {} inputs!", users_exist.len(), users.len());
195 return Err(anyhow!("unexpected number of results from callback"));
196 }
197
198 for (user_id, present) in users.into_iter().zip(users_exist.into_iter()) {
199 if present {
200 // User is still present, but are all of the associated apps?
201 let mut apps: Vec<i32> = owners
202 .iter()
203 .filter_map(|(u, a)| if *u == user_id { Some(*a) } else { None })
204 .collect();
205 apps.sort();
206 apps.dedup();
207
208 let apps_exist = callback
209 .doAppsExist(user_id, &apps)
210 .context(format!("failed to check apps for user {user_id}"))?;
211 if apps_exist.len() != apps.len() {
212 error!(
213 "callback returned {} bools for {} inputs!",
214 apps_exist.len(),
215 apps.len()
216 );
217 return Err(anyhow!("unexpected number of results from callback"));
218 }
219
220 let missing_apps: Vec<i32> = apps
221 .iter()
222 .zip(apps_exist.into_iter())
223 .filter_map(|(app_id, present)| if present { None } else { Some(*app_id) })
224 .collect();
225
226 for app_id in missing_apps {
227 if core_app_id(app_id) {
228 info!("Skipping deletion for core app {app_id} for user {user_id}");
229 continue;
230 }
231 info!("App {app_id} for user {user_id} absent, deleting associated VM IDs");
232 if let Err(err) = self.delete_ids_for_app(user_id, app_id) {
233 error!("Failed to delete VM ID for user {user_id} app {app_id}: {err:?}");
234 }
235 }
236 } else {
237 info!("user {user_id} no longer present, deleting associated VM IDs");
238 if let Err(err) = self.delete_ids_for_user(user_id) {
239 error!("Failed to delete VM IDs for user {user_id} : {err:?}");
240 }
241 }
242 }
243
244 Ok(())
245 }
David Drysdale79af2662024-02-19 14:50:31 +0000246}
247
248#[cfg(test)]
249mod tests {
250 use super::*;
251 use std::sync::{Arc, Mutex};
252 use android_hardware_security_authgraph::aidl::android::hardware::security::authgraph::{
253 IAuthGraphKeyExchange::IAuthGraphKeyExchange,
254 };
255 use android_hardware_security_secretkeeper::aidl::android::hardware::security::secretkeeper::{
256 ISecretkeeper::BnSecretkeeper
257 };
David Drysdale1138fa02024-03-19 13:06:23 +0000258 use virtualizationmaintenance::IVirtualizationReconciliationCallback::{
259 BnVirtualizationReconciliationCallback
260 };
David Drysdale79af2662024-02-19 14:50:31 +0000261
262 /// Fake implementation of Secretkeeper that keeps a history of what operations were invoked.
263 #[derive(Default)]
264 struct FakeSk {
265 history: Arc<Mutex<Vec<SkOp>>>,
266 }
267
268 #[derive(Clone, PartialEq, Eq, Debug)]
269 enum SkOp {
270 Management,
271 DeleteIds(Vec<VmId>),
272 DeleteAll,
273 }
274
275 impl ISecretkeeper for FakeSk {
276 fn processSecretManagementRequest(&self, _req: &[u8]) -> binder::Result<Vec<u8>> {
277 self.history.lock().unwrap().push(SkOp::Management);
278 Ok(vec![])
279 }
280
281 fn getAuthGraphKe(&self) -> binder::Result<binder::Strong<dyn IAuthGraphKeyExchange>> {
282 unimplemented!()
283 }
284
285 fn deleteIds(&self, ids: &[SecretId]) -> binder::Result<()> {
286 self.history.lock().unwrap().push(SkOp::DeleteIds(ids.iter().map(|s| s.id).collect()));
287 Ok(())
288 }
289
290 fn deleteAll(&self) -> binder::Result<()> {
291 self.history.lock().unwrap().push(SkOp::DeleteAll);
292 Ok(())
293 }
294 }
295 impl binder::Interface for FakeSk {}
296
297 fn new_test_state(history: Arc<Mutex<Vec<SkOp>>>, batch_size: usize) -> State {
298 let vm_id_db = vmdb::new_test_db();
299 let sk = FakeSk { history };
300 let sk = BnSecretkeeper::new_binder(sk, binder::BinderFeatures::default());
301 State { sk, vm_id_db, batch_size }
302 }
303
David Drysdale1138fa02024-03-19 13:06:23 +0000304 struct Reconciliation {
305 gone_users: Vec<i32>,
306 gone_apps: Vec<i32>,
307 }
308
309 impl IVirtualizationReconciliationCallback for Reconciliation {
310 fn doUsersExist(&self, user_ids: &[i32]) -> binder::Result<Vec<bool>> {
311 Ok(user_ids.iter().map(|user_id| !self.gone_users.contains(user_id)).collect())
312 }
313 fn doAppsExist(&self, _user_id: i32, app_ids: &[i32]) -> binder::Result<Vec<bool>> {
314 Ok(app_ids.iter().map(|app_id| !self.gone_apps.contains(app_id)).collect())
315 }
316 }
317 impl binder::Interface for Reconciliation {}
318
David Drysdale79af2662024-02-19 14:50:31 +0000319 const VM_ID1: VmId = [1u8; 64];
320 const VM_ID2: VmId = [2u8; 64];
321 const VM_ID3: VmId = [3u8; 64];
322 const VM_ID4: VmId = [4u8; 64];
323 const VM_ID5: VmId = [5u8; 64];
324
David Drysdale1138fa02024-03-19 13:06:23 +0000325 const USER1: i32 = 1;
326 const USER2: i32 = 2;
327 const USER3: i32 = 3;
328 const APP_A: i32 = 10050;
329 const APP_B: i32 = 10060;
330 const APP_C: i32 = 10070;
331 const CORE_APP_A: i32 = 45;
332
David Drysdale79af2662024-02-19 14:50:31 +0000333 #[test]
334 fn test_sk_state_batching() {
335 let history = Arc::new(Mutex::new(Vec::new()));
336 let mut sk_state = new_test_state(history.clone(), 2);
337 sk_state.delete_ids(&[VM_ID1, VM_ID2, VM_ID3, VM_ID4, VM_ID5]);
338 let got = (*history.lock().unwrap()).clone();
339 assert_eq!(
340 got,
341 vec![
342 SkOp::DeleteIds(vec![VM_ID1, VM_ID2]),
343 SkOp::DeleteIds(vec![VM_ID3, VM_ID4]),
344 SkOp::DeleteIds(vec![VM_ID5]),
345 ]
346 );
347 }
348
349 #[test]
350 fn test_sk_state_no_batching() {
351 let history = Arc::new(Mutex::new(Vec::new()));
352 let mut sk_state = new_test_state(history.clone(), 6);
353 sk_state.delete_ids(&[VM_ID1, VM_ID2, VM_ID3, VM_ID4, VM_ID5]);
354 let got = (*history.lock().unwrap()).clone();
355 assert_eq!(got, vec![SkOp::DeleteIds(vec![VM_ID1, VM_ID2, VM_ID3, VM_ID4, VM_ID5])]);
356 }
357
358 #[test]
359 fn test_sk_state() {
David Drysdale79af2662024-02-19 14:50:31 +0000360 let history = Arc::new(Mutex::new(Vec::new()));
361 let mut sk_state = new_test_state(history.clone(), 2);
362
363 sk_state.vm_id_db.add_vm_id(&VM_ID1, USER1, APP_A).unwrap();
364 sk_state.vm_id_db.add_vm_id(&VM_ID2, USER1, APP_A).unwrap();
365 sk_state.vm_id_db.add_vm_id(&VM_ID3, USER2, APP_B).unwrap();
366 sk_state.vm_id_db.add_vm_id(&VM_ID4, USER3, APP_A).unwrap();
David Drysdale1138fa02024-03-19 13:06:23 +0000367 sk_state.vm_id_db.add_vm_id(&VM_ID5, USER3, APP_C).unwrap(); // Overwrites APP_A
David Drysdale79af2662024-02-19 14:50:31 +0000368 assert_eq!((*history.lock().unwrap()).clone(), vec![]);
369
370 sk_state.delete_ids_for_app(USER2, APP_B).unwrap();
371 assert_eq!((*history.lock().unwrap()).clone(), vec![SkOp::DeleteIds(vec![VM_ID3])]);
372
373 sk_state.delete_ids_for_user(USER3).unwrap();
374 assert_eq!(
375 (*history.lock().unwrap()).clone(),
376 vec![SkOp::DeleteIds(vec![VM_ID3]), SkOp::DeleteIds(vec![VM_ID4, VM_ID5]),]
377 );
378
379 assert_eq!(vec![VM_ID1, VM_ID2], sk_state.vm_id_db.vm_ids_for_user(USER1).unwrap());
380 assert_eq!(vec![VM_ID1, VM_ID2], sk_state.vm_id_db.vm_ids_for_app(USER1, APP_A).unwrap());
381 let empty: Vec<VmId> = Vec::new();
382 assert_eq!(empty, sk_state.vm_id_db.vm_ids_for_app(USER2, APP_B).unwrap());
383 assert_eq!(empty, sk_state.vm_id_db.vm_ids_for_user(USER3).unwrap());
Alan Stokesea1f0462024-02-19 16:25:47 +0000384 }
David Drysdale1138fa02024-03-19 13:06:23 +0000385
386 #[test]
387 fn test_sk_state_reconcile() {
388 let history = Arc::new(Mutex::new(Vec::new()));
389 let mut sk_state = new_test_state(history.clone(), 20);
390
391 sk_state.vm_id_db.add_vm_id(&VM_ID1, USER1, APP_A).unwrap();
392 sk_state.vm_id_db.add_vm_id(&VM_ID2, USER1, APP_A).unwrap();
393 sk_state.vm_id_db.add_vm_id(&VM_ID3, USER2, APP_B).unwrap();
394 sk_state.vm_id_db.add_vm_id(&VM_ID4, USER2, CORE_APP_A).unwrap();
395 sk_state.vm_id_db.add_vm_id(&VM_ID5, USER3, APP_C).unwrap();
396
397 assert_eq!(vec![VM_ID1, VM_ID2], sk_state.vm_id_db.vm_ids_for_user(USER1).unwrap());
398 assert_eq!(vec![VM_ID1, VM_ID2], sk_state.vm_id_db.vm_ids_for_app(USER1, APP_A).unwrap());
399 assert_eq!(vec![VM_ID3], sk_state.vm_id_db.vm_ids_for_app(USER2, APP_B).unwrap());
400 assert_eq!(vec![VM_ID5], sk_state.vm_id_db.vm_ids_for_user(USER3).unwrap());
401
402 // Perform a reconciliation and pretend that USER1 and [CORE_APP_A, APP_B] are gone.
403 let reconciliation =
404 Reconciliation { gone_users: vec![USER1], gone_apps: vec![CORE_APP_A, APP_B] };
405 let callback = BnVirtualizationReconciliationCallback::new_binder(
406 reconciliation,
407 binder::BinderFeatures::default(),
408 );
409 sk_state.reconcile(&callback).unwrap();
410
411 let empty: Vec<VmId> = Vec::new();
412 assert_eq!(empty, sk_state.vm_id_db.vm_ids_for_user(USER1).unwrap());
413 assert_eq!(empty, sk_state.vm_id_db.vm_ids_for_app(USER1, APP_A).unwrap());
414 // VM for core app stays even though it's reported as absent.
415 assert_eq!(vec![VM_ID4], sk_state.vm_id_db.vm_ids_for_user(USER2).unwrap());
416 assert_eq!(empty, sk_state.vm_id_db.vm_ids_for_app(USER2, APP_B).unwrap());
417 assert_eq!(vec![VM_ID5], sk_state.vm_id_db.vm_ids_for_user(USER3).unwrap());
418 }
419
David Drysdale825c90f2024-03-26 12:45:29 +0000420 #[test]
421 fn test_sk_state_too_many_vms() {
422 let history = Arc::new(Mutex::new(Vec::new()));
423 let mut sk_state = new_test_state(history.clone(), 20);
424
425 // Every VM ID added up to the limit is kept.
426 for idx in 0..MAX_VM_IDS_PER_APP {
427 let mut vm_id = [0u8; 64];
428 vm_id[0..8].copy_from_slice(&(idx as u64).to_be_bytes());
429 sk_state.add_id(&vm_id, USER1 as u32, APP_A as u32).unwrap();
430 assert_eq!(idx + 1, sk_state.vm_id_db.count_vm_ids_for_app(USER1, APP_A).unwrap());
431 }
432 assert_eq!(
433 MAX_VM_IDS_PER_APP,
434 sk_state.vm_id_db.count_vm_ids_for_app(USER1, APP_A).unwrap()
435 );
436
437 // Beyond the limit it's one in, one out.
438 for idx in MAX_VM_IDS_PER_APP..MAX_VM_IDS_PER_APP + 10 {
439 let mut vm_id = [0u8; 64];
440 vm_id[0..8].copy_from_slice(&(idx as u64).to_be_bytes());
441 sk_state.add_id(&vm_id, USER1 as u32, APP_A as u32).unwrap();
442 assert_eq!(
443 MAX_VM_IDS_PER_APP,
444 sk_state.vm_id_db.count_vm_ids_for_app(USER1, APP_A).unwrap()
445 );
446 }
447 assert_eq!(
448 MAX_VM_IDS_PER_APP,
449 sk_state.vm_id_db.count_vm_ids_for_app(USER1, APP_A).unwrap()
450 );
451 }
452
David Drysdale1138fa02024-03-19 13:06:23 +0000453 struct Irreconcilable;
454
455 impl IVirtualizationReconciliationCallback for Irreconcilable {
456 fn doUsersExist(&self, user_ids: &[i32]) -> binder::Result<Vec<bool>> {
457 panic!("doUsersExist called with {user_ids:?}");
458 }
459 fn doAppsExist(&self, user_id: i32, app_ids: &[i32]) -> binder::Result<Vec<bool>> {
460 panic!("doAppsExist called with {user_id:?}, {app_ids:?}");
461 }
462 }
463 impl binder::Interface for Irreconcilable {}
464
465 #[test]
466 fn test_sk_state_reconcile_not_needed() {
467 let history = Arc::new(Mutex::new(Vec::new()));
468 let mut sk_state = new_test_state(history.clone(), 20);
469
470 sk_state.vm_id_db.add_vm_id(&VM_ID1, USER1, APP_A).unwrap();
471 sk_state.vm_id_db.add_vm_id(&VM_ID2, USER1, APP_A).unwrap();
472 sk_state.vm_id_db.add_vm_id(&VM_ID3, USER2, APP_B).unwrap();
473 sk_state.vm_id_db.add_vm_id(&VM_ID5, USER3, APP_C).unwrap();
474 sk_state.delete_ids_for_user(USER1).unwrap();
475 sk_state.delete_ids_for_user(USER2).unwrap();
476 sk_state.delete_ids_for_user(USER3).unwrap();
477
478 // No extant secrets, so reconciliation should not trigger the callback.
479 let callback = BnVirtualizationReconciliationCallback::new_binder(
480 Irreconcilable,
481 binder::BinderFeatures::default(),
482 );
483 sk_state.reconcile(&callback).unwrap();
484 }
Alan Stokesea1f0462024-02-19 16:25:47 +0000485}