fix(sync): enforce private profile boundaries
This commit is contained in:
@@ -7,11 +7,14 @@
|
||||
//! so the existing 8 ms tick is the single point that reconciles
|
||||
//! background-task state with `BrowserCore`.
|
||||
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::{path::Path, sync::mpsc::Sender};
|
||||
|
||||
use ely_browser_core::SyncEngine;
|
||||
use ely_domain::{ProfileId, ProfileKind};
|
||||
use ely_sync_client::{ApiClientConfig, BearerToken, send_email_otp, verify_email_otp};
|
||||
use ely_domain::ProfileId;
|
||||
use ely_sync_client::{
|
||||
ApiClientConfig, BearerToken, BearerTokenStore, SyncClientError, send_email_otp,
|
||||
verify_email_otp,
|
||||
};
|
||||
use gpui::Context;
|
||||
|
||||
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
|
||||
@@ -63,6 +66,9 @@ impl ElyShell {
|
||||
/// through the shared `SyncStateUpdate` channel, which the next
|
||||
/// shell tick reconciles into the `auth_flow_phase`.
|
||||
pub(crate) fn submit_email_otp_request(&mut self, cx: &mut Context<Self>) {
|
||||
if active_profile_sync_context_for(&self.state).is_none() {
|
||||
return;
|
||||
}
|
||||
let email = self.read_auth_email_input(cx);
|
||||
let Some(email) = normalize_email(&email) else {
|
||||
self.auth_flow_phase = AuthFlowPhase::Error {
|
||||
@@ -115,21 +121,16 @@ impl ElyShell {
|
||||
/// call to make, the token is the only artefact we own.
|
||||
pub(crate) fn submit_sign_out(&mut self, _cx: &mut Context<Self>) {
|
||||
self.auth_flow_phase = AuthFlowPhase::Idle;
|
||||
let active_profile = match active_profile_sync_context_for(&self.state) {
|
||||
Some(profile) => profile,
|
||||
let active_profile_id = match active_profile_id_for(&self.state) {
|
||||
Some(profile_id) => profile_id,
|
||||
None => return,
|
||||
};
|
||||
let Some(profile_root) = default_profile_data_root() else {
|
||||
return;
|
||||
};
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile.id);
|
||||
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
|
||||
Ok(mut engine) => {
|
||||
let _ = engine.install_bearer("");
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!(target: "ely::sync", error = %error, "sign-out failed to load engine");
|
||||
}
|
||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
|
||||
if let Err(error) = clear_persisted_bearer(&profile_dir) {
|
||||
tracing::warn!(target: "ely::sync", error = %error, "sign-out failed to clear bearer");
|
||||
}
|
||||
if let ShellState::Ready(core) = &mut self.state {
|
||||
core.set_sync_connection_state(ely_domain::SyncConnectionState::SignedOut);
|
||||
@@ -156,19 +157,27 @@ fn normalize_email(raw: &str) -> Option<String> {
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
struct ActiveProfileSyncContext {
|
||||
id: ProfileId,
|
||||
name: String,
|
||||
kind: ProfileKind,
|
||||
}
|
||||
|
||||
fn active_profile_sync_context_for(state: &ShellState) -> Option<ActiveProfileSyncContext> {
|
||||
let ShellState::Ready(core) = state else {
|
||||
return None;
|
||||
};
|
||||
core.snapshot().ok().map(|snapshot| ActiveProfileSyncContext {
|
||||
id: snapshot.active_profile_id,
|
||||
name: snapshot.active_profile_name,
|
||||
kind: snapshot.active_profile_kind,
|
||||
})
|
||||
if !core.active_profile_allows_sync() {
|
||||
return None;
|
||||
}
|
||||
active_profile_id_for(state).map(|id| ActiveProfileSyncContext { id })
|
||||
}
|
||||
|
||||
fn active_profile_id_for(state: &ShellState) -> Option<ProfileId> {
|
||||
let ShellState::Ready(core) = state else {
|
||||
return None;
|
||||
};
|
||||
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
|
||||
}
|
||||
|
||||
pub(super) fn clear_persisted_bearer(profile_dir: &Path) -> Result<(), SyncClientError> {
|
||||
BearerTokenStore::new(profile_dir.join("sync").join("bearer.token")).clear()
|
||||
}
|
||||
|
||||
fn spawn_send_otp(email: String, tx: Sender<SyncStateUpdate>) {
|
||||
@@ -237,7 +246,10 @@ fn spawn_verify_otp(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AuthFlowPhase, normalize_email};
|
||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||
|
||||
use super::{AuthFlowPhase, active_profile_sync_context_for, normalize_email};
|
||||
use crate::shell::ShellState;
|
||||
|
||||
#[test]
|
||||
fn normalize_lowercases_and_trims() {
|
||||
@@ -265,4 +277,18 @@ mod tests {
|
||||
assert_eq!(phase.error_message(), Some("rate limited"));
|
||||
assert!(!phase.is_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_profile_has_no_sync_auth_context() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let state =
|
||||
ShellState::Ready(Box::new(BrowserCore::new(InitialBrowserConfig::private_window()?)?));
|
||||
|
||||
assert_eq!(active_profile_sync_context_for(&state), None);
|
||||
|
||||
let state =
|
||||
ShellState::Ready(Box::new(BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?));
|
||||
assert!(active_profile_sync_context_for(&state).is_some());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use ely_browser_core::BrowserSnapshot;
|
||||
use ely_design_system::colors;
|
||||
use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectStatus};
|
||||
use ely_domain::{ProfileKind, SyncConnectionState, SyncObjectKind, SyncObjectStatus};
|
||||
use gpui::{
|
||||
AnyElement, Context, FontWeight, IntoElement, ParentElement, Styled, div, px, rgb, rgba,
|
||||
};
|
||||
@@ -36,25 +36,48 @@ fn render_sync_body(
|
||||
snapshot: &BrowserSnapshot,
|
||||
cx: &mut Context<ElyShell>,
|
||||
) -> AnyElement {
|
||||
let body = div().max_w(px(860.0)).flex().flex_col().gap(px(18.0)).child(
|
||||
div()
|
||||
.text_size(px(26.0))
|
||||
.font_weight(FontWeight(500.0))
|
||||
.text_color(rgb(colors::ink()))
|
||||
.child("Sync"),
|
||||
);
|
||||
if !profile_allows_sync_controls(&snapshot.active_profile_kind) {
|
||||
return body.child(render_private_profile_card()).into_any_element();
|
||||
}
|
||||
body.child(
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(2)
|
||||
.gap(px(18.0))
|
||||
.child(render_account_card(shell, snapshot, cx))
|
||||
.child(render_data_card(shell, snapshot, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn profile_allows_sync_controls(profile_kind: &ProfileKind) -> bool {
|
||||
profile_kind == &ProfileKind::Standard
|
||||
}
|
||||
|
||||
fn render_private_profile_card() -> AnyElement {
|
||||
div()
|
||||
.max_w(px(860.0))
|
||||
.p(px(16.0))
|
||||
.rounded(px(12.0))
|
||||
.bg(rgba(card_bg()))
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap(px(18.0))
|
||||
.gap(px(8.0))
|
||||
.child(
|
||||
div()
|
||||
.text_size(px(26.0))
|
||||
.text_size(px(14.0))
|
||||
.font_weight(FontWeight(500.0))
|
||||
.text_color(rgb(colors::ink()))
|
||||
.child("Sync"),
|
||||
.child("Private profile"),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.grid()
|
||||
.grid_cols(2)
|
||||
.gap(px(18.0))
|
||||
.child(render_account_card(shell, snapshot, cx))
|
||||
.child(render_data_card(shell, snapshot, cx)),
|
||||
div().text_size(px(14.0)).text_color(rgb(colors::ink_3())).child("Local session only"),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -241,3 +264,16 @@ fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str {
|
||||
fn card_bg() -> u32 {
|
||||
colors::pick(0xffffffd9, 0x1f1d1bd9)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ely_domain::ProfileKind;
|
||||
|
||||
use super::profile_allows_sync_controls;
|
||||
|
||||
#[test]
|
||||
fn private_profile_hides_sync_controls() {
|
||||
assert!(!profile_allows_sync_controls(&ProfileKind::Private));
|
||||
assert!(profile_allows_sync_controls(&ProfileKind::Standard));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,6 +255,15 @@ impl ElyShell {
|
||||
}
|
||||
|
||||
fn trigger_cloud_sync_upload_with_clock_floor(&mut self, logical_clock_floor: Option<u64>) {
|
||||
let active_profile_allows_sync = match &self.state {
|
||||
ShellState::Ready(core) => core.active_profile_allows_sync(),
|
||||
ShellState::StartupError(_) => false,
|
||||
};
|
||||
if !active_profile_allows_sync {
|
||||
self.sync_upload_scheduled = false;
|
||||
self.clear_pending_cloud_sync_upload();
|
||||
return;
|
||||
}
|
||||
if self.sync_upload_in_flight {
|
||||
self.sync_upload_scheduled = false;
|
||||
self.queue_cloud_sync_upload(logical_clock_floor);
|
||||
|
||||
@@ -88,7 +88,7 @@ impl ElyShell {
|
||||
true
|
||||
}
|
||||
|
||||
fn clear_pending_cloud_sync_upload(&mut self) {
|
||||
pub(super) fn clear_pending_cloud_sync_upload(&mut self) {
|
||||
self.sync_upload_pending = false;
|
||||
self.sync_upload_pending_logical_clock_floor = None;
|
||||
}
|
||||
@@ -103,34 +103,14 @@ impl ElyShell {
|
||||
/// Inspect the on-disk bearer token and seed `SyncConnectionState`
|
||||
/// so the Sync settings page reads the startup state on first render.
|
||||
pub(super) fn probe_initial_sync_state(&mut self) -> bool {
|
||||
let ShellState::Ready(core) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
let Some(snapshot) = core.snapshot().ok() else {
|
||||
return false;
|
||||
};
|
||||
let Some(profile_root) = crate::services::servo_profile_data::default_profile_data_root()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir(
|
||||
&profile_root,
|
||||
&snapshot.active_profile_id,
|
||||
);
|
||||
if snapshot.active_profile_name == "Default"
|
||||
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard)
|
||||
{
|
||||
migrate_legacy_default_sync_dir(&profile_root, &profile_dir);
|
||||
}
|
||||
let bearer_path = profile_dir.join("sync").join("bearer.token");
|
||||
let bearer_present = bearer_token_file_present(&bearer_path);
|
||||
let state = if bearer_present {
|
||||
ely_domain::SyncConnectionState::SignedIn
|
||||
} else {
|
||||
ely_domain::SyncConnectionState::SignedOut
|
||||
let ShellState::Ready(core) = &mut self.state else {
|
||||
return false;
|
||||
};
|
||||
core.set_sync_connection_state(state);
|
||||
bearer_present
|
||||
probe_initial_sync_state_at(core, &profile_root)
|
||||
}
|
||||
|
||||
/// Drain any sync upload outcomes the off-thread worker pushed
|
||||
@@ -225,6 +205,40 @@ impl ElyShell {
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_initial_sync_state_at(
|
||||
core: &mut ely_browser_core::BrowserCore,
|
||||
profile_root: &Path,
|
||||
) -> bool {
|
||||
let Some(snapshot) = core.snapshot().ok() else {
|
||||
return false;
|
||||
};
|
||||
let profile_dir = crate::services::servo_profile_data::sync_profile_data_dir(
|
||||
profile_root,
|
||||
&snapshot.active_profile_id,
|
||||
);
|
||||
if !core.active_profile_allows_sync() {
|
||||
if let Err(error) = auth::clear_persisted_bearer(&profile_dir) {
|
||||
tracing::warn!(target: "ely::sync", error = %error, "private bearer cleanup failed");
|
||||
}
|
||||
core.set_sync_connection_state(SyncConnectionState::SignedOut);
|
||||
return false;
|
||||
}
|
||||
if snapshot.active_profile_name == "Default"
|
||||
&& matches!(snapshot.active_profile_kind, ProfileKind::Standard)
|
||||
{
|
||||
migrate_legacy_default_sync_dir(profile_root, &profile_dir);
|
||||
}
|
||||
let bearer_path = profile_dir.join("sync").join("bearer.token");
|
||||
let bearer_present = bearer_token_file_present(&bearer_path);
|
||||
let state = if bearer_present {
|
||||
ely_domain::SyncConnectionState::SignedIn
|
||||
} else {
|
||||
ely_domain::SyncConnectionState::SignedOut
|
||||
};
|
||||
core.set_sync_connection_state(state);
|
||||
bearer_present
|
||||
}
|
||||
|
||||
fn bearer_token_file_present(path: &Path) -> bool {
|
||||
std::fs::metadata(path).map(|metadata| metadata.len() > 0).unwrap_or(false)
|
||||
}
|
||||
@@ -266,7 +280,13 @@ fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{bearer_token_file_present, migrate_legacy_default_sync_dir};
|
||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||
use ely_domain::SyncConnectionState;
|
||||
|
||||
use super::{
|
||||
bearer_token_file_present, migrate_legacy_default_sync_dir, probe_initial_sync_state_at,
|
||||
};
|
||||
use crate::services::servo_profile_data::sync_profile_data_dir;
|
||||
|
||||
#[test]
|
||||
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -315,4 +335,41 @@ mod tests {
|
||||
assert!(!stable.join("sync/bearer.token").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_startup_clears_a_persisted_bearer() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let mut core = BrowserCore::new(InitialBrowserConfig::private_window()?)?;
|
||||
let profile_id = core.snapshot()?.active_profile_id;
|
||||
let profile_dir = sync_profile_data_dir(directory.path(), &profile_id);
|
||||
let bearer_path = profile_dir.join("sync/bearer.token");
|
||||
std::fs::create_dir_all(bearer_path.parent().ok_or("missing bearer parent")?)?;
|
||||
std::fs::write(&bearer_path, "a".repeat(64))?;
|
||||
std::fs::write(bearer_path.with_extension("tmp"), "b".repeat(64))?;
|
||||
core.set_sync_connection_state(SyncConnectionState::SignedIn);
|
||||
|
||||
assert!(!probe_initial_sync_state_at(&mut core, directory.path()));
|
||||
assert!(!bearer_path.exists());
|
||||
assert!(!bearer_path.with_extension("tmp").exists());
|
||||
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedOut);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_startup_preserves_a_persisted_bearer() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||
let profile_id = core.snapshot()?.active_profile_id;
|
||||
let profile_dir = sync_profile_data_dir(directory.path(), &profile_id);
|
||||
let bearer_path = profile_dir.join("sync/bearer.token");
|
||||
std::fs::create_dir_all(bearer_path.parent().ok_or("missing bearer parent")?)?;
|
||||
std::fs::write(&bearer_path, "a".repeat(64))?;
|
||||
|
||||
assert!(probe_initial_sync_state_at(&mut core, directory.path()));
|
||||
assert!(bearer_path.exists());
|
||||
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedIn);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user