Skip to main content

max / synckit

Make the keychain build and work on iOS synckit-client did not compile for an iOS target, and would not have had a working keychain if it had. Two independent breaks: keyring's `v1` feature enables only `apple-native-keyring-store/keychain`, and that crate hard `compile_error!`s on iOS unless `protected` is on. Name the store crate directly for the iOS target with `protected` so the feature unifies with keyring's copy. keyring's store installer is cfg'd out on iOS entirely, so nothing ever called `set_default_store` and every `Entry::new` would have failed with NoDefaultStore. Install the Protected Data store in keystore.rs instead. Entries carry an explicit `after-first-unlock-this-device-only` access policy. The store default of `when-unlocked` would make the key unreadable exactly when background sync runs, and `ThisDeviceOnly` keeps the master key out of device backups. The rest is the fallout the diagnosis turned up. The claim that the keychain "falls back gracefully when unavailable" was false: `store_key`'s error propagated through `setup_encryption_new` and `setup_encryption_existing` with a question mark, so a Linux box without a secret-service daemon failed encryption setup outright. The keychain is a cache, not the system of record, so add `cache_key`, which reports a write failure instead of propagating it, and route the setup paths through it. Rotation is the exception: a failed write there strands the pre-rotation key in the keychain, so it deletes the stale entry rather than leaving it to be loaded. `entry()` now honours a store already installed in the process instead of letting keyring overwrite it, which also lets the new tests run against keyring-core's mock store. Add scripts/check-mobile-targets.sh, which cross-checks the Apple targets from any host. Both breaks were invisible to every build we run. Still outstanding, and on-device only: the round trip on real hardware and a background/jetsam run confirming the access policy survives suspension.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-28 17:16 UTC
Signed with PGP, not checked
Commit: c97efef9ffb770108229c40baafbd22d9d2fec68
Parent: 8cbe5f6
8 files changed, +347 insertions, -21 deletions
@@ -7,7 +7,12 @@
7 7
8 8 [features]
9 9 default = ["keychain", "store"]
10 - keychain = ["dep:keyring"]
10 + # `keyring` supplies the store for macOS/Windows/Linux; `keyring-core` is the
11 + # common Entry/Error layer both it and the iOS store speak. On iOS the
12 + # target-gated `apple-native-keyring-store` below comes along too: keyring's own
13 + # `v1` helper refuses to build (and refuses to install a store) on iOS, so we
14 + # install the Protected Data store ourselves. See `src/keystore.rs`.
15 + keychain = ["dep:keyring", "dep:keyring-core", "dep:apple-native-keyring-store"]
11 16 # The higher-level SyncStore engine (owns a rusqlite connection to the app's DB).
12 17 # Default-on so app consumers get it for free; a consumer that only needs the
13 18 # transport/crypto SDK (e.g. mnw-cli, which sets `default-features = false`) skips
@@ -53,6 +58,10 @@
53 58
54 59 # OS keychain (optional)
55 60 keyring = { version = "4", optional = true }
61 + # The store-agnostic half of the keyring ecosystem. `keyring::Entry` is a thin
62 + # wrapper over `keyring_core::Entry` (and re-exports its `Error`), so keystore.rs
63 + # works in `keyring_core` types and every platform ends up on one code path.
64 + keyring-core = { version = "1", optional = true }
56 65
57 66 # URL encoding
58 67 urlencoding = "2"
@@ -75,6 +84,20 @@
75 84 # the MNW tree, versioned together.
76 85 synckit-config = { path = "../synckit-config", optional = true }
77 86
87 + # iOS keychain. Two things force this direct dependency rather than letting
88 + # `keyring` handle it:
89 + #
90 + # 1. `keyring`'s `v1` feature enables only `apple-native-keyring-store/keychain`,
91 + # and that crate hard `compile_error!`s on iOS unless `protected` is on
92 + # (apple-native-keyring-store/src/lib.rs:49). Naming it here with `protected`
93 + # feature-unifies with keyring's copy, so the crate builds for iOS at all.
94 + # 2. `keyring`'s store installer is cfg'd out on iOS entirely
95 + # (keyring-4.x/src/v1.rs:96-106), so nothing would ever call
96 + # `set_default_store` and every `Entry::new` would fail. keystore.rs installs
97 + # the Protected Data store itself.
98 + [target.'cfg(target_os = "ios")'.dependencies]
99 + apple-native-keyring-store = { version = "1.0.1", features = ["protected"], optional = true }
100 +
78 101 [dev-dependencies]
79 102 wiremock = "0.6"
80 103 # reqwest is built `rustls-no-provider`, so real consumers install a rustls crypto
@@ -7,7 +7,7 @@
7 7 ## Features
8 8
9 9 - **E2E encryption**: XChaCha20-Poly1305 with Argon2id key derivation (64 MB, 3 iterations)
10 - - **OS keychain integration**: master key cached in macOS Keychain, Linux secret-service, or Windows Credential Manager
10 + - **OS keychain integration**: master key cached in macOS Keychain, the iOS Protected Data store, Linux secret-service, or Windows Credential Manager
11 11 - **Blob encryption**: binary files encrypted with fixed 40-byte overhead (no base64 expansion)
12 12 - **Retry with backoff**: transient failures (network, 5xx, 429) retried up to 3 times with exponential delay
13 13 - **OAuth2 PKCE**: browser-based auth flow alongside email/password
@@ -73,7 +73,7 @@
73 73
74 74 | Flag | Default | Description |
75 75 |------|---------|-------------|
76 - | `keychain` | on | OS keychain storage via the `keyring` crate. Disable with `default-features = false` for headless/CI environments. |
76 + | `keychain` | on | OS keychain storage. `keyring` v4 on macOS/Linux/Windows, `apple-native-keyring-store` on iOS. Disable with `default-features = false` for headless/CI environments. |
77 77
78 78 ## Security Properties
79 79
@@ -283,17 +283,45 @@
283 283
284 284 ## Keychain integration
285 285
286 - The `keychain` feature (default on) uses the `keyring` crate (v4) to store
287 - the master key in the OS credential store:
286 + The `keychain` feature (default on) stores the master key in the OS credential
287 + store:
288 288
289 - - **macOS**: Keychain Services
290 - - **Linux**: secret-service (D-Bus Secret Service API)
291 - - **Windows**: Windows Credential Manager
289 + - **macOS**: Keychain Services (legacy keychain), via `keyring` v4
290 + - **iOS**: Protected Data store, via `apple-native-keyring-store` directly
291 + - **Linux**: secret-service (D-Bus Secret Service API), via `keyring` v4
292 + - **Windows**: Windows Credential Manager, via `keyring` v4
292 293
293 294 Keychain entries are keyed by `synckit:<app_id>` (service) and `<user_id>`
294 295 (account). When the feature is disabled, store/load/delete are no-ops that
295 296 return `Ok`.
296 297
298 + The keychain is a cache, not the system of record. The master key is always
299 + recoverable from the server envelope with the user's password, so a keychain
300 + that is locked, missing, or absent costs a password re-entry and nothing more.
301 + `keystore::cache_key` reflects that: it reports a write failure instead of
302 + propagating it, and encryption setup completes either way. Key rotation is the
303 + exception. A failed write there would leave the pre-rotation key cached, so
304 + rotation deletes the stale entry rather than leaving it to be loaded.
305 +
306 + ### iOS
307 +
308 + `keyring` cannot serve iOS on its own. Its `v1` feature enables only the
309 + Apple store's `keychain` module, which does not exist on iOS, and its store
310 + installer is compiled out for the platform, so nothing would ever call
311 + `set_default_store`. `keystore.rs` installs
312 + `apple_native_keyring_store::protected::Store` itself, once, and builds entries
313 + against it.
314 +
315 + Entries carry an explicit `after-first-unlock-this-device-only` accessibility
316 + class rather than the store default of `when-unlocked`. The default would make
317 + the key unreadable whenever the screen is locked, which is when background sync
318 + runs. `ThisDeviceOnly` keeps the key out of device backups, since a backup copy
319 + of the master key outside the device is the escrow the E2E design refuses.
320 +
321 + The device-local store is used, not the iCloud-synchronized one: the latter
322 + would sync the master key through iCloud Keychain and also rejects an explicit
323 + accessibility class.
324 +
297 325 ## Retry and resilience
298 326
299 327 Push and pull use `retry_request()` with exponential backoff:
@@ -125,7 +125,7 @@
125 125 // Subsequent devices: decrypt existing key from server
126 126 client.setup_encryption_existing(password).await?;
127 127
128 - // Try restore from OS keychain (macOS Keychain, Linux secret-service, Windows Credential Manager)
128 + // Try restore from OS keychain (macOS Keychain, iOS Protected Data, Linux secret-service, Windows Credential Manager)
129 129 client.try_load_key_from_keychain().await? -> bool
130 130
131 131 // Check if server has encrypted key (determines new vs existing flow)
@@ -1,14 +1,25 @@
1 1 //! OS keychain integration for caching the master key.
2 2 //!
3 3 //! Feature-gated behind `keychain` (enabled by default).
4 - //! Falls back gracefully when the keychain is unavailable.
4 + //!
5 + //! The keychain is a *cache*, never the system of record: the master key is
6 + //! always recoverable from the server envelope with the user's password. So a
7 + //! keychain that is missing, locked, or absent entirely costs a password
8 + //! re-entry, not access to the data. Callers reflect
9 + //! that: see [`cache_key`], which reports failure rather than propagating it.
5 10 //!
6 11 //! ## Platform backends
7 12 //!
8 - //! - **macOS**: Keychain (via Security framework).
13 + //! - **macOS**: legacy Keychain (Security framework), via `keyring`'s `v1` store.
14 + //! - **iOS**: Protected Data store (`kSecClassGenericPassword`), installed by
15 + //! this module. `keyring`'s `v1` helper cannot do it (its store installer is
16 + //! cfg'd out on iOS), so [`entry`] calls `keyring_core::set_default_store`
17 + //! directly with `apple_native_keyring_store::protected::Store`. Entries are
18 + //! created with an explicit [`IOS_ACCESS_POLICY`] rather than the store
19 + //! default; see that constant for why.
9 20 //! - **Linux**: secret-service (D-Bus). Requires a running keyring daemon such
10 - //! as gnome-keyring. Without a secret-service provider, `store_key` and
11 - //! `load_key` will return a `Keychain` error.
21 + //! as gnome-keyring. Without a secret-service provider, entry construction
22 + //! fails and the cache is simply unavailable.
12 23 //! - **Windows**: Credential Manager.
13 24
14 25 use crate::error::Result;
@@ -47,11 +58,88 @@
47 58 user_id.to_string()
48 59 }
49 60
61 + /// Accessibility class for the iOS keychain item.
62 + ///
63 + /// The store's own default is `WhenUnlocked`, which makes the item unreadable
64 + /// the moment the screen locks. SyncKit syncs from background tasks, so that
65 + /// default would turn every locked-device wake into a decryption failure.
66 + /// `AfterFirstUnlock` is the class Apple documents for exactly this case:
67 + /// readable from the first post-boot unlock onward, including while locked.
68 + ///
69 + /// `ThisDeviceOnly` on top of it keeps the item out of encrypted iTunes/iCloud
70 + /// backups. That is a requirement, not a preference: the master key is the whole
71 + /// of SyncKit's end-to-end guarantee, and a backup copy of it outside the device
72 + /// is exactly the escrow the design refuses. A restored-to-new-phone user
73 + /// re-enters their password and recovers the key from the server envelope, which
74 + /// is the intended path.
75 + #[cfg(all(feature = "keychain", target_os = "ios"))]
76 + const IOS_ACCESS_POLICY: &str = "after-first-unlock-this-device-only";
77 +
78 + /// Build a keychain entry for this (app, user) pair.
79 + ///
80 + /// Everything below this function works in `keyring_core` types, so the
81 + /// platform split lives here and nowhere else.
82 + ///
83 + /// Non-iOS: go through `keyring::Entry::new`, whose only real job is installing
84 + /// the platform default store on first call, then unwrap to the inner
85 + /// `keyring_core::Entry`.
86 + ///
87 + /// A store already installed in the process wins. `keyring::Entry::new` would
88 + /// otherwise overwrite it with the platform default on its first call, silently
89 + /// redirecting an app that chose its own store. It also lets the tests below run
90 + /// against `keyring_core::mock`.
91 + #[cfg(all(feature = "keychain", not(target_os = "ios")))]
92 + fn entry(app_id: AppId, user_id: UserId) -> Result<keyring_core::Entry> {
93 + let (service, user) = (service_name(app_id), user_key(user_id));
94 + if keyring_core::get_default_store().is_some() {
95 + return Ok(keyring_core::Entry::new(&service, &user)?);
96 + }
97 + Ok(keyring::Entry::new(&service, &user)?.inner)
98 + }
99 +
100 + /// Build a keychain entry for this (app, user) pair (iOS).
101 + ///
102 + /// `keyring`'s store installer is compiled out on iOS, so the default store
103 + /// would never be set and every entry would fail with `NoDefaultStore`. Install
104 + /// the Protected Data store once, then build entries against it with an explicit
105 + /// access policy ([`IOS_ACCESS_POLICY`]).
106 + ///
107 + /// `Store::new` (not `new_with_configuration`) selects the device-local store;
108 + /// the cloud-synchronized variant would push the master key into iCloud
109 + /// Keychain, and it also refuses to honour an access policy.
110 + #[cfg(all(feature = "keychain", target_os = "ios"))]
111 + fn entry(app_id: AppId, user_id: UserId) -> Result<keyring_core::Entry> {
112 + use std::collections::HashMap;
113 + use std::sync::Once;
114 +
115 + static INSTALL: Once = Once::new();
116 + INSTALL.call_once(|| {
117 + match apple_native_keyring_store::protected::Store::new() {
118 + Ok(store) => keyring_core::set_default_store(store),
119 + // `Store::new` is infallible in practice (it only stamps an id), but
120 + // the signature allows failure. Leaving the default store unset makes
121 + // the next line fail with `NoDefaultStore`, which the callers treat
122 + // as "no cache available", the correct degradation.
123 + Err(e) => tracing::warn!(error = %e, "Could not create the iOS keychain store"),
124 + }
125 + });
126 +
127 + let modifiers = HashMap::from([("access-policy", IOS_ACCESS_POLICY)]);
128 + Ok(keyring_core::Entry::new_with_modifiers(
129 + &service_name(app_id),
130 + &user_key(user_id),
131 + &modifiers,
132 + )?)
133 + }
134 +
50 135 /// Store the master key in the OS keychain.
136 + ///
137 + /// Prefer [`cache_key`] from in-crate callers: this returns the raw error, and
138 + /// a failure to *cache* the key is not a failure to *have* it.
51 139 #[cfg(feature = "keychain")]
52 140 pub fn store_key(app_id: AppId, user_id: UserId, master_key: &[u8; 32]) -> Result<()> {
53 141 use zeroize::Zeroize;
54 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
142 + let entry = entry(app_id, user_id)?;
55 143 let mut encoded = B64.encode(master_key);
56 144 let result = entry.set_password(&encoded);
57 145 encoded.zeroize();
@@ -73,7 +161,16 @@
73 161 app_id: AppId,
74 162 user_id: UserId,
75 163 ) -> Result<Option<crate::crypto::ZeroizeOnDrop>> {
76 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
164 + let entry = match entry(app_id, user_id) {
165 + Ok(entry) => entry,
166 + // No usable store on this device (no secret-service daemon on Linux, no
167 + // default store installed on iOS). That is "nothing cached", not an
168 + // error: the caller falls through to the password path.
169 + Err(e) => {
170 + tracing::debug!(error = %e, "OS keychain unavailable, no cached master key");
171 + return Ok(None);
172 + }
173 + };
77 174
78 175 match entry.get_password() {
79 176 Ok(mut encoded) => {
@@ -98,10 +195,35 @@
98 195 }
99 196 }
100 197
198 + /// Cache the master key in the OS keychain, best-effort.
199 + ///
200 + /// Returns whether the key was cached. A `false` costs the user a password
201 + /// re-entry on next cold launch and nothing else, so callers holding a valid
202 + /// master key must carry on rather than abort. The key itself is fine; only the
203 + /// convenience cache is not.
204 + ///
205 + /// Without the `keychain` feature `store_key` is a no-op that returns `Ok`, so
206 + /// this reports `true` for a key that was never written. That is deliberate: the
207 + /// return value says "nothing went wrong", and a build with no keychain has
208 + /// nothing to go wrong.
209 + pub(crate) fn cache_key(app_id: AppId, user_id: UserId, master_key: &[u8; 32]) -> bool {
210 + match store_key(app_id, user_id, master_key) {
211 + Ok(()) => true,
212 + Err(e) => {
213 + tracing::warn!(
214 + error = %e,
215 + "Could not cache the master key in the OS keychain; \
216 + the password will be required on next launch"
217 + );
218 + false
219 + }
220 + }
221 + }
222 +
101 223 /// Delete the master key from the OS keychain.
102 224 #[cfg(feature = "keychain")]
103 225 pub fn delete_key(app_id: AppId, user_id: UserId) -> Result<()> {
104 - let entry = keyring::Entry::new(&service_name(app_id), &user_key(user_id))?;
226 + let entry = entry(app_id, user_id)?;
105 227
106 228 match entry.delete_credential() {
107 229 Ok(()) => {
@@ -345,3 +467,103 @@
345 467 let _: Result<()> = delete_key(app_id, user_id);
346 468 }
347 469 }
470 +
471 + // ── Behavioural tests against a mock credential store ──
472 + //
473 + // The tests above cover the pure helpers and the encoding arithmetic. These
474 + // cover the parts that actually talk to a store: the round trip, the absent-entry
475 + // and corrupt-entry paths, and `cache_key`'s promise that a store failure is
476 + // reported rather than propagated.
477 + //
478 + // `keyring_core::mock` gives a platform-independent in-memory store, so this runs
479 + // the same on a CI box with no secret-service daemon as on a developer machine.
480 + // It is installed as the process default store, which `entry()` honours in
481 + // preference to the platform store.
482 + //
483 + // One test function, not several: the default store is process-global, so
484 + // splitting these up would let the harness run them concurrently against shared
485 + // state. The mock is also installed for the life of the process, which is why
486 + // this cannot double as a test of the real platform store.
487 + //
488 + // iOS is excluded: `entry()` there installs the Protected Data store
489 + // unconditionally, and the mock rejects the access-policy modifier it passes.
490 + // The iOS path is validated by compiling for the target (scripts/check-mobile-targets.sh)
491 + // and by an on-device run.
492 + #[cfg(all(test, feature = "keychain", not(target_os = "ios")))]
493 + mod mock_store_tests {
494 + use super::*;
495 + use keyring_core::mock;
496 +
497 + /// Distinct ids per assertion block, so entries never collide in the store.
498 + fn ids(n: u128) -> (AppId, UserId) {
499 + (
500 + AppId::new(Uuid::from_u128(n)),
501 + UserId::new(Uuid::from_u128(n + 1000)),
502 + )
503 + }
504 +
505 + #[test]
506 + fn keychain_behaviour_against_mock_store() {
507 + keyring_core::set_default_store(mock::Store::new().expect("mock store"));
508 +
509 + // ── Round trip ──
510 + let (app_id, user_id) = ids(1);
511 + let key = [7u8; 32];
512 + store_key(app_id, user_id, &key).expect("store into the mock");
513 + let loaded = load_key(app_id, user_id)
514 + .expect("load from the mock")
515 + .expect("a key was stored");
516 + assert_eq!(loaded.0, key, "the key must round-trip byte for byte");
517 +
518 + // ── Absent entry is None, not an error ──
519 + let (absent_app, absent_user) = ids(2);
520 + assert!(
521 + load_key(absent_app, absent_user)
522 + .expect("an absent entry is not an error")
523 + .is_none()
524 + );
525 +
526 + // ── Delete ──
527 + delete_key(app_id, user_id).expect("delete an existing entry");
528 + assert!(
529 + load_key(app_id, user_id)
530 + .expect("load after delete")
531 + .is_none(),
532 + "a deleted key must not come back"
533 + );
534 + delete_key(app_id, user_id).expect("deleting an absent entry is a no-op");
535 +
536 + // ── A stored value of the wrong length is rejected, not truncated ──
537 + let (bad_app, bad_user) = ids(3);
538 + entry(bad_app, bad_user)
539 + .expect("build entry")
540 + .set_password(&B64.encode([0u8; 16]))
541 + .expect("seed a 16-byte value");
542 + let err = load_key(bad_app, bad_user).expect_err("a 16-byte key must be rejected");
543 + assert!(
544 + format!("{err}").contains("wrong length"),
545 + "expected a wrong-length error, got: {err}"
546 + );
547 +
548 + // ── cache_key reports a store failure instead of propagating it ──
549 + let (fail_app, fail_user) = ids(4);
550 + let probe = entry(fail_app, fail_user).expect("build entry");
551 + let cred: &mock::Cred = probe
552 + .as_any()
553 + .downcast_ref()
554 + .expect("the mock store yields mock credentials");
555 + cred.set_error(keyring_core::Error::NoStorageAccess(Box::new(
556 + std::io::Error::other("keychain is locked"),
557 + )));
558 + assert!(
559 + !cache_key(fail_app, fail_user, &key),
560 + "a failed write must report false, not panic or propagate"
561 + );
562 +
563 + // The mock clears its error after one call, so the next write succeeds.
564 + assert!(
565 + cache_key(fail_app, fail_user, &key),
566 + "a successful write must report true"
567 + );
568 + }
569 + }
@@ -65,8 +65,10 @@
65 65 // Push to server (expected_version 0 = first key)
66 66 self.put_server_key(&envelope, 0).await?;
67 67
68 - // Cache in OS keychain
69 - keystore::store_key(app_id, user_id, &master_key.0)?;
68 + // Cache in OS keychain. Best-effort: the key is already committed to the
69 + // server, so a keychain that will not take it costs a password re-entry
70 + // on next cold launch, not the setup.
71 + keystore::cache_key(app_id, user_id, &master_key.0);
70 72
71 73 // Store in memory
72 74 *self.master_key.write() = Some(master_key);
@@ -86,8 +88,9 @@
86 88 let master_key =
87 89 crypto::ZeroizeOnDrop(crypto::unwrap_master_key(&envelope_json, password)?);
88 90
89 - // Cache in OS keychain
90 - keystore::store_key(app_id, user_id, &master_key.0)?;
91 + // Cache in OS keychain. Best-effort, as above: the key came from the
92 + // server envelope and can come from it again.
93 + keystore::cache_key(app_id, user_id, &master_key.0);
91 94
92 95 // Store in memory
93 96 *self.master_key.write() = Some(master_key);
@@ -117,7 +117,18 @@
117 117
118 118 // 6. Update local state
119 119 let (app_id, user_id) = self.require_session_ids()?;
120 - crate::keystore::store_key(app_id, user_id, &new_master_key)?;
120 + // Rotation is the one place where a failed cache write is worse than an
121 + // empty cache: the entry still holds the pre-rotation key, and a cold
122 + // launch that loads it would decrypt nothing. Drop the stale entry so the
123 + // next launch falls through to the password path instead.
124 + if !crate::keystore::cache_key(app_id, user_id, &new_master_key)
125 + && let Err(e) = crate::keystore::delete_key(app_id, user_id)
126 + {
127 + tracing::warn!(
128 + error = %e,
129 + "Could not clear the stale pre-rotation key from the OS keychain"
130 + );
131 + }
121 132 *self.master_key.write() = Some(new_master_key);
122 133 *self.master_key_id.write() = new_key_id;
123 134 *self.pending_key.write() = None;
@@ -1,0 +1,39 @@
1 + #!/bin/bash
2 + # check-mobile-targets: cross-check synckit-client for the Apple targets.
3 + #
4 + # Why this exists: synckit-client compiled cleanly on every host we build on
5 + # while being entirely unbuildable for iOS, because nothing ever pointed a
6 + # compiler at an iOS target. Two separate breaks hid behind that (the Apple
7 + # store crate hard-errors on iOS without its `protected` feature, and keyring's
8 + # store installer is cfg'd out on iOS), and both were found by reading, not by
9 + # building. Run this after touching keystore.rs, the `keychain` feature, or any
10 + # keyring-family dependency.
11 + #
12 + # Runs anywhere with rustup, Linux included: `cargo check` does not link, and
13 + # these feature sets pull in no C code.
14 + #
15 + # Not covered here, and still mbp-only:
16 + # - the `store` feature, whose bundled SQLite needs the Apple SDK (xcrun)
17 + # - linking, code signing, and anything on-device
18 + set -euo pipefail
19 +
20 + cd "$(dirname "$0")/.."
21 +
22 + TARGETS=(aarch64-apple-ios aarch64-apple-darwin)
23 + # `store` is excluded deliberately: bundled libsqlite3-sys shells out to xcrun.
24 + FEATURES="keychain"
25 +
26 + for target in "${TARGETS[@]}"; do
27 + if ! rustup target list --installed | grep -qx "$target"; then
28 + echo "==> installing target $target"
29 + rustup target add "$target"
30 + fi
31 + echo "==> cargo check --target $target --features $FEATURES"
32 + cargo check \
33 + --manifest-path synckit-client/Cargo.toml \
34 + --target "$target" \
35 + --no-default-features \
36 + --features "$FEATURES"
37 + done
38 +
39 + echo "==> Apple targets check clean."