Commit a250c7fa43fb
tarball9 files changed+360-33
@@ -126,8 +126,13 @@ what's different in a browser
126126
has no upload progress127127
- the sdk doesn't retry requests or time them out individually. sync still128128
restarts itself with backoff, that part is ours129
-- two tabs or workers on one store each need `StoreConfig::with_lock_holder`130
- with a name of their own, or they both think they hold the crypto store129
+- an OAuth login only finishes in the process that began it, since the sdk130
+ keeps its pending state in memory. run it in a popup, or in a worker that131
+ outlives the redirect, not by navigating the page away132
+- one engine per store. a second tab or worker opening a store that's in use133
+ gets `StoreInUse` straight away, so serve many tabs from one engine in a134
+ SharedWorker rather than one each. the store's Web Lock needs a secure135
+ context131136
- `discard` while another tab still has the store open answers132137
`StoreDeletePending`. the delete lands once that tab lets go, and reopening133138
the store waits behind it@@ -222,7 +222,43 @@ would restore into empty crypto. the host follows `Termination::retain`.
222222
swaps the record for one holding the homeserver, user, device and oauth client223223
id with no tokens. `restore` hands that back as `Restore::SoftLoggedOut`, so a224224
restart still knows which device to log in again as, and the login overwrites225
-it. one store directory per login225
+it. one store directory per login. a login whose first save fails comes back as226
+`AccountError::Unsaved` holding the live session, so the host retries the save227
+rather than spending another device228
+229
+one engine per store. before the client opens the store we take the platform's230
+own exclusive lock on it, `File::try_lock` on `macltok.lock` in the store231
+directory natively and a Web Lock named after the store in a browser. the232
+engine and every `ClientHandle` share it, so it goes when the last of them233
+does, since a lingering handle still has the client and its store open. anyone234
+else, in this process, another one or another tab, gets `StoreInUse` at once,235
+so the host decides whether to wait, hand over or say so. the os or the browser236
+lets go when the holder dies. a SharedWorker serving every tab from one engine237
+never contends for it. Web Locks only exist in a secure context, so a browser238
+store needs one too239
+240
+the sdk's cross process lease can't do this job. in matrix-sdk-common 0.18241
+`CrossProcessLock::try_lock_once` (`cross_process_lock.rs` 389-448) leases for242
+500ms and renews every 50ms from a spawned task, which gives up for good on one243
+failed renewal or on finding the lease lapsed and retaken, and tells no one. a244
+stall past half a second, ordinary in a tab and certain during the record's245
+PBKDF2, or one `SQLITE_BUSY`, hands the store to a second engine while the246
+first still thinks it's alone. with our lock keeping everyone else out, clients247
+run with `CrossProcessLockConfig::SingleProcess` and the sdk's own sections skip248
+the lease writes and renewal tasks249
+250
+a pending OAuth login isn't persisted. `begin_oauth` and `finish` have to run in251
+the same process, so a web host runs the login in a popup or in a worker that252
+outlives the redirect. the sdk keeps what `finish_login` needs, the PKCE253
+verifier among it, in `AuthorizationValidationData` inside a `pub(crate)` map254
+keyed by `CsrfToken` (matrix-sdk 0.18, `authentication/oauth/mod.rs` around255
+262-266 and 1744-1756). nothing public reads it or puts it back, and256
+`finish_login` on a fresh client answers `InvalidState`. doing the code grant257
+ourselves would copy some 250 lines of the sdk's private oauth behaviour and258
+drift from it every upgrade. patching the sdk to export and import that data is259
+small, but it means carrying a matrix-sdk fork, which we won't for now. upstream260
+exposing export and import of pending authorization data, or a fork becoming261
+acceptable, reopens this226262
227263
## room lists, spaces and profiles228264
@@ -26,7 +26,8 @@ use url::Url;
2626
2727
use crate::{2828
ClientHandle, Engine, EngineConfig, EngineError, ErrorKind, EventStream, SessionStore,29
- engine::Persistence, error::SourceError,29
+ engine::{Persistence, StoreLock},30
+ error::SourceError,3031
};3132
3233
/// a logged in client and the `SessionStore` its record lives in. the engine it builds@@ -34,6 +35,7 @@ use crate::{
3435
pub struct Session {3536
client: Client,3637
tracking: Tracking,38
+ store_lock: StoreLock,3739
}3840
3941
/// taken as soon as the client exists, since the sdk can refresh before there's an engine@@ -143,7 +145,7 @@ pub enum OAuthApplicationType {
143145
pub struct PendingOAuthLogin {144146
client: Client,145147
sessions: Arc<dyn SessionStore>,146
- tracking: Option<Tracking>,148
+ tracking: Option<(Tracking, StoreLock)>,147149
authorization_url: String,148150
state: Option<CsrfToken>,149151
}@@ -181,11 +183,17 @@ impl PendingOAuthLogin {
181183
oauth.abort_login(&state).await;182184
return Err(account_error("OAuth login", error));183185
}184
- let tracking = self186
+ let (tracking, store_lock) = self185187
.tracking186188
.take()187189
.ok_or(AccountError::OAuthCallbackAlreadySubmitted)?;188
- Session::saved(self.client.clone(), &*self.sessions, tracking).await190
+ Session::saved(191
+ self.client.clone(),192
+ self.sessions.clone(),193
+ tracking,194
+ store_lock,195
+ )196
+ .await189197
}190198
191199
pub async fn abort(mut self) {@@ -198,7 +206,6 @@ impl PendingOAuthLogin {
198206
pub struct StoreConfig {199207
location: StoreLocation,200208
passphrase: String,201
- lock_holder: Option<String>,202209
}203210
204211
enum StoreLocation {@@ -215,7 +222,6 @@ impl StoreConfig {
215222
Self {216223
location: StoreLocation::Sqlite(path.into()),217224
passphrase: passphrase.into(),218
- lock_holder: None,219225
}220226
}221227
@@ -225,17 +231,9 @@ impl StoreConfig {
225231
Self {226232
location: StoreLocation::IndexedDb(name.into()),227233
passphrase: passphrase.into(),228
- lock_holder: None,229234
}230235
}231236
232
- /// the sdk names every client `main`, so two tabs on one store would both think they hold its lock233
- #[must_use]234
- pub fn with_lock_holder(mut self, holder: impl Into<String>) -> Self {235
- self.lock_holder = Some(holder.into());236
- self237
- }238
-239237
/// delete the store's databases, once nothing has them open240238
pub async fn delete(&self) -> Result<(), AccountError> {241239
match &self.location {@@ -249,6 +247,17 @@ impl StoreConfig {
249247
}250248
}251249
250
+ /// taken before the client opens the store, and held until the engine and every251
+ /// `ClientHandle` are gone252
+ pub(crate) async fn lock(&self) -> Result<StoreLock, AccountError> {253
+ match &self.location {254
+ #[cfg(feature = "sqlite")]255
+ StoreLocation::Sqlite(path) => StoreLock::file(path),256
+ #[cfg(feature = "indexeddb")]257
+ StoreLocation::IndexedDb(name) => StoreLock::web(name).await,258
+ }259
+ }260
+252261
pub(crate) fn passphrase(&self) -> &str {253262
&self.passphrase254263
}@@ -352,15 +361,62 @@ pub enum AccountError {
352361
/// once they close, and opening the store again waits behind it353362
#[error("the store is still open elsewhere, it will be deleted once that closes")]354363
StoreDeletePending,364
+ /// another engine, in this process or another, has the store. only one runs per store365
+ #[error("another engine is using this store")]366
+ StoreInUse,367
+ /// the login worked but its first record didn't save. the server already has the368
+ /// device, so retry the save rather than log in again369
+ #[error("saving the new login's record failed")]370
+ Unsaved {371
+ session: Box<UnsavedSession>,372
+ #[source]373
+ source: Box<AccountError>,374
+ },355375
#[error(transparent)]356376
Engine(#[from] EngineError),357377
}358378
379
+/// a live login whose record never reached its `SessionStore`380
+pub struct UnsavedSession {381
+ session: Session,382
+ sessions: Arc<dyn SessionStore>,383
+}384
+385
+impl UnsavedSession {386
+ pub async fn save(self) -> Result<Session, AccountError> {387
+ let saved = match session_record(&self.session.client) {388
+ Ok(record) => self.sessions.save(&record).await,389
+ Err(error) => Err(error),390
+ };391
+ match saved {392
+ Ok(()) => Ok(self.session),393
+ Err(source) => Err(AccountError::Unsaved {394
+ session: Box::new(self),395
+ source: Box::new(source),396
+ }),397
+ }398
+ }399
+400
+ /// the record to keep somewhere else, if the store won't take it401
+ pub fn record(&self) -> Result<SessionRecord, AccountError> {402
+ session_record(&self.session.client)403
+ }404
+}405
+406
+impl std::fmt::Debug for UnsavedSession {407
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {408
+ formatter409
+ .debug_struct("UnsavedSession")410
+ .finish_non_exhaustive()411
+ }412
+}413
+359414
impl Session {360415
pub async fn login_password(361416
login: PasswordLogin,362417
sessions: Arc<dyn SessionStore>,363418
) -> Result<Self, AccountError> {419
+ let store_lock = sessions.store().lock().await?;364420
let client = matrix_client_builder(sessions.store())365421
.server_name_or_homeserver_url(login.homeserver.trim())366422
.build()@@ -381,9 +437,10 @@ impl Session {
381437
.send()382438
.await383439
.map_err(|error| account_error("password login", error))?;384
- Self::saved(client, &*sessions, tracking).await440
+ Self::saved(client, sessions, tracking, store_lock).await385441
}386442
443
+ /// the pending login lives only in this process, so the one that begins it has to finish it387444
pub async fn begin_oauth(388445
login: OAuthLogin,389446
sessions: Arc<dyn SessionStore>,@@ -392,6 +449,7 @@ impl Session {
392449
.map_err(|error| account_error("parsing the OAuth redirect URI", error))?;393450
let client_uri = Url::parse(login.client_uri.trim())394451
.map_err(|error| account_error("parsing the OAuth client URI", error))?;452
+ let store_lock = sessions.store().lock().await?;395453
let client = matrix_client_builder(sessions.store())396454
.server_name_or_homeserver_url(login.homeserver.trim())397455
.build()@@ -428,7 +486,7 @@ impl Session {
428486
Ok(PendingOAuthLogin {429487
client,430488
sessions,431
- tracking: Some(tracking),489
+ tracking: Some((tracking, store_lock)),432490
authorization_url: authorization.url.to_string(),433491
state: Some(authorization.state),434492
})@@ -454,6 +512,7 @@ impl Session {
454512
})));455513
}456514
};515
+ let store_lock = sessions.store().lock().await?;457516
let client = matrix_client_builder(sessions.store())458517
.homeserver_url(&record.homeserver)459518
.build()@@ -464,7 +523,11 @@ impl Session {
464523
.restore_session(authentication)465524
.await466525
.map_err(|error| account_error("restoring the authenticated session", error))?;467
- Ok(Some(Restore::Resumed(Self { client, tracking })))526
+ Ok(Some(Restore::Resumed(Self {527
+ client,528
+ tracking,529
+ store_lock,530
+ })))468531
}469532
470533
/// leave the record to the host, which then saves `ClientHandle::session_record()`@@ -486,6 +549,7 @@ impl Session {
486549
config,487550
self.tracking.session_changes,488551
self.tracking.persistence,552
+ Some(self.store_lock),489553
)490554
.await491555
.map_err(AccountError::from)@@ -493,11 +557,20 @@ impl Session {
493557
494558
async fn saved(495559
client: Client,496
- sessions: &dyn SessionStore,560
+ sessions: Arc<dyn SessionStore>,497561
tracking: Tracking,562
+ store_lock: StoreLock,498563
) -> Result<Self, AccountError> {499
- sessions.save(&session_record(&client)?).await?;500
- Ok(Self { client, tracking })564
+ UnsavedSession {565
+ session: Self {566
+ client,567
+ tracking,568
+ store_lock,569
+ },570
+ sessions,571
+ }572
+ .save()573
+ .await501574
}502575
}503576
@@ -587,16 +660,16 @@ fn encryption_settings() -> EncryptionSettings {
587660
}588661
}589662
663
+/// `StoreLock` already keeps every other client off the store, and the sdk's lease would cost664
+/// a store write per locked section and a task renewing it every 50ms590665
fn matrix_client_builder(store: &StoreConfig) -> ClientBuilder {591
- let mut builder = Client::builder()666
+ let builder = Client::builder()592667
.with_encryption_settings(encryption_settings())593668
.with_threading_support(ThreadingSupport::Enabled {594669
with_subscriptions: false,595670
})596
- .handle_refresh_tokens();597
- if let Some(holder) = &store.lock_holder {598
- builder = builder.cross_process_store_config(CrossProcessLockConfig::multi_process(holder));599
- }671
+ .handle_refresh_tokens()672
+ .cross_process_store_config(CrossProcessLockConfig::SingleProcess);600673
match &store.location {601674
#[cfg(feature = "sqlite")]602675
StoreLocation::Sqlite(path) => builder.sqlite_store(path, Some(&store.passphrase)),@@ -25,6 +25,11 @@ use tokio::sync::{broadcast, mpsc, watch};
2525
pub(crate) use media::MediaService;2626
pub(crate) use session::Persistence;2727
28
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]29
+pub(crate) use crate::store_lock::StoreLock;30
+#[cfg(not(any(feature = "sqlite", feature = "indexeddb")))]31
+pub(crate) enum StoreLock {}32
+2833
use self::{2934
commands::Job,3035
executor::{EngineBoxFuture, TaskSet},@@ -273,6 +278,8 @@ pub struct Engine {
273278
stops: mpsc::UnboundedReceiver<Termination>,274279
logouts: Arc<watch::Sender<Logouts>>,275280
preparations: prepare::Preparations,281
+ /// shared with every `ClientHandle`, so the store stays locked while the client is reachable282
+ _store_lock: Option<Arc<StoreLock>>,276283
}277284
278285
impl Engine {@@ -282,7 +289,7 @@ impl Engine {
282289
config: EngineConfig,283290
) -> Result<(Self, ClientHandle, EventStream), EngineError> {284291
let session_changes = client.subscribe_to_session_changes();285
- Self::with_persistence(client, config, session_changes, None).await292
+ Self::with_persistence(client, config, session_changes, None, None).await286293
}287294
288295
/// `session_changes` is taken when the client is built, since the sdk can refresh@@ -292,6 +299,7 @@ impl Engine {
292299
config: EngineConfig,293300
session_changes: broadcast::Receiver<SessionChange>,294301
persistence: Option<Persistence>,302
+ store_lock: Option<StoreLock>,295303
) -> Result<(Self, ClientHandle, EventStream), EngineError> {296304
if client.user_id().is_none() {297305
return Err(EngineError::Unauthenticated);@@ -309,6 +317,7 @@ impl Engine {
309317
.await310318
.map_err(EngineError::SyncService)?,311319
);320
+ let store_lock = store_lock.map(Arc::new);312321
let media = Arc::new(MediaService::new(313322
client.clone(),314323
config.max_media_bytes,@@ -318,6 +327,7 @@ impl Engine {
318327
client.clone(),319328
sync.clone(),320329
media.clone(),330
+ store_lock.clone(),321331
config.command_capacity,322332
config.event_capacity,323333
);@@ -360,6 +370,7 @@ impl Engine {
360370
stop,361371
stops,362372
logouts: Arc::new(watch::Sender::new(Logouts::default())),373
+ _store_lock: store_lock,363374
};364375
Ok((engine, handle, events))365376
}@@ -22,11 +22,13 @@ compile_error!("indexeddb only exists in a browser, use sqlite");
2222
mod account;2323
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]2424
mod session_store;25
+#[cfg(any(feature = "sqlite", feature = "indexeddb"))]26
+mod store_lock;2527
2628
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]2729
pub use account::{2830
AccountError, OAuthApplicationType, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore,29
- Session, SessionRecord, SoftLogout, StoreConfig,31
+ Session, SessionRecord, SoftLogout, StoreConfig, UnsavedSession,3032
};3133
#[cfg(any(feature = "sqlite", feature = "indexeddb"))]3234
pub use session_store::{EncryptedSessionStore, SessionStore};@@ -487,6 +487,10 @@ pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
487487
}488488
}489489
}490
+ match fs::remove_file(path.join(crate::store_lock::LOCK_FILE)) {491
+ Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),492
+ _ => {}493
+ }490494
match fs::remove_dir(path) {491495
Err(error) if error.kind() != ErrorKind::NotFound => Err(error),492496
_ => Ok(()),@@ -0,0 +1,117 @@
1
+use crate::{AccountError, account::account_error};2
+3
+/// the platform's own exclusive lock on a store. matrix-sdk 0.18's cross process lease drops4
+/// itself silently after one failed renewal or a stall past 500ms, so it can't be what keeps a5
+/// second engine out6
+pub(crate) struct StoreLock {7
+ #[cfg(feature = "sqlite")]8
+ _file: std::fs::File,9
+ #[cfg(feature = "indexeddb")]10
+ release: js_sys::Function,11
+}12
+13
+#[cfg(feature = "sqlite")]14
+pub(crate) const LOCK_FILE: &str = "macltok.lock";15
+16
+#[cfg(feature = "sqlite")]17
+impl StoreLock {18
+ pub(crate) fn file(directory: &std::path::Path) -> Result<Self, AccountError> {19
+ use std::fs::{self, File, TryLockError};20
+21
+ fs::create_dir_all(directory)22
+ .map_err(|error| account_error("creating the Matrix store directory", error))?;23
+ let file = File::options()24
+ .create(true)25
+ .truncate(false)26
+ .write(true)27
+ .open(directory.join(LOCK_FILE))28
+ .map_err(|error| account_error("opening the Matrix store's lock file", error))?;29
+ match file.try_lock() {30
+ Ok(()) => Ok(Self { _file: file }),31
+ Err(TryLockError::WouldBlock) => Err(AccountError::StoreInUse),32
+ Err(TryLockError::Error(error)) => {33
+ Err(account_error("locking the Matrix store", error))34
+ }35
+ }36
+ }37
+}38
+39
+#[cfg(feature = "indexeddb")]40
+#[derive(Debug, thiserror::Error)]41
+enum WebLockError {42
+ #[error("this global scope has no Web Locks, they need a secure context")]43
+ Unavailable,44
+ #[error("requesting the Web Lock failed, {0}")]45
+ Failed(String),46
+}47
+48
+#[cfg(feature = "indexeddb")]49
+impl StoreLock {50
+ /// `ifAvailable` answers at once. the lock is ours until the promise we hand back settles51
+ pub(crate) async fn web(name: &str) -> Result<Self, AccountError> {52
+ use futures_util::future::{Either, select};53
+ use js_sys::{Function, Object, Promise, Reflect};54
+ use tokio::sync::oneshot;55
+ use wasm_bindgen::{JsCast, JsValue, closure::Closure};56
+57
+ let failed = |error: JsValue| {58
+ account_error(59
+ "locking the Matrix store",60
+ WebLockError::Failed(format!("{error:?}")),61
+ )62
+ };63
+ let locks = Reflect::get(&js_sys::global(), &JsValue::from_str("navigator"))64
+ .and_then(|navigator| Reflect::get(&navigator, &JsValue::from_str("locks")))65
+ .map_err(failed)?;66
+ if locks.is_undefined() {67
+ return Err(account_error(68
+ "locking the Matrix store",69
+ WebLockError::Unavailable,70
+ ));71
+ }72
+ let request: Function = Reflect::get(&locks, &JsValue::from_str("request"))73
+ .map_err(failed)?74
+ .dyn_into()75
+ .map_err(failed)?;76
+ let options = Object::new();77
+ Reflect::set(&options, &JsValue::from_str("ifAvailable"), &JsValue::TRUE)78
+ .map_err(failed)?;79
+80
+ let (granted, grant) = oneshot::channel();81
+ let callback = Closure::once(move |lock: JsValue| -> JsValue {82
+ if lock.is_null() {83
+ let _ = granted.send(None);84
+ return JsValue::UNDEFINED;85
+ }86
+ let mut release = None;87
+ let held = Promise::new(&mut |resolve, _| release = Some(resolve));88
+ let _ = granted.send(release);89
+ held.into()90
+ });91
+ let outcome: Promise = request92
+ .call3(93
+ &locks,94
+ &JsValue::from_str(&format!("macltok:{name}")),95
+ &options,96
+ callback.as_ref(),97
+ )98
+ .map_err(failed)?99
+ .dyn_into()100
+ .map_err(failed)?;101
+ match select(grant, wasm_bindgen_futures::JsFuture::from(outcome)).await {102
+ Either::Left((Ok(Some(release)), _)) => Ok(Self { release }),103
+ Either::Left((Ok(None), _)) => Err(AccountError::StoreInUse),104
+ Either::Left((Err(_), _)) | Either::Right((Ok(_), _)) => {105
+ Err(failed(JsValue::from_str("the lock callback never ran")))106
+ }107
+ Either::Right((Err(error), _)) => Err(failed(error)),108
+ }109
+ }110
+}111
+112
+#[cfg(feature = "indexeddb")]113
+impl Drop for StoreLock {114
+ fn drop(&mut self) {115
+ let _ = self.release.call0(&wasm_bindgen::JsValue::UNDEFINED);116
+ }117
+}@@ -35,7 +35,7 @@ use crate::{
3535
RoomNotificationSettings, RoomPowerLevels, RoomSummary, SpaceChild, TimelineCommand,3636
TimelineId, VerificationAction, VerificationTarget,3737
encryption::encryption_state,38
- engine::MediaService,38
+ engine::{MediaService, StoreLock},3939
model::{PushNotificationRoom, PushNotificationSender},4040
normalise::{member_summary, room_summaries, space_child},4141
};@@ -49,6 +49,7 @@ pub struct ClientHandle {
4949
sync_service: Arc<SyncService>,5050
media: Arc<MediaService>,5151
spaces: Arc<OnceCell<SpaceService>>,52
+ _store_lock: Option<Arc<StoreLock>>,5253
}5354
5455
impl fmt::Debug for ClientHandle {@@ -1006,6 +1007,7 @@ pub(crate) fn transport_pair(
10061007
client: Client,10071008
sync_service: Arc<SyncService>,10081009
media: Arc<MediaService>,1010
+ store_lock: Option<Arc<StoreLock>>,10091011
command_capacity: usize,10101012
event_capacity: usize,10111013
) -> (ClientHandle, DriverPort, EventStream) {@@ -1021,6 +1023,7 @@ pub(crate) fn transport_pair(
10211023
sync_service,10221024
media,10231025
spaces: Arc::new(OnceCell::new()),1026
+ _store_lock: store_lock,10241027
},10251028
DriverPort {10261029
requests: request_rx,@@ -4,8 +4,8 @@ mod support;
44
use std::{path::PathBuf, sync::Arc, time::SystemTime};55
66
use macltok::{7
- EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session, SessionRecord,8
- SessionStore, StoreConfig,7
+ AccountError, EncryptedSessionStore, EngineConfig, Event, PasswordLogin, Restore, Session,8
+ SessionRecord, SessionStore, StoreConfig,99
};1010
use matrix_sdk::{ruma::api::client::sync::sync_events::v5, test_utils::mocks::MatrixMockServer};1111
@@ -121,6 +121,82 @@ async fn a_restart_after_a_soft_logout_still_knows_the_device() {
121121
}122122
123123
#[tokio::test]124
+async fn a_login_whose_first_save_fails_can_still_be_saved() {125
+ let server = MatrixMockServer::new().await;126
+ server.mock_versions().ok().mount().await;127
+ server.mock_login().ok().mount().await;128
+ let root = temporary_path();129
+ let records = root.join("records");130
+ std::fs::create_dir_all(&records).unwrap();131
+ let sessions = || {132
+ EncryptedSessionStore::file(133
+ records.join("session"),134
+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),135
+ )136
+ };137
+ let writable = std::fs::metadata(&records).unwrap().permissions();138
+ let mut read_only = writable.clone();139
+ read_only.set_readonly(true);140
+ std::fs::set_permissions(&records, read_only).unwrap();141
+142
+ let login = Session::login_password(143
+ PasswordLogin::new(server.uri(), "example", "wordpass"),144
+ Arc::new(sessions()),145
+ )146
+ .await;147
+ let Err(AccountError::Unsaved { session, .. }) = login else {148
+ panic!("a login that couldn't save its record didn't hand the session back");149
+ };150
+ std::fs::set_permissions(&records, writable).unwrap();151
+ drop(session.save().await.unwrap());152
+ assert!(matches!(153
+ Session::restore(Arc::new(sessions())).await.unwrap(),154
+ Some(Restore::Resumed(_))155
+ ));156
+ std::fs::remove_dir_all(root).unwrap();157
+}158
+159
+#[tokio::test]160
+async fn a_store_takes_one_session_at_a_time() {161
+ let server = MatrixMockServer::new().await;162
+ server.mock_versions().ok().mount().await;163
+ server.mock_login().ok().mount().await;164
+ let root = temporary_path();165
+ let sessions = || {166
+ EncryptedSessionStore::file(167
+ root.join("session"),168
+ StoreConfig::sqlite(root.join("store"), "test store passphrase"),169
+ )170
+ };171
+ let (engine, handle, events) = Session::login_password(172
+ PasswordLogin::new(server.uri(), "example", "wordpass"),173
+ Arc::new(sessions()),174
+ )175
+ .await176
+ .unwrap()177
+ .build(EngineConfig::default())178
+ .await179
+ .unwrap();180
+181
+ assert!(matches!(182
+ Session::restore(Arc::new(sessions())).await,183
+ Err(AccountError::StoreInUse)184
+ ));185
+ drop((engine, events));186
+ assert!(matches!(187
+ Session::restore(Arc::new(sessions())).await,188
+ Err(AccountError::StoreInUse)189
+ ));190
+ drop(handle);191
+ let resumed = Session::restore(Arc::new(sessions())).await.unwrap();192
+ assert!(matches!(resumed, Some(Restore::Resumed(_))));193
+ drop(resumed);194
+ sessions().discard().await.unwrap();195
+ assert!(!root.join("store").exists());196
+ std::fs::remove_dir_all(root).unwrap();197
+}198
+199
+#[tokio::test]124200
async fn a_torn_or_failed_save_leaves_the_old_record() {125201
let root = temporary_path();126202
let record_path = root.join("session");