fix(core,app,android,cli,packaging): harden account sign-in and watch-history sync

- Desktop shows Proton's CAPTCHA instead of failing the sign-in: a wry webview
  in a child process of the app (`pstr-app::verify`), confined to
  https://*.proton.me, hands the token back and the sign-in is retried. Linux
  packages and CI gain WebKitGTK 4.1; the Windows zip and MSI ship
  WebView2Loader.dll, without which a -gnu build does not start.
- The desktop keyring splits a secret too long for one entry across several,
  so a Proton session with many key salts survives Windows' 1280-unit cap.
  Short secrets stay one entry, so every existing secret still reads.
- `WatchSync` runs against a small `HistoryDrive` trait and is tested end to
  end against an in-memory Drive: two devices, newer wins, no re-upload of an
  unchanged history, a missing file written again, an unreadable own file
  never overwritten, a newer-format file left alone.
- An installation's file unwritten for 90 days is retired by the next device
  to sync, after its entries are merged and carried in that device's file.
- Removing a share keeps its watch state: sync would bring it back from the
  other devices anyway, and re-adding the share picks up where it left off.
- Watch history syncs once more on desktop exit (bounded to 3 s) and, on
  Android, through a WorkManager job enqueued when the app leaves the screen.
- The second-factor prompts say security keys are not supported.
This commit is contained in:
nvrl committed 2026-10-06 14:30:15 +02:00
1 parent 26cccd5c63
commit d9ca8f8d60
28 files changed
+2475 -186

No files matched your search

+39 -9
View File
@@ -513,19 +513,17 @@ impl Catalog {
Ok(rows.collect::<std::result::Result<_, _>>()?)
}
/// Drop everything a share contributed, watch positions included.
/// Drop everything a share contributed except where it was watched.
///
/// Unlike a recrawl — which replaces rows and deliberately leaves watch
/// state alone — this is the viewer saying they are done with the share.
/// Keeping positions for files they can no longer reach would only leave
/// the "continue watching" row pointing at nothing.
/// Watch state stays, as through a recrawl. It is history, and not only
/// this device's: with an account signed in, the next sync would bring it
/// straight back from the other devices' files, so deleting it here would
/// only make it flicker. Nothing shows it — the library pairs positions
/// with files, and the files are gone — until the share is added again,
/// when it picks up where it left off.
pub fn remove_share(&mut self, share_id: &str) -> Result<()> {
let tx = self.conn.transaction()?;
tx.execute("DELETE FROM nodes WHERE share_id = ?1", params![share_id])?;
tx.execute(
"DELETE FROM watch_state WHERE share_id = ?1",
params![share_id],
)?;
tx.execute(
"DELETE FROM offline_files WHERE share_id = ?1",
params![share_id],
@@ -2430,6 +2428,38 @@ mod tests {
assert_eq!(rows[0].link_id, "film");
}
#[test]
fn removing_a_share_keeps_its_watch_state_out_of_sight() {
let mut catalog = Catalog::in_memory().expect("open");
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("crawl");
let state = WatchState {
position_secs: 120.0,
duration_secs: Some(1440.0),
watched: false,
updated_at: 10,
};
catalog.set_watch_state("s1", "a", &state).expect("set");
catalog.remove_share("s1").expect("remove");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert!(library.titles.is_empty());
assert_eq!(catalog.watch_state("s1", "a").expect("read"), Some(state));
catalog
.replace_share("s1", &[file("s1", "a", "Show S01E01.mkv")])
.expect("re-add");
let library = crate::library::Library::build(
catalog.all_files().expect("files"),
&catalog.all_watch_states().expect("watch"),
);
assert_eq!(library.titles[0].seasons[0].episodes[0].watch, Some(state));
}
#[test]
fn merging_takes_only_states_newer_than_the_recorded_ones() {
let mut catalog = Catalog::in_memory().expect("open");
+208 -2
View File
@@ -55,19 +55,26 @@ pub trait SecretStore: Send + Sync {
}
/// The desktop OS credential store.
///
/// A value too long for one entry is split across several; see
/// [`ENTRY_UNITS`].
#[cfg(not(target_os = "android"))]
#[derive(Debug, Default)]
pub struct KeyringSecretStore;
/// One credential-store entry per key, with no splitting.
#[cfg(not(target_os = "android"))]
impl KeyringSecretStore {
struct KeyringEntries;
#[cfg(not(target_os = "android"))]
impl KeyringEntries {
fn entry(key: &str) -> Result<keyring::Entry> {
Ok(keyring::Entry::new(KEYRING_SERVICE, key)?)
}
}
#[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore {
impl SecretStore for KeyringEntries {
fn set(&self, key: &str, value: &str) -> Result<()> {
Self::entry(key)?.set_password(value)?;
Ok(())
@@ -89,6 +96,205 @@ impl SecretStore for KeyringSecretStore {
}
}
#[cfg(not(target_os = "android"))]
impl SecretStore for KeyringSecretStore {
fn set(&self, key: &str, value: &str) -> Result<()> {
chunked::set(&KeyringEntries, key, value)
}
fn get(&self, key: &str) -> Result<Option<String>> {
chunked::get(&KeyringEntries, key)
}
fn delete(&self, key: &str) -> Result<()> {
chunked::delete(&KeyringEntries, key)
}
}
/// The most UTF-16 code units one credential-store entry is given.
///
/// Windows caps a credential's blob at 2560 bytes, and `keyring` stores it as
/// UTF-16: 1280 units. A share secret is far below that, but the account
/// session — tokens, scopes, the mailbox password and a salt per key — can
/// pass it, and failing to store a refreshed token signs the viewer out at
/// the next launch. The margin is for the header.
#[cfg(any(not(target_os = "android"), test))]
const ENTRY_UNITS: usize = 1200;
/// Splitting a long secret across several entries of an underlying store.
///
/// A short value is one entry, exactly as before splitting existed, so every
/// secret already stored still reads. A long one is written as `key#0`,
/// `key#1`, … with `key` itself holding [`HEADER`] and the count. The parts go
/// first and the header last, so a reader never finds a header naming parts
/// that are not there yet.
#[cfg(any(not(target_os = "android"), test))]
mod chunked {
use super::{ENTRY_UNITS, SecretStore};
use crate::error::Result;
/// Marks a split value. A short value that happens to start with it is
/// split too, so it cannot be mistaken for a header.
const HEADER: &str = "pstr-chunked:";
fn part(key: &str, index: usize) -> String {
format!("{key}#{index}")
}
fn parts_of(header: &str) -> Option<usize> {
header.strip_prefix(HEADER)?.parse().ok()
}
/// `value` cut into pieces of at most [`ENTRY_UNITS`] UTF-16 units, never
/// inside a character.
fn split(value: &str) -> Vec<&str> {
let mut pieces = Vec::new();
let mut start = 0;
let mut units = 0;
for (at, ch) in value.char_indices() {
if units + ch.len_utf16() > ENTRY_UNITS {
pieces.push(&value[start..at]);
start = at;
units = 0;
}
units += ch.len_utf16();
}
pieces.push(&value[start..]);
pieces
}
pub(super) fn set(store: &dyn SecretStore, key: &str, value: &str) -> Result<()> {
let before = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
let fits = value.encode_utf16().count() <= ENTRY_UNITS && !value.starts_with(HEADER);
let written = if fits {
store.set(key, value)?;
0
} else {
let pieces = split(value);
for (index, piece) in pieces.iter().enumerate() {
store.set(&part(key, index), piece)?;
}
store.set(key, &format!("{HEADER}{}", pieces.len()))?;
pieces.len()
};
for index in written..before {
store.delete(&part(key, index))?;
}
Ok(())
}
pub(super) fn get(store: &dyn SecretStore, key: &str) -> Result<Option<String>> {
let Some(value) = store.get(key)? else {
return Ok(None);
};
let Some(count) = parts_of(&value) else {
return Ok(Some(value));
};
let mut joined = String::new();
for index in 0..count {
// A part gone missing is a value that no longer exists whole;
// half of a token is worse than none.
let Some(piece) = store.get(&part(key, index))? else {
return Ok(None);
};
joined.push_str(&piece);
}
Ok(Some(joined))
}
pub(super) fn delete(store: &dyn SecretStore, key: &str) -> Result<()> {
let count = store.get(key)?.as_deref().and_then(parts_of).unwrap_or(0);
store.delete(key)?;
for index in 0..count {
store.delete(&part(key, index))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use parking_lot::Mutex;
use super::*;
/// An underlying store that refuses what Windows would.
#[derive(Default)]
struct Limited(Mutex<BTreeMap<String, String>>);
impl SecretStore for Limited {
fn set(&self, key: &str, value: &str) -> Result<()> {
assert!(
value.encode_utf16().count() <= 1280,
"{key} is too long for one entry"
);
self.0.lock().insert(key.to_owned(), value.to_owned());
Ok(())
}
fn get(&self, key: &str) -> Result<Option<String>> {
Ok(self.0.lock().get(key).cloned())
}
fn delete(&self, key: &str) -> Result<()> {
self.0.lock().remove(key);
Ok(())
}
}
#[test]
fn a_short_secret_is_one_entry_as_it_always_was() {
let store = Limited::default();
set(&store, "share-a", "fragment").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "share-a").unwrap().as_deref(), Some("fragment"));
}
#[test]
fn a_secret_longer_than_an_entry_round_trips() {
let store = Limited::default();
let long: String = "aé😀".repeat(1500);
set(&store, "proton-account", &long).unwrap();
assert!(store.0.lock().len() > 2);
assert_eq!(get(&store, "proton-account").unwrap(), Some(long));
}
#[test]
fn a_shorter_value_leaves_no_parts_of_the_longer_one_behind() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
set(&store, "k", &"y".repeat(1300)).unwrap();
assert_eq!(store.0.lock().len(), 3);
set(&store, "k", "short").unwrap();
assert_eq!(store.0.lock().len(), 1);
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("short"));
}
#[test]
fn a_value_that_looks_like_a_header_is_not_taken_for_one() {
let store = Limited::default();
set(&store, "k", "pstr-chunked:3").unwrap();
assert_eq!(get(&store, "k").unwrap().as_deref(), Some("pstr-chunked:3"));
}
#[test]
fn deleting_a_split_secret_deletes_every_part() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
delete(&store, "k").unwrap();
assert!(store.0.lock().is_empty());
assert_eq!(get(&store, "k").unwrap(), None);
}
#[test]
fn a_split_secret_missing_a_part_reads_as_absent() {
let store = Limited::default();
set(&store, "k", &"x".repeat(5000)).unwrap();
store.delete("k#1").unwrap();
assert_eq!(get(&store, "k").unwrap(), None);
}
}
}
/// What the app records about a share, minus its secrets.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Share {
+603 -94
View File
@@ -32,7 +32,7 @@
use std::collections::HashMap;
use parking_lot::Mutex;
use proton_drive_rs::{DeviceType, NodeKind};
use proton_drive_rs::{DeviceType, NodeKind, ProtonDriveClient};
use proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
@@ -120,12 +120,133 @@ pub struct SyncReport {
pub uploaded: bool,
/// Other installations whose history was read.
pub devices: usize,
/// Files of installations gone quiet that were moved to the trash, their
/// history now carried in this installation's own file.
pub retired: usize,
}
/// A history file as last read, by the revision it was read at.
/// How long an installation's file may go unwritten before another
/// installation retires it.
///
/// Reinstalling — routine on Android, where uninstalling wipes the config —
/// starts a new id, and every file ever written would otherwise be read by
/// every device forever. Retiring is safe at any age: a file is only trashed
/// after its entries were merged and this installation's file, which then
/// holds them too, is written. An installation that was merely idle finds its
/// file gone on its next sync and writes it again.
const RETIRE_AFTER_SECS: i64 = 90 * 24 * 60 * 60;
/// One history file in Drive, as listed.
#[derive(Debug, Clone)]
pub(crate) struct RemoteFile {
pub uid: NodeUid,
pub name: String,
/// The active revision, which moves with every write.
pub revision: Option<String>,
/// Last modification, epoch seconds.
pub modified: i64,
}
/// The few Drive operations sync needs: the account's Drive in the app, an
/// in-memory one in the tests.
pub(crate) trait HistoryDrive: Sync {
/// The history folders, creating the device and folder the first time.
/// See [`WatchSync::folders`].
fn history_folders(&self) -> impl Future<Output = Result<Vec<NodeUid>>> + Send;
/// The untrashed `.json` files directly in `folder`.
fn list(&self, folder: &NodeUid) -> impl Future<Output = Result<Vec<RemoteFile>>> + Send;
fn download(&self, uid: &NodeUid) -> impl Future<Output = Result<Vec<u8>>> + Send;
fn create(
&self,
folder: &NodeUid,
name: &str,
bytes: &[u8],
) -> impl Future<Output = Result<()>> + Send;
fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> impl Future<Output = Result<()>> + Send;
fn trash(&self, uids: &[NodeUid]) -> impl Future<Output = Result<()>> + Send;
}
impl HistoryDrive for ProtonDriveClient {
/// Two installations signing in at the same moment can each create the
/// device before seeing the other's. Every device of that name is read
/// from so neither's history is lost; the oldest is the one written to,
/// which is the same one on every installation.
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
let mut devices: Vec<_> = self
.enumerate_devices()
.await?
.into_iter()
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
.collect();
devices.sort_by_key(|device| device.creation_time);
if devices.is_empty() {
devices.push(self.create_device(DEVICE_NAME, device_type()).await?);
}
let mut folders = Vec::with_capacity(devices.len());
for device in &devices {
folders.push(
self.create_folder_path(&device.root_folder_uid, FOLDER)
.await?,
);
}
Ok(folders)
}
async fn list(&self, folder: &NodeUid) -> Result<Vec<RemoteFile>> {
let uids = self.enumerate_folder_children_node_uids(folder).await?;
if uids.is_empty() {
return Ok(Vec::new());
}
Ok(self
.enumerate_nodes(&uids)
.await?
.into_iter()
.filter_map(|node| {
let NodeKind::File {
active_revision_id, ..
} = node.kind
else {
return None;
};
(!node.trashed && node.name.ends_with(".json")).then_some(RemoteFile {
uid: node.uid,
name: node.name,
revision: active_revision_id,
modified: node.modification_time,
})
})
.collect())
}
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
Ok(self.download_file(uid).await?)
}
async fn create(&self, folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
self.upload_file(folder, name, "application/json", bytes)
.await?;
Ok(())
}
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
Ok(self.upload_new_revision(uid, bytes).await?)
}
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
for (uid, outcome) in self.trash_nodes(uids).await? {
if let Err(error) = outcome {
tracing::warn!(%uid, %error, "a retired watch-history file was not trashed");
}
}
Ok(())
}
}
/// A history file as last read, by the revision it was read at. `None`
/// entries for one this build could not read.
struct Seen {
revision: Option<String>,
entries: Vec<HistoryEntry>,
entries: Option<Vec<HistoryEntry>>,
}
/// Syncs the catalog's watch table with the account's Drive.
@@ -139,7 +260,7 @@ pub struct WatchSync {
/// the catalog so an unchanged history is not uploaded as a new revision.
own: Mutex<Option<Vec<HistoryEntry>>>,
/// The history folders, resolved once per account. Usually one; see
/// [`WatchSync::folders`].
/// [`HistoryDrive::history_folders`].
folders: Mutex<Option<Vec<NodeUid>>>,
/// One sync at a time: two would race on creating the device and the
/// installation's file.
@@ -182,59 +303,69 @@ impl WatchSync {
*self.folders.lock() = None;
}
/// Pull every other installation's history into `catalog`, then write
/// this one's if it changed.
/// Pull every other installation's history into `catalog`, write this
/// one's if it changed, then retire the files of installations gone quiet.
///
/// The catalog is locked only around its own reads and writes, never
/// across a request, so playback can keep saving its position while this
/// runs.
pub async fn sync(&self, account: &Account, catalog: &Mutex<Catalog>) -> Result<SyncReport> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.as_secs() as i64);
self.sync_with(account.drive(), catalog, now).await
}
pub(crate) async fn sync_with<D: HistoryDrive>(
&self,
drive: &D,
catalog: &Mutex<Catalog>,
now: i64,
) -> Result<SyncReport> {
let _running = self.running.lock().await;
let drive = account.drive();
let folders = self.folders(account).await?;
let folders = self.folders(drive).await?;
let own_name = format!("{}.json", self.installation);
let mut own_file = None;
let mut remote = Vec::new();
let mut devices = 0;
let mut others = Vec::new();
for folder in &folders {
let uids = drive.enumerate_folder_children_node_uids(folder).await?;
if uids.is_empty() {
continue;
}
for node in drive.enumerate_nodes(&uids).await? {
let NodeKind::File {
active_revision_id, ..
} = &node.kind
else {
continue;
};
if node.trashed || !node.name.ends_with(".json") {
continue;
for file in drive.list(folder).await? {
if file.name == own_name {
own_file.get_or_insert(file);
} else {
others.push(file);
}
if node.name == own_name {
if own_file.is_none() {
own_file = Some((node.uid.clone(), active_revision_id.clone()));
}
continue;
}
devices += 1;
remote.extend(
self.read(account, &node.uid, active_revision_id.clone())
.await,
);
}
}
let mut remote = Vec::new();
// Files whose every entry has been read, and so can be retired once
// this installation's file carries them.
let mut readable = Vec::new();
for file in &others {
if let Some(entries) = self.read(drive, file).await {
remote.extend(entries);
readable.push(file);
}
}
// What this installation wrote before is history too: after a
// reinstall that kept the config, or a catalog that was deleted. One
// that cannot be read now is left alone rather than overwritten with
// less than it holds, and read again next time.
let mut own_unreadable = false;
if self.own.lock().is_none() {
let entries = match &own_file {
Some((uid, revision)) => self.read(account, uid, revision.clone()).await,
None => Vec::new(),
Some(file) => self.read(drive, file).await,
None => Some(Vec::new()),
};
// What this installation wrote before is history too: after a
// reinstall that kept the config, or a catalog that was deleted.
remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries);
match entries {
Some(entries) => {
remote.extend(entries.iter().cloned());
*self.own.lock() = Some(entries);
}
None => own_unreadable = true,
}
}
let incoming: Vec<(String, String, WatchState)> = remote
@@ -254,8 +385,12 @@ impl WatchSync {
.collect();
entries.sort_by(|a, b| (&a.share, &a.link).cmp(&(&b.share, &b.link)));
let unchanged = self.own.lock().as_ref() == Some(&entries);
let uploaded = if unchanged || (entries.is_empty() && own_file.is_none()) {
// A file that is missing is written even when what it would hold has
// not changed: another installation retired it, or it was deleted by
// hand, and either way the history it carried lives only here now.
let unchanged = own_file.is_some() && self.own.lock().as_ref() == Some(&entries);
let uploaded = if own_unreadable || unchanged || (entries.is_empty() && own_file.is_none())
{
false
} else {
let file = HistoryFile {
@@ -266,94 +401,91 @@ impl WatchSync {
let bytes = serde_json::to_vec(&file)
.map_err(|e| Error::Config(format!("serialize watch history: {e}")))?;
match &own_file {
Some((uid, _)) => drive.upload_new_revision(uid, &bytes).await?,
None => {
drive
.upload_file(&folders[0], &own_name, "application/json", &bytes)
.await?;
}
Some(file) => drive.replace(&file.uid, &bytes).await?,
None => drive.create(&folders[0], &own_name, &bytes).await?,
}
*self.own.lock() = Some(entries);
true
};
// Only now: every entry of a retired file was merged above, and this
// installation's file — just written, or already up to date — holds
// the merged table.
let retired: Vec<NodeUid> = readable
.into_iter()
.filter(|file| !own_unreadable && now - file.modified >= RETIRE_AFTER_SECS)
.map(|file| file.uid.clone())
.collect();
if !retired.is_empty() {
match drive.trash(&retired).await {
Ok(()) => {
let mut seen = self.seen.lock();
for uid in &retired {
seen.remove(uid.link_id.as_str());
}
}
Err(error) => {
tracing::warn!(%error, "retiring old watch-history files failed");
}
}
}
Ok(SyncReport {
applied,
uploaded,
devices,
devices: others.len(),
retired: retired.len(),
})
}
/// The entries of one history file, from the cache when its revision has
/// not moved.
/// not moved, or `None` when it cannot be read.
///
/// A file that will not download or parse is logged and read as empty: it
/// is another device's, and one bad file must not stop the rest syncing.
async fn read(
/// A file that will not download or parse is logged and skipped: it is
/// another device's, and one bad file must not stop the rest syncing.
async fn read<D: HistoryDrive>(
&self,
account: &Account,
uid: &NodeUid,
revision: Option<String>,
) -> Vec<HistoryEntry> {
let key = uid.link_id.as_str().to_owned();
drive: &D,
file: &RemoteFile,
) -> Option<Vec<HistoryEntry>> {
let key = file.uid.link_id.as_str().to_owned();
if let Some(seen) = self.seen.lock().get(&key)
&& revision.is_some()
&& seen.revision == revision
&& file.revision.is_some()
&& seen.revision == file.revision
{
return seen.entries.clone();
}
let entries = match account.drive().download_file(uid).await {
Ok(bytes) => match parse(&bytes) {
Some(entries) => entries,
None => {
let uid = &file.uid;
let entries = match drive.download(uid).await {
Ok(bytes) => {
let entries = parse(&bytes);
if entries.is_none() {
tracing::warn!(%uid, "skipping a watch-history file this build cannot read");
Vec::new()
}
},
entries
}
Err(error) => {
// Not cached: a download that failed is tried again next time.
tracing::warn!(%uid, %error, "a watch-history file could not be downloaded");
return Vec::new();
return None;
}
};
self.seen.lock().insert(
key,
Seen {
revision,
revision: file.revision.clone(),
entries: entries.clone(),
},
);
entries
}
/// The history folders, creating the device and folder the first time.
///
/// Two installations signing in at the same moment can each create the
/// device before seeing the other's. Every device of that name is read
/// from so neither's history is lost; the oldest is the one written to,
/// which is the same one on every installation.
async fn folders(&self, account: &Account) -> Result<Vec<NodeUid>> {
/// The history folders, resolved once per account.
async fn folders<D: HistoryDrive>(&self, drive: &D) -> Result<Vec<NodeUid>> {
if let Some(folders) = self.folders.lock().clone() {
return Ok(folders);
}
let drive = account.drive();
let mut devices: Vec<_> = drive
.enumerate_devices()
.await?
.into_iter()
.filter(|device| device.name.as_deref().ok() == Some(DEVICE_NAME))
.collect();
devices.sort_by_key(|device| device.creation_time);
if devices.is_empty() {
devices.push(drive.create_device(DEVICE_NAME, device_type()).await?);
}
let mut folders = Vec::with_capacity(devices.len());
for device in &devices {
folders.push(
drive
.create_folder_path(&device.root_folder_uid, FOLDER)
.await?,
);
}
let folders = drive.history_folders().await?;
*self.folders.lock() = Some(folders.clone());
Ok(folders)
}
@@ -398,6 +530,383 @@ mod tests {
}
}
/// One history folder in memory, standing in for the account's Drive.
#[derive(Default)]
struct FakeDrive {
files: Mutex<Vec<FakeFile>>,
/// What the next write is stamped with, epoch seconds.
clock: std::sync::atomic::AtomicI64,
/// Downloads fail while set, as on a dropped connection.
offline: std::sync::atomic::AtomicBool,
uploads: std::sync::atomic::AtomicUsize,
}
struct FakeFile {
uid: NodeUid,
name: String,
bytes: Vec<u8>,
revision: u32,
modified: i64,
trashed: bool,
}
fn uid(link: &str) -> NodeUid {
NodeUid::new("volume".to_owned().into(), link.to_owned().into())
}
impl FakeDrive {
fn at(&self, now: i64) {
self.clock.store(now, std::sync::atomic::Ordering::SeqCst);
}
fn names(&self) -> Vec<String> {
let mut names: Vec<_> = self
.files
.lock()
.iter()
.filter(|file| !file.trashed)
.map(|file| file.name.clone())
.collect();
names.sort();
names
}
fn uploads(&self) -> usize {
self.uploads.load(std::sync::atomic::Ordering::SeqCst)
}
fn put(&self, name: &str, bytes: Vec<u8>) {
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
let mut files = self.files.lock();
let link = format!("file-{}", files.len());
files.push(FakeFile {
uid: uid(&link),
name: name.to_owned(),
bytes,
revision: 1,
modified,
trashed: false,
});
}
}
impl HistoryDrive for FakeDrive {
async fn history_folders(&self) -> Result<Vec<NodeUid>> {
Ok(vec![uid("folder")])
}
async fn list(&self, _folder: &NodeUid) -> Result<Vec<RemoteFile>> {
Ok(self
.files
.lock()
.iter()
.filter(|file| !file.trashed)
.map(|file| RemoteFile {
uid: file.uid.clone(),
name: file.name.clone(),
revision: Some(file.revision.to_string()),
modified: file.modified,
})
.collect())
}
async fn download(&self, uid: &NodeUid) -> Result<Vec<u8>> {
if self.offline.load(std::sync::atomic::Ordering::SeqCst) {
return Err(Error::NotFound("offline".to_owned()));
}
self.files
.lock()
.iter()
.find(|file| &file.uid == uid)
.map(|file| file.bytes.clone())
.ok_or_else(|| Error::NotFound(uid.to_string()))
}
async fn create(&self, _folder: &NodeUid, name: &str, bytes: &[u8]) -> Result<()> {
self.uploads
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
self.put(name, bytes.to_vec());
Ok(())
}
async fn replace(&self, uid: &NodeUid, bytes: &[u8]) -> Result<()> {
self.uploads
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let modified = self.clock.load(std::sync::atomic::Ordering::SeqCst);
let mut files = self.files.lock();
let file = files
.iter_mut()
.find(|file| &file.uid == uid)
.ok_or_else(|| Error::NotFound(uid.to_string()))?;
file.bytes = bytes.to_vec();
file.revision += 1;
file.modified = modified;
Ok(())
}
async fn trash(&self, uids: &[NodeUid]) -> Result<()> {
for file in self.files.lock().iter_mut() {
if uids.contains(&file.uid) {
file.trashed = true;
}
}
Ok(())
}
}
/// An installation: its sync and its catalog.
fn device(id: &str) -> (WatchSync, Mutex<Catalog>) {
(
WatchSync::with_installation(id.to_owned()),
Mutex::new(Catalog::in_memory().unwrap()),
)
}
fn watched_at(position_secs: f64, updated_at: i64) -> WatchState {
WatchState {
position_secs,
duration_secs: Some(1440.0),
watched: false,
updated_at,
}
}
const DAY: i64 = 24 * 60 * 60;
#[tokio::test]
async fn a_position_saved_on_one_device_resumes_on_another() {
let drive = FakeDrive::default();
let (laptop, laptop_catalog) = device("laptop");
let (phone, phone_catalog) = device("phone");
laptop_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
.unwrap();
let first = laptop
.sync_with(&drive, &laptop_catalog, 100)
.await
.unwrap();
assert!(first.uploaded);
let second = phone.sync_with(&drive, &phone_catalog, 110).await.unwrap();
assert_eq!(second.applied, 1);
assert_eq!(second.devices, 1);
assert_eq!(
phone_catalog.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(600.0, 100))
);
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
}
#[tokio::test]
async fn the_newer_position_wins_whichever_device_syncs_first() {
let drive = FakeDrive::default();
let (laptop, laptop_catalog) = device("laptop");
let (phone, phone_catalog) = device("phone");
laptop_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(300.0, 100))
.unwrap();
phone_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(900.0, 200))
.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.await
.unwrap();
phone.sync_with(&drive, &phone_catalog, 300).await.unwrap();
laptop
.sync_with(&drive, &laptop_catalog, 300)
.await
.unwrap();
for catalog in [&laptop_catalog, &phone_catalog] {
assert_eq!(
catalog.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(900.0, 200))
);
}
}
#[tokio::test]
async fn an_unchanged_history_is_not_uploaded_again() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let again = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
assert!(!again.uploaded);
assert_eq!(drive.uploads(), 1);
}
#[tokio::test]
async fn nothing_is_uploaded_before_anything_was_watched() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
let report = laptop.sync_with(&drive, &catalog, 100).await.unwrap();
assert!(!report.uploaded);
assert!(drive.names().is_empty());
}
#[tokio::test]
async fn a_file_that_went_missing_is_written_again() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let own: Vec<NodeUid> = drive.files.lock().iter().map(|f| f.uid.clone()).collect();
drive.trash(&own).await.unwrap();
let report = laptop.sync_with(&drive, &catalog, 200).await.unwrap();
assert!(report.uploaded);
assert_eq!(drive.names(), ["laptop.json"]);
}
#[tokio::test]
async fn history_this_installation_wrote_before_comes_back_into_an_empty_catalog() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
// The catalog was deleted; the config, and with it the id, was not.
let (relaunched, empty) = device("laptop");
let report = relaunched.sync_with(&drive, &empty, 200).await.unwrap();
assert_eq!(report.applied, 1);
assert!(!report.uploaded);
assert_eq!(
empty.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(60.0, 100))
);
}
#[tokio::test]
async fn an_own_file_that_cannot_be_read_is_not_overwritten() {
let drive = FakeDrive::default();
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(60.0, 100))
.unwrap();
laptop.sync_with(&drive, &catalog, 100).await.unwrap();
let (relaunched, other) = device("laptop");
other
.lock()
.set_watch_state("share-a", "ep2", &watched_at(5.0, 150))
.unwrap();
drive
.offline
.store(true, std::sync::atomic::Ordering::SeqCst);
let report = relaunched.sync_with(&drive, &other, 200).await.unwrap();
assert!(!report.uploaded);
drive
.offline
.store(false, std::sync::atomic::Ordering::SeqCst);
relaunched.sync_with(&drive, &other, 300).await.unwrap();
let state = other.lock().all_watch_states().unwrap();
assert_eq!(state.len(), 2, "both episodes survive: {state:?}");
}
#[tokio::test]
async fn a_file_from_a_newer_build_is_neither_merged_nor_retired() {
let drive = FakeDrive::default();
let future = HistoryFile {
version: FORMAT_VERSION + 1,
installation: "future".to_owned(),
entries: vec![entry("ep1", 60.0, 100)],
};
drive.put("future.json", serde_json::to_vec(&future).unwrap());
let (laptop, catalog) = device("laptop");
catalog
.lock()
.set_watch_state("share-a", "ep2", &watched_at(5.0, 100))
.unwrap();
let report = laptop
.sync_with(&drive, &catalog, RETIRE_AFTER_SECS + DAY)
.await
.unwrap();
assert_eq!(report.applied, 0);
assert_eq!(report.retired, 0);
assert_eq!(drive.names(), ["future.json", "laptop.json"]);
}
#[tokio::test]
async fn a_quiet_installations_file_is_retired_once_its_history_is_carried() {
let drive = FakeDrive::default();
let (old_phone, old_catalog) = device("old-phone");
old_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
.unwrap();
drive.at(100);
old_phone
.sync_with(&drive, &old_catalog, 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let later = 100 + RETIRE_AFTER_SECS;
drive.at(later);
let report = phone.sync_with(&drive, &catalog, later).await.unwrap();
assert_eq!(report.retired, 1);
assert_eq!(drive.names(), ["phone.json"]);
// Nothing was lost: the newcomer's file carries it now.
let (fresh, fresh_catalog) = device("fresh");
fresh
.sync_with(&drive, &fresh_catalog, later)
.await
.unwrap();
assert_eq!(
fresh_catalog.lock().watch_state("share-a", "ep1").unwrap(),
Some(watched_at(600.0, 100))
);
}
#[tokio::test]
async fn a_recent_installations_file_is_kept() {
let drive = FakeDrive::default();
let (laptop, laptop_catalog) = device("laptop");
laptop_catalog
.lock()
.set_watch_state("share-a", "ep1", &watched_at(600.0, 100))
.unwrap();
drive.at(100);
laptop
.sync_with(&drive, &laptop_catalog, 100)
.await
.unwrap();
let (phone, catalog) = device("phone");
let report = phone
.sync_with(&drive, &catalog, 100 + RETIRE_AFTER_SECS - DAY)
.await
.unwrap();
assert_eq!(report.retired, 0);
assert_eq!(drive.names(), ["laptop.json", "phone.json"]);
}
#[test]
fn an_installation_keeps_the_id_it_was_given() {
let root =