Commit 6db49c0ecf71
tarball6 files changed+268-89
@@ -1828,6 +1828,7 @@ dependencies = [
18281828
"getrandom 0.4.3",18291829
"http",18301830
"imbl",1831
+ "js-sys",18311832
"matrix-sdk",18321833
"matrix-sdk-crypto",18331834
"matrix-sdk-store-encryption",@@ -1845,6 +1846,9 @@ dependencies = [
18451846
"tracing",18461847
"url",18471848
"uuid",1849
+ "wasm-bindgen",1850
+ "wasm-bindgen-futures",1851
+ "web-sys",18481852
"wiremock",18491853
]18501854
@@ -16,6 +16,10 @@ indexeddb = [
1616
"dep:url",1717
"dep:matrix-sdk-store-encryption",1818
"dep:indexed_db_futures",19
+ "dep:js-sys",20
+ "dep:wasm-bindgen",21
+ "dep:wasm-bindgen-futures",22
+ "dep:web-sys",1923
]2024
2125
[dependencies]@@ -40,11 +44,21 @@ getrandom = { version = "0.2", features = ["js"] }
4044
getrandom3 = { package = "getrandom", version = "0.3", features = ["wasm_js"] }4145
getrandom4 = { package = "getrandom", version = "0.4", features = ["wasm_js"] }4246
indexed_db_futures = { package = "matrix_indexed_db_futures", version = "0.7.0", optional = true, features = ["typed-arrays"] }47
+js-sys = { version = "0.3.105", optional = true }4348
matrix-sdk = { version = "0.18.0", default-features = false, features = ["js"] }4449
matrix-sdk-ui = { version = "0.18.0", default-features = false, features = ["js"] }4550
# the sdk's own wasm reqwest leaves out stream, which the bounded media download needs4651
reqwest = { version = "0.13.1", default-features = false, features = ["stream"] }4752
uuid = { version = "1", features = ["js"] }53
+wasm-bindgen = { version = "0.2.128", optional = true }54
+wasm-bindgen-futures = { version = "0.4.78", optional = true }55
+web-sys = { version = "0.3.105", optional = true, features = [56
+ "IdbFactory",57
+ "IdbOpenDbRequest",58
+ "IdbRequest",59
+ "Window",60
+ "WorkerGlobalScope",61
+] }4862
4963
[dev-dependencies]5064
matrix-sdk = { version = "0.18.0", default-features = false, features = ["testing"] }@@ -128,6 +128,9 @@ what's different in a browser
128128
restarts itself with backoff, that part is ours129129
- two tabs or workers on one store each need `StoreConfig::with_lock_holder`130130
with a name of their own, or they both think they hold the crypto store131
+- `discard` while another tab still has the store open answers132
+ `StoreDeletePending`. the delete lands once that tab lets go, and reopening133
+ the store waits behind it131134
132135
## Dependency policy133136
@@ -245,7 +245,7 @@ impl StoreConfig {
245245
#[cfg(feature = "indexeddb")]246246
StoreLocation::IndexedDb(name) => crate::session_store::delete_indexeddb_store(name)247247
.await248
- .map_err(|error| account_error("deleting the Matrix store", error)),248
+ .map_err(|error| error.into_account_error("deleting the Matrix store")),249249
}250250
}251251
@@ -348,6 +348,10 @@ pub enum AccountError {
348348
UnsupportedAuthentication,349349
#[error("the OAuth callback was already submitted")]350350
OAuthCallbackAlreadySubmitted,351
+ /// an IndexedDB delete is waiting on the store's other connections. it goes through352
+ /// once they close, and opening the store again waits behind it353
+ #[error("the store is still open elsewhere, it will be deleted once that closes")]354
+ StoreDeletePending,351355
#[error(transparent)]352356
Engine(#[from] EngineError),353357
}@@ -3,7 +3,6 @@ use std::path::{Path, PathBuf};
33
44
use matrix_sdk::{BoxFuture, SendOutsideWasm, SyncOutsideWasm};55
use matrix_sdk_store_encryption::StoreCipher;6
-use tokio::sync::Mutex;76
87
use crate::{AccountError, SessionRecord, StoreConfig, account::account_error};98
@@ -12,6 +11,11 @@ type RecordLocation = PathBuf;
1211
#[cfg(feature = "indexeddb")]1312
type RecordLocation = String;1413
14
+#[cfg(not(target_family = "wasm"))]15
+type Lock<T> = std::sync::Mutex<T>;16
+#[cfg(target_family = "wasm")]17
+type Lock<T> = tokio::sync::Mutex<T>;18
+1519
/// one login's record and the matrix store it belongs to. the engine rotates refresh tokens1620
/// and each works once, so a `save` has to land before the next refresh or the login is gone1721
pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {@@ -43,7 +47,7 @@ pub trait SessionStore: SendOutsideWasm + SyncOutsideWasm {
4347
pub struct EncryptedSessionStore {4448
record: RecordLocation,4549
store: StoreConfig,46
- sealer: Mutex<Option<Sealer>>,50
+ sealer: Lock<Option<Sealer>>,4751
}4852
4953
struct Sealer {@@ -70,50 +74,126 @@ impl EncryptedSessionStore {
7074
Self {7175
record,7276
store,73
- sealer: Mutex::new(None),77
+ sealer: Lock::new(None),7478
}7579
}7680
77
- async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {78
- let mut sealer = self.sealer.lock().await;79
- self.read(&mut sealer).await81
+ fn open(82
+ &self,83
+ sealer: &mut Option<Sealer>,84
+ sealed: Option<Vec<u8>>,85
+ ) -> Result<Option<SessionRecord>, AccountError> {86
+ let Some(sealed) = sealed else {87
+ return Ok(None);88
+ };89
+ let (opened, record) = unseal(self.store.passphrase(), &sealed)?;90
+ *sealer = Some(opened);91
+ Ok(Some(record))8092
}8193
82
- async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {83
- let mut sealer = self.sealer.lock().await;84
- self.write(&mut sealer, record).await94
+ fn seal(95
+ &self,96
+ sealer: &mut Option<Sealer>,97
+ record: &SessionRecord,98
+ ) -> Result<Vec<u8>, AccountError> {99
+ let sealer = match sealer {100
+ Some(sealer) => sealer,101
+ empty => empty.insert(Sealer::new(self.store.passphrase())?),102
+ };103
+ sealer.seal(record)85104
}105
+}86106
87
- async fn soft_log_out_record(&self) -> Result<(), AccountError> {88
- let mut sealer = self.sealer.lock().await;89
- match self.read(&mut sealer).await? {90
- Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,107
+#[cfg(not(target_family = "wasm"))]108
+impl EncryptedSessionStore {109
+ fn lock(&self) -> std::sync::MutexGuard<'_, Option<Sealer>> {110
+ self.sealer111
+ .lock()112
+ .unwrap_or_else(std::sync::PoisonError::into_inner)113
+ }114
+115
+ fn read(&self, sealer: &mut Option<Sealer>) -> Result<Option<SessionRecord>, AccountError> {116
+ let sealed = backend::read(&self.record)117
+ .map_err(|error| account_error("reading the session record", error))?;118
+ self.open(sealer, sealed)119
+ }120
+121
+ fn write(122
+ &self,123
+ sealer: &mut Option<Sealer>,124
+ record: &SessionRecord,125
+ ) -> Result<(), AccountError> {126
+ let sealed = self.seal(sealer, record)?;127
+ backend::write(&self.record, &sealed)128
+ .map_err(|error| account_error("writing the session record", error))129
+ }130
+131
+ fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {132
+ self.read(&mut self.lock())133
+ }134
+135
+ fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {136
+ self.write(&mut self.lock(), record)137
+ }138
+139
+ fn soft_log_out_record(&self) -> Result<(), AccountError> {140
+ let mut sealer = self.lock();141
+ match self.read(&mut sealer)? {142
+ Some(record) => self.write(&mut sealer, &record.soft_logged_out()),91143
None => Ok(()),92144
}93145
}94146
95
- async fn discard_all(&self) -> Result<(), AccountError> {96
- let mut sealer = self.sealer.lock().await;147
+ fn forget_record(&self) -> Result<(), AccountError> {148
+ let mut sealer = self.lock();97149
backend::remove(&self.record)98
- .await99150
.map_err(|error| account_error("removing the session record", error))?;100151
*sealer = None;101
- self.store.delete().await152
+ Ok(())153
+ }154
+}155
+156
+#[cfg(not(target_family = "wasm"))]157
+impl SessionStore for EncryptedSessionStore {158
+ fn store(&self) -> &StoreConfig {159
+ &self.store102160
}103161
162
+ fn load(&self) -> BoxFuture<'_, Result<Option<SessionRecord>, AccountError>> {163
+ Box::pin(async move { self.load_record() })164
+ }165
+166
+ fn save<'a>(&'a self, record: &'a SessionRecord) -> BoxFuture<'a, Result<(), AccountError>> {167
+ Box::pin(async move { self.save_record(record) })168
+ }169
+170
+ /// blocks the calling worker for the write and its fsyncs, about a millisecond171
+ fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {172
+ Some(self.save_record(record))173
+ }174
+175
+ fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {176
+ Box::pin(async move { self.soft_log_out_record() })177
+ }178
+179
+ fn discard(&self) -> BoxFuture<'_, Result<(), AccountError>> {180
+ Box::pin(async move {181
+ self.forget_record()?;182
+ self.store.delete().await183
+ })184
+ }185
+}186
+187
+#[cfg(target_family = "wasm")]188
+impl EncryptedSessionStore {104189
async fn read(105190
&self,106191
sealer: &mut Option<Sealer>,107192
) -> Result<Option<SessionRecord>, AccountError> {108
- let Some(sealed) = backend::read(&self.record)193
+ let sealed = backend::read(&self.record)109194
.await110
- .map_err(|error| account_error("reading the session record", error))?111
- else {112
- return Ok(None);113
- };114
- let (opened, record) = unseal(self.store.passphrase(), &sealed)?;115
- *sealer = Some(opened);116
- Ok(Some(record))195
+ .map_err(|error| account_error("reading the session record", error))?;196
+ self.open(sealer, sealed)117197
}118198
119199
async fn write(@@ -127,30 +207,33 @@ impl EncryptedSessionStore {
127207
.map_err(|error| account_error("writing the session record", error))128208
}129209
130
- fn seal(131
- &self,132
- sealer: &mut Option<Sealer>,133
- record: &SessionRecord,134
- ) -> Result<Vec<u8>, AccountError> {135
- let sealer = match sealer {136
- Some(sealer) => sealer,137
- empty => empty.insert(Sealer::new(self.store.passphrase())?),138
- };139
- sealer.seal(record)210
+ async fn load_record(&self) -> Result<Option<SessionRecord>, AccountError> {211
+ self.read(&mut *self.sealer.lock().await).await140212
}141213
142
- /// the lock is only ever held around one small write, so losing the race to it just143
- /// hands this refresh to the async save144
- #[cfg(feature = "sqlite")]145
- fn save_now(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {146
- let mut sealer = self.sealer.try_lock().ok()?;147
- Some(self.seal(&mut sealer, record).and_then(|sealed| {148
- backend::write_blocking(&self.record, &sealed)149
- .map_err(|error| account_error("writing the session record", error))150
- }))214
+ async fn save_record(&self, record: &SessionRecord) -> Result<(), AccountError> {215
+ self.write(&mut *self.sealer.lock().await, record).await216
+ }217
+218
+ async fn soft_log_out_record(&self) -> Result<(), AccountError> {219
+ let mut sealer = self.sealer.lock().await;220
+ match self.read(&mut sealer).await? {221
+ Some(record) => self.write(&mut sealer, &record.soft_logged_out()).await,222
+ None => Ok(()),223
+ }224
+ }225
+226
+ async fn discard_all(&self) -> Result<(), AccountError> {227
+ let mut sealer = self.sealer.lock().await;228
+ backend::remove(&self.record)229
+ .await230
+ .map_err(|error| error.into_account_error("removing the session record"))?;231
+ *sealer = None;232
+ self.store.delete().await151233
}152234
}153235
236
+#[cfg(target_family = "wasm")]154237
impl SessionStore for EncryptedSessionStore {155238
fn store(&self) -> &StoreConfig {156239
&self.store@@ -164,11 +247,6 @@ impl SessionStore for EncryptedSessionStore {
164247
Box::pin(self.save_record(record))165248
}166249
167
- #[cfg(feature = "sqlite")]168
- fn save_blocking(&self, record: &SessionRecord) -> Option<Result<(), AccountError>> {169
- self.save_now(record)170
- }171
-172250
fn soft_log_out(&self) -> BoxFuture<'_, Result<(), AccountError>> {173251
Box::pin(self.soft_log_out_record())174252
}@@ -229,12 +307,16 @@ fn unseal(passphrase: &str, sealed: &[u8]) -> Result<(Sealer, SessionRecord), Ac
229307
#[cfg(feature = "sqlite")]230308
mod backend {231309
use std::{310
+ ffi::OsString,232311
fs::{self, File, OpenOptions},233312
io::{self, ErrorKind, Write},234313
path::{Path, PathBuf},314
+ sync::atomic::{AtomicU64, Ordering},235315
};236316
237
- pub(super) async fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {317
+ static WRITES: AtomicU64 = AtomicU64::new(0);318
+319
+ pub(super) fn read(path: &Path) -> io::Result<Option<Vec<u8>>> {238320
match fs::read(path) {239321
Ok(sealed) => Ok(Some(sealed)),240322
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),@@ -242,59 +324,68 @@ mod backend {
242324
}243325
}244326
245
- pub(super) async fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {246
- write_blocking(path, sealed)247
- }248
-249
- pub(super) fn write_blocking(path: &Path, sealed: &[u8]) -> io::Result<()> {250
- let mut temporary = path.as_os_str().to_owned();251
- temporary.push(".tmp");252
- let temporary = PathBuf::from(temporary);327
+ pub(super) fn write(path: &Path, sealed: &[u8]) -> io::Result<()> {253328
let parent = match path.parent() {254329
Some(parent) if !parent.as_os_str().is_empty() => parent,255330
_ => Path::new("."),256331
};257332
fs::create_dir_all(parent)?;258
- let written = replace(path, &temporary, parent, sealed);333
+ let (temporary, mut file) = temporary_file(path)?;334
+ let written = replace(path, &temporary, &mut file, parent, sealed);259335
if written.is_err() {260336
let _ = fs::remove_file(&temporary);261337
}262338
written263339
}264340
265
- fn replace(path: &Path, temporary: &Path, parent: &Path, sealed: &[u8]) -> io::Result<()> {266
- let mut file = private_file(temporary)?;341
+ fn temporary_file(path: &Path) -> io::Result<(PathBuf, File)> {342
+ loop {343
+ let mut name = OsString::from(path.as_os_str());344
+ let write = WRITES.fetch_add(1, Ordering::Relaxed);345
+ name.push(format!(".{}.{write}.tmp", std::process::id()));346
+ let temporary = PathBuf::from(name);347
+ match private_file(&temporary) {348
+ Ok(file) => return Ok((temporary, file)),349
+ Err(error) if error.kind() == ErrorKind::AlreadyExists => {}350
+ Err(error) => return Err(error),351
+ }352
+ }353
+ }354
+355
+ fn replace(356
+ path: &Path,357
+ temporary: &Path,358
+ file: &mut File,359
+ parent: &Path,360
+ sealed: &[u8],361
+ ) -> io::Result<()> {267362
file.write_all(sealed)?;268363
file.sync_all()?;269364
fs::rename(temporary, path)?;270365
sync_directory(parent)271366
}272367
273
- pub(super) async fn remove(path: &Path) -> io::Result<()> {368
+ pub(super) fn remove(path: &Path) -> io::Result<()> {274369
match fs::remove_file(path) {275370
Err(error) if error.kind() != ErrorKind::NotFound => Err(error),276371
_ => Ok(()),277372
}278373
}279374
375
+ /// `create_new` is `O_EXCL`, which posix says never follows a symlink, even a dangling one280376
#[cfg(unix)]281377
fn private_file(path: &Path) -> io::Result<File> {282378
use std::os::unix::fs::OpenOptionsExt;283379
OpenOptions::new()284380
.write(true)285
- .create(true)286
- .truncate(true)381
+ .create_new(true)287382
.mode(0o600)288383
.open(path)289384
}290385
291386
#[cfg(not(unix))]292387
fn private_file(path: &Path) -> io::Result<File> {293
- OpenOptions::new()294
- .write(true)295
- .create(true)296
- .truncate(true)297
- .open(path)388
+ OpenOptions::new().write(true).create_new(true).open(path)298389
}299390
300391
/// a rename isn't durable until its directory is synced@@ -334,20 +425,36 @@ mod backend {
334425
.await335426
}336427
428
+ /// the crate's `Database` never closes itself on drop, and an open one blocks deleting it337429
pub(super) async fn read(name: &str) -> Result<Option<Vec<u8>>, OpenDbError> {338430
let database = open(name).await?;339
- let sealed: Option<Uint8Array> = database431
+ let sealed = get(&database).await;432
+ database.close();433
+ Ok(sealed?.map(Into::into))434
+ }435
+436
+ pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {437
+ let database = open(name).await?;438
+ let written = put(&database, sealed).await;439
+ database.close();440
+ Ok(written?)441
+ }442
+443
+ pub(super) async fn remove(name: &str) -> Result<(), super::DeleteError> {444
+ super::delete_database(name).await445
+ }446
+447
+ async fn get(database: &Database) -> Result<Option<Uint8Array>, Error> {448
+ database340449
.transaction(RECORDS)341450
.with_mode(TransactionMode::Readonly)342451
.build()?343452
.object_store(RECORDS)?344453
.get(KEY)345
- .await?;346
- Ok(sealed.map(Into::into))454
+ .await347455
}348456
349
- pub(super) async fn write(name: &str, sealed: &[u8]) -> Result<(), OpenDbError> {350
- let database = open(name).await?;457
+ async fn put(database: &Database, sealed: &[u8]) -> Result<(), Error> {351458
let transaction = database352459
.transaction(RECORDS)353460
.with_mode(TransactionMode::Readwrite)@@ -357,11 +464,7 @@ mod backend {
357464
.put(Uint8ArraySlice::new(sealed))358465
.with_key(KEY)359466
.build()?;360
- Ok(transaction.commit().await?)361
- }362
-363
- pub(super) async fn remove(name: &str) -> Result<(), OpenDbError> {364
- Ok(Database::delete_by_name(name)?.await?)467
+ transaction.commit().await365468
}366469
}367470
@@ -391,11 +494,7 @@ pub(crate) fn delete_sqlite_store(path: &Path) -> std::io::Result<()> {
391494
}392495
393496
#[cfg(feature = "indexeddb")]394
-pub(crate) async fn delete_indexeddb_store(395
- name: &str,396
-) -> Result<(), indexed_db_futures::error::OpenDbError> {397
- use indexed_db_futures::database::Database;398
-497
+pub(crate) async fn delete_indexeddb_store(name: &str) -> Result<(), DeleteError> {399498
// the databases matrix-sdk-indexeddb 0.18 opens under one store name400499
for suffix in [401500
"",@@ -405,7 +504,59 @@ pub(crate) async fn delete_indexeddb_store(
405504
"::event_cache",406505
"::media",407506
] {408
- Database::delete_by_name(&format!("{name}{suffix}"))?.await?;507
+ delete_database(&format!("{name}{suffix}")).await?;409508
}410509
Ok(())411510
}511
+512
+#[cfg(feature = "indexeddb")]513
+#[derive(Debug, thiserror::Error)]514
+pub(crate) enum DeleteError {515
+ #[error("the database is still open somewhere, it goes once that closes")]516
+ Blocked,517
+ #[error("this global scope has no IndexedDB")]518
+ Unavailable,519
+ #[error("deleting the database failed, {0}")]520
+ Failed(String),521
+}522
+523
+#[cfg(feature = "indexeddb")]524
+impl DeleteError {525
+ pub(crate) fn into_account_error(self, context: &'static str) -> AccountError {526
+ match self {527
+ Self::Blocked => AccountError::StoreDeletePending,528
+ error => account_error(context, error),529
+ }530
+ }531
+}532
+533
+/// matrix_indexed_db_futures 0.7 ignores `blocked` on a delete, which then waits forever534
+#[cfg(feature = "indexeddb")]535
+async fn delete_database(name: &str) -> Result<(), DeleteError> {536
+ use wasm_bindgen::{JsCast, JsValue};537
+538
+ const BLOCKED: &str = "blocked";539
+ let global = js_sys::global();540
+ let factory = if let Some(window) = global.dyn_ref::<web_sys::Window>() {541
+ window.indexed_db()542
+ } else if let Some(worker) = global.dyn_ref::<web_sys::WorkerGlobalScope>() {543
+ worker.indexed_db()544
+ } else {545
+ return Err(DeleteError::Unavailable);546
+ };547
+ let factory = factory.ok().flatten().ok_or(DeleteError::Unavailable)?;548
+ let request = factory549
+ .delete_database(name)550
+ .map_err(|error| DeleteError::Failed(format!("{error:?}")))?;551
+ let settled = js_sys::Promise::new(&mut |resolve, reject| {552
+ request.set_onsuccess(Some(&resolve));553
+ request.set_onerror(Some(&reject));554
+ let blocked = reject.bind1(&JsValue::NULL, &JsValue::from_str(BLOCKED));555
+ request.set_onblocked(Some(blocked.unchecked_ref()));556
+ });557
+ match wasm_bindgen_futures::JsFuture::from(settled).await {558
+ Ok(_) => Ok(()),559
+ Err(reason) if reason.as_string().as_deref() == Some(BLOCKED) => Err(DeleteError::Blocked),560
+ Err(_) => Err(DeleteError::Failed(format!("{:?}", request.error()))),561
+ }562
+}@@ -130,7 +130,7 @@ async fn a_torn_or_failed_save_leaves_the_old_record() {
130130
StoreConfig::sqlite(root.join("store"), "test store passphrase"),131131
)132132
};133
- let temporary = root.join("session.tmp");133
+ let temporary = root.join("session.1.0.tmp");134134
sessions()135135
.save(&record("https://old.example"))136136
.await@@ -140,8 +140,10 @@ async fn a_torn_or_failed_save_leaves_the_old_record() {
140140
let loaded = sessions().load().await.unwrap().unwrap();141141
assert_eq!(loaded.homeserver(), "https://old.example");142142
143
- std::fs::remove_file(&temporary).unwrap();144
- std::fs::create_dir(&temporary).unwrap();143
+ let writable = std::fs::metadata(&root).unwrap().permissions();144
+ let mut read_only = writable.clone();145
+ read_only.set_readonly(true);146
+ std::fs::set_permissions(&root, read_only).unwrap();145147
assert!(146148
sessions()147149
.save(&record("https://new.example"))@@ -151,6 +153,7 @@ async fn a_torn_or_failed_save_leaves_the_old_record() {
151153
let loaded = sessions().load().await.unwrap().unwrap();152154
assert_eq!(loaded.homeserver(), "https://old.example");153155
156
+ std::fs::set_permissions(&root, writable).unwrap();154157
std::fs::remove_dir_all(root).unwrap();155158
}