fix(auth): store bearer tokens in native credentials

This commit is contained in:
2026-07-10 07:57:44 -04:00
parent 34ac842078
commit 94afa23a69
26 changed files with 2316 additions and 286 deletions
+116 -11
View File
@@ -139,29 +139,54 @@ impl ElyShell {
}
/// Drop the persisted bearer token and reset the local form.
/// The bearer file is removed synchronously — there is no network
/// 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;
self.sync_devices.reset();
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
let active_profile_id = match active_profile_id_for(&self.state) {
Some(profile_id) => profile_id,
None => return,
};
self.auth_flow_phase = AuthFlowPhase::Idle;
self.sync_devices.reset();
self.sync_upload_scheduled = false;
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
let Some(profile_root) = default_profile_data_root() else {
self.set_sign_out_error(
active_profile_id,
"Profile data root is unavailable. Retry sign out.",
);
return;
};
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
if let Err(error) = clear_persisted_bearer(&profile_dir) {
if let Err(error) = clear_persisted_bearer(
&active_profile_id,
&profile_dir,
&profile_root,
self.default_profile_id.as_ref(),
) {
tracing::warn!(target: "ely::sync", error = %error, "sign-out failed to clear bearer");
self.set_sign_out_error(
active_profile_id,
"System credential access failed. Retry sign out.",
);
return;
}
if let ShellState::Ready(core) = &mut self.state {
core.set_sync_connection_state(ely_domain::SyncConnectionState::SignedOut);
}
}
fn set_sign_out_error(&mut self, profile_id: ProfileId, message: &str) {
self.auth_flow_phase =
AuthFlowPhase::Error { profile_id, email: String::new(), message: message.to_string() };
if let ShellState::Ready(core) = &mut self.state {
core.set_sync_connection_state(
ely_domain::SyncConnectionState::CredentialUnavailable {
message: message.to_string(),
},
);
}
}
fn read_auth_email_input(&self, cx: &Context<Self>) -> String {
self.auth_email_input.read(cx).value().to_string()
}
@@ -201,8 +226,28 @@ fn active_profile_id_for(state: &ShellState) -> Option<ProfileId> {
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()
pub(super) fn clear_persisted_bearer(
profile_id: &ProfileId,
profile_dir: &Path,
profile_root: &Path,
default_profile_id: Option<&ProfileId>,
) -> Result<(), SyncClientError> {
bearer_store_for_profile(profile_id, profile_dir, profile_root, default_profile_id).clear()
}
pub(super) fn bearer_store_for_profile(
profile_id: &ProfileId,
profile_dir: &Path,
profile_root: &Path,
default_profile_id: Option<&ProfileId>,
) -> BearerTokenStore {
let store = BearerTokenStore::new(profile_id, profile_dir);
if default_profile_id != Some(profile_id) {
return store;
}
store.with_legacy_path(
profile_root.join("default").join("servo").join("sync").join("bearer.token"),
)
}
fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) {
@@ -252,7 +297,12 @@ fn spawn_verify_otp(
}
};
let mut engine =
match SyncEngine::for_profile_dir(&profile_dir, "ELY", sync_platform_label()) {
match SyncEngine::for_profile_dir(
&profile_id,
&profile_dir,
"ELY",
sync_platform_label(),
) {
Ok(engine) => engine,
Err(error) => {
let _ = tx.send(SyncStateUpdate::AuthError {
@@ -284,7 +334,9 @@ mod tests {
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
use ely_domain::ProfileId;
use super::{AuthFlowPhase, active_profile_sync_context_for, normalize_email};
use super::{
AuthFlowPhase, active_profile_sync_context_for, bearer_store_for_profile, normalize_email,
};
use crate::shell::ShellState;
#[test]
@@ -334,4 +386,57 @@ mod tests {
Ok(())
}
#[test]
fn default_profile_store_cleans_stable_and_old_legacy_paths()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let profile_dir = directory.path().join(profile_id.as_str()).join("servo");
let stable = profile_dir.join("sync/bearer.token");
let old = directory.path().join("default/servo/sync/bearer.token");
std::fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
std::fs::create_dir_all(old.parent().ok_or("missing old parent")?)?;
std::fs::write(&stable, "a".repeat(64))?;
std::fs::write(&old, "b".repeat(64))?;
let store = bearer_store_for_profile(
&profile_id,
&profile_dir,
directory.path(),
Some(&profile_id),
);
store.clear_legacy_files()?;
assert!(!stable.exists());
assert!(!old.exists());
Ok(())
}
#[test]
fn custom_profile_store_leaves_default_legacy_credentials_untouched()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let default_profile_id = ProfileId::new();
let profile_dir = directory.path().join(profile_id.as_str()).join("servo");
let stable = profile_dir.join("sync/bearer.token");
let old_default = directory.path().join("default/servo/sync/bearer.token");
std::fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
std::fs::create_dir_all(old_default.parent().ok_or("missing default parent")?)?;
std::fs::write(&stable, "a".repeat(64))?;
std::fs::write(&old_default, "b".repeat(64))?;
let store = bearer_store_for_profile(
&profile_id,
&profile_dir,
directory.path(),
Some(&default_profile_id),
);
store.clear_legacy_files()?;
assert!(!stable.exists());
assert!(old_default.exists());
Ok(())
}
}
@@ -15,7 +15,6 @@ use super::sync_controls::{
render_reset_button, render_secondary_button, render_sign_out_button,
};
use super::{ElyShell, render_canvas_surface};
impl ElyShell {
pub(super) fn render_sync_page(
&mut self,
@@ -23,7 +22,10 @@ impl ElyShell {
cx: &mut Context<Self>,
) -> AnyElement {
if profile_allows_sync_controls(&snapshot.active_profile_kind)
&& !matches!(snapshot.sync_status.connection(), SyncConnectionState::SignedOut)
&& !matches!(
snapshot.sync_status.connection(),
SyncConnectionState::SignedOut | SyncConnectionState::CredentialUnavailable { .. }
)
{
self.ensure_sync_devices_loaded(cx);
}
@@ -37,7 +39,6 @@ impl ElyShell {
)
}
}
fn render_sync_body(
shell: &mut ElyShell,
snapshot: &BrowserSnapshot,
@@ -63,11 +64,9 @@ fn render_sync_body(
)
.into_any_element()
}
fn profile_allows_sync_controls(profile_kind: &ProfileKind) -> bool {
profile_kind == &ProfileKind::Standard
}
fn render_private_profile_card() -> AnyElement {
div()
.p(px(16.0))
@@ -88,7 +87,6 @@ fn render_private_profile_card() -> AnyElement {
)
.into_any_element()
}
fn render_account_card(
shell: &mut ElyShell,
snapshot: &BrowserSnapshot,
@@ -102,6 +100,11 @@ fn render_account_card(
.child(render_card_heading("Account"))
.children(account_form(shell, &snapshot.active_profile_id, cx))
.into_any_element(),
SyncConnectionState::CredentialUnavailable { message } => card
.child(render_card_heading("Account"))
.child(render_inline_error(message))
.children(account_form(shell, &snapshot.active_profile_id, cx))
.into_any_element(),
SyncConnectionState::SignedIn
| SyncConnectionState::AwaitingDeviceApproval
| SyncConnectionState::SyncReady { .. }
@@ -120,11 +123,13 @@ fn render_account_card(
.text_color(rgb(colors::ink_3()))
.child("End-to-end encrypted"),
)
.when_some(shell.auth_flow_phase.error_message(), |card, message| {
card.child(render_inline_error(message))
})
.child(render_devices(shell, cx))
.into_any_element(),
}
}
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement {
let loading = shell.sync_devices.is_loading();
let header =
+10 -5
View File
@@ -115,6 +115,7 @@ pub struct ElyShell {
sync_upload_pending: bool,
sync_upload_pending_merge: Option<PendingMergeUpload>,
sync_retry_at: Option<std::time::Instant>,
default_profile_id: Option<ProfileId>,
pub(crate) sync_devices: SyncDeviceUiState,
pub(crate) sync_verification_input: Entity<InputState>,
pub(crate) auth_email_input: Entity<InputState>,
@@ -126,21 +127,23 @@ pub struct ElyShell {
impl ElyShell {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let mut default_profile_id = None;
let config = InitialBrowserConfig::ely_defaults()
.map_err(|error| error.to_string())
.and_then(|mut config| {
config.profile_id = Some(
crate::services::profile_identity::default_standard_profile_id()
.map_err(|error| error.to_string())?,
);
let profile_id = crate::services::profile_identity::default_standard_profile_id()
.map_err(|error| error.to_string())?;
config.profile_id = Some(profile_id.clone());
default_profile_id = Some(profile_id);
Ok(config)
});
Self::new_with_config(config, window, cx)
Self::new_with_config(config, default_profile_id, window, cx)
}
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::new_with_config(
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
None,
window,
cx,
)
@@ -148,6 +151,7 @@ impl ElyShell {
fn new_with_config(
config: Result<InitialBrowserConfig, String>,
default_profile_id: Option<ProfileId>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
@@ -261,6 +265,7 @@ impl ElyShell {
sync_upload_pending: false,
sync_upload_pending_merge: None,
sync_retry_at: None,
default_profile_id,
sync_devices: SyncDeviceUiState::default(),
sync_verification_input,
auth_email_input,
+13 -12
View File
@@ -9,7 +9,9 @@ use gpui_component::slider::SliderValue;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::{PendingMergeUpload, SyncStateUpdate, sync_platform_label};
use super::sync_state::{
PendingMergeUpload, SyncStateUpdate, sync_failure_update, sync_platform_label,
};
use super::{ElyShell, ShellState};
impl ElyShell {
@@ -255,11 +257,11 @@ impl ElyShell {
}
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
let active_profile_allows_sync = match &self.state {
ShellState::Ready(core) => core.active_profile_allows_sync(),
let cloud_sync_enabled = match &self.state {
ShellState::Ready(core) => core.cloud_sync_upload_enabled(),
ShellState::StartupError(_) => false,
};
if !active_profile_allows_sync {
if !cloud_sync_enabled {
self.sync_upload_scheduled = false;
self.clear_pending_cloud_sync_upload();
return;
@@ -352,6 +354,7 @@ fn run_sync_upload(
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
) {
let mut engine = match SyncEngine::for_profile_dir(
&profile_id,
&profile_dir,
device_name,
sync_platform_label(),
@@ -360,7 +363,7 @@ fn run_sync_upload(
Err(error) => {
let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
let _ = inbox.send(SyncStateUpdate::SyncError { profile_id, message });
let _ = inbox.send(sync_failure_update(profile_id, error));
return;
}
};
@@ -380,7 +383,10 @@ fn run_sync_upload(
device_id = %device_id,
"sync device is awaiting approval",
);
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval { profile_id });
let _ = inbox.send(SyncStateUpdate::AwaitingDeviceApproval {
profile_id,
finishes_upload: true,
});
}
Ok(ely_browser_core::SyncOutcome::RemoteSnapshot {
snapshot_id,
@@ -454,12 +460,7 @@ fn run_sync_upload(
Err(error) => {
let message = error.to_string();
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
let update = if message.contains("device_not_approved") {
SyncStateUpdate::AwaitingDeviceApproval { profile_id }
} else {
SyncStateUpdate::SyncError { profile_id, message }
};
let _ = inbox.send(update);
let _ = inbox.send(sync_failure_update(profile_id, error));
}
}
}
+84 -12
View File
@@ -7,7 +7,7 @@ use gpui::Context;
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
use super::sync_state::sync_platform_label;
use super::sync_state::{device_failure_update, sync_platform_label};
use super::{ElyShell, ShellState, sync_state::SyncStateUpdate};
#[derive(Clone, Debug, Default)]
@@ -141,8 +141,12 @@ impl ElyShell {
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
let engine = SyncEngine::for_profile_dir(
&profile_id,
&profile_dir,
device_name,
sync_platform_label(),
)?;
engine.approve_cloud_device(&device_id, &verification_code)?;
load_devices(profile_id, engine)
});
@@ -163,8 +167,12 @@ impl ElyShell {
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
let engine = SyncEngine::for_profile_dir(
&profile_id,
&profile_dir,
device_name,
sync_platform_label(),
)?;
engine.revoke_cloud_device(&device_id)?;
load_devices(profile_id, engine)
});
@@ -181,8 +189,12 @@ impl ElyShell {
}
let tx = self.sync_inbox_tx.clone();
spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || {
let engine =
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
let engine = SyncEngine::for_profile_dir(
&profile_id,
&profile_dir,
device_name,
sync_platform_label(),
)?;
load_devices(profile_id, engine)
});
}
@@ -191,7 +203,7 @@ impl ElyShell {
let ShellState::Ready(core) = &self.state else {
return None;
};
if !core.active_profile_allows_sync() {
if !core.cloud_sync_upload_enabled() {
return None;
}
let snapshot = core.snapshot().ok()?;
@@ -225,10 +237,7 @@ fn spawn_device_task<F>(
let worker_tx = tx.clone();
let worker_profile_id = profile_id.clone();
let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || {
let update = task().unwrap_or_else(|error| SyncStateUpdate::DevicesError {
profile_id: worker_profile_id,
message: error.to_string(),
});
let update = task().unwrap_or_else(|error| device_failure_update(worker_profile_id, error));
let _ = worker_tx.send(update);
});
if let Err(error) = spawn_result {
@@ -257,4 +266,67 @@ mod tests {
assert!(state.current_code().is_none());
assert!(state.error().is_none());
}
#[test]
fn credential_failures_do_not_finish_an_upload() {
let profile_id = ProfileId::new();
let update = device_failure_update(
profile_id.clone(),
ely_sync_client::SyncClientError::BearerCredentialStorage("locked".to_string()),
);
assert!(matches!(
update,
SyncStateUpdate::CredentialUnavailable {
profile_id: owner,
finishes_upload: false,
..
} if owner == profile_id
));
}
#[test]
fn approval_failures_do_not_finish_an_upload() {
let profile_id = ProfileId::new();
let errors = [
ely_sync_client::SyncClientError::DeviceApprovalStatus {
device_id: "device-id".to_string(),
status: "pending".to_string(),
},
ely_sync_client::SyncClientError::HttpStatus {
endpoint: "/api/sync/devices".to_string(),
status: 403,
body: r#"{"error":"device_not_approved"}"#.to_string(),
},
];
for error in errors {
let update = device_failure_update(profile_id.clone(), error);
assert!(matches!(
update,
SyncStateUpdate::AwaitingDeviceApproval {
profile_id: owner,
finishes_upload: false,
} if owner == profile_id
));
}
}
#[test]
fn operational_failures_stay_in_device_ui_state() {
let profile_id = ProfileId::new();
let update = device_failure_update(
profile_id.clone(),
ely_sync_client::SyncClientError::HttpStatus {
endpoint: "/api/sync/devices".to_string(),
status: 503,
body: "unavailable".to_string(),
},
);
assert!(matches!(
update,
SyncStateUpdate::DevicesError { profile_id: owner, .. } if owner == profile_id
));
}
}
+139 -105
View File
@@ -1,11 +1,13 @@
use std::{path::Path, time::Duration};
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
use ely_sync_client::{AuthenticatedSnapshotHead, DeviceRecord};
use ely_sync_client::{AuthenticatedSnapshotHead, BearerTokenStore, DeviceRecord};
use gpui::{Context, Timer};
use super::{ElyShell, ShellState, auth};
mod legacy_sync_migration;
const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
const CAS_RETRY_LIMIT: u8 = 3;
const CAS_RETRY_DELAY: Duration = Duration::from_secs(2);
@@ -25,11 +27,12 @@ pub(crate) struct PendingMergeUpload {
#[derive(Clone, Debug)]
pub(crate) enum SyncStateUpdate {
SignedOut { profile_id: ProfileId },
AwaitingDeviceApproval { profile_id: ProfileId },
AwaitingDeviceApproval { profile_id: ProfileId, finishes_upload: bool },
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
SyncBusy { profile_id: ProfileId },
SyncError { profile_id: ProfileId, message: String },
CredentialUnavailable { profile_id: ProfileId, message: String, finishes_upload: bool },
DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String },
DevicesError { profile_id: ProfileId, message: String },
AuthOtpSent { profile_id: ProfileId, email: String },
@@ -37,6 +40,45 @@ pub(crate) enum SyncStateUpdate {
AuthError { profile_id: ProfileId, email: String, message: String },
}
pub(super) fn sync_failure_update(
profile_id: ProfileId,
error: ely_sync_client::SyncClientError,
) -> SyncStateUpdate {
let message = error.to_string();
match error {
ely_sync_client::SyncClientError::BearerCredentialStorage(_)
| ely_sync_client::SyncClientError::AccountKeyStorage(_)
| ely_sync_client::SyncClientError::DeviceKeyStorage(_) => {
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload: true }
}
ely_sync_client::SyncClientError::DeviceApprovalStatus { .. } => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: true }
}
_ if message.contains("device_not_approved") => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: true }
}
_ => SyncStateUpdate::SyncError { profile_id, message },
}
}
pub(super) fn device_failure_update(
profile_id: ProfileId,
error: ely_sync_client::SyncClientError,
) -> SyncStateUpdate {
match sync_failure_update(profile_id, error) {
SyncStateUpdate::CredentialUnavailable { profile_id, message, .. } => {
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload: false }
}
SyncStateUpdate::AwaitingDeviceApproval { profile_id, .. } => {
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload: false }
}
SyncStateUpdate::SyncError { profile_id, message } => {
SyncStateUpdate::DevicesError { profile_id, message }
}
update => update,
}
}
/// Stable label for the current OS used by the device registration
/// payload. Defined once here so every off-thread call site agrees.
pub(crate) const fn sync_platform_label() -> &'static str {
@@ -128,7 +170,7 @@ impl ElyShell {
let ShellState::Ready(core) = &mut self.state else {
return false;
};
probe_initial_sync_state_at(core, &profile_root)
probe_initial_sync_state_at(core, &profile_root, self.default_profile_id.as_ref())
}
/// Drain any sync upload outcomes the off-thread worker pushed
@@ -155,8 +197,8 @@ impl ElyShell {
latest_connection = Some(SyncConnectionState::SignedOut);
}
}
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => {
upload_finished = true;
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload } => {
upload_finished |= finishes_upload;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
}
@@ -216,6 +258,20 @@ impl ElyShell {
latest_connection = Some(SyncConnectionState::SyncError { message });
}
}
SyncStateUpdate::CredentialUnavailable { profile_id, message, finishes_upload } => {
upload_finished |= finishes_upload;
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
latest_connection =
Some(SyncConnectionState::CredentialUnavailable { message });
self.sync_devices.reset();
self.sync_upload_scheduled = false;
self.sync_retry_at = None;
self.clear_pending_cloud_sync_upload();
trigger_initial_sync = false;
trigger_merged_upload = None;
devices_changed = true;
}
}
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
self.sync_devices.set_ready(profile_id, devices, current_code);
@@ -296,6 +352,7 @@ fn active_profile_id(state: &ShellState) -> Option<ProfileId> {
fn probe_initial_sync_state_at(
core: &mut ely_browser_core::BrowserCore,
profile_root: &Path,
default_profile_id: Option<&ProfileId>,
) -> bool {
let Some(snapshot) = core.snapshot().ok() else {
return false;
@@ -305,65 +362,46 @@ fn probe_initial_sync_state_at(
&snapshot.active_profile_id,
);
if !core.active_profile_allows_sync() {
if let Err(error) = auth::clear_persisted_bearer(&profile_dir) {
let store = BearerTokenStore::new(&snapshot.active_profile_id, &profile_dir);
if let Err(error) = store.clear_legacy_files() {
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)
let is_default_profile = matches!(snapshot.active_profile_kind, ProfileKind::Standard)
&& default_profile_id == Some(&snapshot.active_profile_id);
if is_default_profile
&& let Err(error) =
legacy_sync_migration::migrate_default_sync_device(profile_root, &profile_dir)
{
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)
}
fn migrate_legacy_default_sync_dir(profile_root: &Path, stable_profile_dir: &Path) {
let stable_sync_dir = stable_profile_dir.join("sync");
if stable_sync_dir.exists() {
return;
}
let candidate = profile_root.join("default").join("servo").join("sync");
if !bearer_token_file_present(&candidate.join("bearer.token")) {
return;
}
if let Err(error) = copy_dir_recursive(&candidate, &stable_sync_dir) {
tracing::warn!(
target: "ely::sync",
error = %error,
source = %candidate.display(),
"legacy sync profile migration failed",
);
}
}
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(destination)?;
for entry in std::fs::read_dir(source)? {
let entry = entry?;
let file_type = entry.file_type()?;
let destination_path = destination.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &destination_path)?;
} else if file_type.is_file() {
std::fs::copy(entry.path(), destination_path)?;
let store = auth::bearer_store_for_profile(
&snapshot.active_profile_id,
&profile_dir,
profile_root,
default_profile_id,
);
let (bearer_present, state) = match store.load() {
Ok(Some(_)) => (true, SyncConnectionState::SignedIn),
Ok(None) => (false, SyncConnectionState::SignedOut),
Err(error) => {
tracing::warn!(target: "ely::sync", error = %error, "bearer credential probe failed");
(
false,
SyncConnectionState::CredentialUnavailable {
message: "System credential access failed.".to_string(),
},
)
}
}
Ok(())
};
core.set_sync_connection_state(state);
bearer_present
}
#[cfg(test)]
@@ -371,59 +409,9 @@ mod tests {
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 super::{SyncStateUpdate, probe_initial_sync_state_at, sync_failure_update};
use crate::services::servo_profile_data::sync_profile_data_dir;
#[test]
fn bearer_token_file_presence_requires_bytes() -> Result<(), Box<dyn std::error::Error>> {
let suffix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH)?.as_nanos();
let dir = std::env::temp_dir().join(format!("ely-sync-token-probe-{}", suffix));
std::fs::create_dir_all(&dir)?;
let path = dir.join("bearer.token");
assert!(!bearer_token_file_present(&path));
std::fs::write(&path, "")?;
assert!(!bearer_token_file_present(&path));
std::fs::write(&path, "session-token")?;
assert!(bearer_token_file_present(&path));
std::fs::remove_dir_all(dir)?;
Ok(())
}
#[test]
fn known_default_sync_directory_is_migrated() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let legacy = directory.path().join("default/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&legacy)?;
std::fs::write(legacy.join("bearer.token"), "default-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
assert_eq!(std::fs::read_to_string(stable.join("sync/bearer.token"))?, "default-token");
Ok(())
}
#[test]
fn custom_profile_bearer_is_ignored_during_default_migration()
-> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let custom = directory.path().join("profile_custom/servo/sync");
let stable = directory.path().join("profile_stable/servo");
std::fs::create_dir_all(&custom)?;
std::fs::write(custom.join("bearer.token"), "custom-token")?;
migrate_legacy_default_sync_dir(directory.path(), &stable);
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()?;
@@ -436,7 +424,7 @@ mod tests {
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!(!probe_initial_sync_state_at(&mut core, directory.path(), None));
assert!(!bearer_path.exists());
assert!(!bearer_path.with_extension("tmp").exists());
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedOut);
@@ -454,10 +442,56 @@ mod tests {
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!(probe_initial_sync_state_at(&mut core, directory.path(), Some(&profile_id),));
assert!(!bearer_path.exists());
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedIn);
let store = ely_sync_client::BearerTokenStore::new(&profile_id, &profile_dir);
assert!(store.load()?.is_some());
store.clear()?;
Ok(())
}
#[cfg(unix)]
#[test]
fn credential_probe_failure_enters_unavailable_state() -> Result<(), Box<dyn std::error::Error>>
{
use std::os::unix::fs::symlink;
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 lock = profile_dir.join("sync/bearer.lock");
let target = directory.path().join("untrusted.lock");
std::fs::create_dir_all(lock.parent().ok_or("missing lock parent")?)?;
std::fs::write(&target, "target")?;
symlink(&target, &lock)?;
assert!(!probe_initial_sync_state_at(&mut core, directory.path(), Some(&profile_id),));
assert!(matches!(
core.snapshot()?.sync_status.connection(),
SyncConnectionState::CredentialUnavailable { .. }
));
Ok(())
}
#[test]
fn credential_storage_failures_use_the_unavailable_update() {
let profile_id = ely_domain::ProfileId::new();
let update = sync_failure_update(
profile_id.clone(),
ely_sync_client::SyncClientError::BearerCredentialStorage("locked".to_string()),
);
assert!(matches!(
update,
SyncStateUpdate::CredentialUnavailable {
profile_id: owner,
finishes_upload: true,
..
} if owner == profile_id
));
}
}
@@ -0,0 +1,351 @@
use std::{
io::{self, ErrorKind, Read, Write},
path::{Component, Path},
};
use cap_fs_ext::{
DirExt, FollowSymlinks, MetadataExt as CrossPlatformMetadataExt, OpenOptionsFollowExt,
};
use cap_std::{
ambient_authority,
fs::{Dir, File, Metadata, OpenOptions, Permissions},
};
use ely_sync_client::DeviceIdentity;
use uuid::Uuid;
const SOURCE_COMPONENTS: [&str; 3] = ["default", "servo", "sync"];
const DEVICE_FILE: &str = "device.json";
const COMPLETION_MARKER: &str = ".default-sync-device-migrated-v1";
const LOCK_FILE: &str = ".default-sync-device-migration.lock";
const TEMP_PREFIX: &str = ".ely-sync-migration-";
const COMPLETION_BYTES: &[u8] = b"ely-default-sync-device-migration-v1\n";
const MAX_DEVICE_BYTES: usize = 16 * 1024;
#[cfg(unix)]
type ExpectedOwner = u32;
#[cfg(not(unix))]
type ExpectedOwner = ();
pub(super) fn migrate_default_sync_device(
profile_root: &Path,
stable_profile_dir: &Path,
) -> io::Result<()> {
let expected_owner = effective_owner()?;
let root = Dir::open_ambient_dir(profile_root, ambient_authority())?;
validate_directory(&root, expected_owner)?;
let relative_destination = stable_profile_dir
.strip_prefix(profile_root)
.map_err(|_| invalid_source("stable profile directory escapes the profile root"))?;
let stable = open_or_create_path(&root, relative_destination, expected_owner)?;
let destination = open_or_create_child(&stable, "sync", expected_owner)?;
let _lock = acquire_lock(&destination, expected_owner)?;
cleanup_temporary_files(&destination)?;
if completion_marker_exists(&destination, expected_owner)? {
validate_optional_device(&destination, expected_owner)?;
return Ok(());
}
if validate_optional_device(&destination, expected_owner)? {
return write_completion_marker(&destination, expected_owner);
}
let Some(source) = open_source_directory(&root, expected_owner)? else {
return Ok(());
};
let Some(bytes) = read_secure_file(&source, DEVICE_FILE, expected_owner, MAX_DEVICE_BYTES)?
else {
return Ok(());
};
validate_device_bytes(&bytes)?;
persist_bytes_if_absent(
&destination,
DEVICE_FILE,
&bytes,
expected_owner,
validate_device_bytes,
)?;
write_completion_marker(&destination, expected_owner)
}
fn open_source_directory(root: &Dir, owner: ExpectedOwner) -> io::Result<Option<Dir>> {
let mut directory = root.try_clone()?;
for component in SOURCE_COMPONENTS {
directory = match directory.open_dir_nofollow(component) {
Ok(child) => child,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
validate_directory(&directory, owner)?;
}
Ok(Some(directory))
}
fn open_or_create_path(root: &Dir, path: &Path, owner: ExpectedOwner) -> io::Result<Dir> {
let mut directory = root.try_clone()?;
for component in path.components() {
let Component::Normal(name) = component else {
return Err(invalid_source("stable profile directory contains an invalid component"));
};
directory = open_or_create_child(&directory, name, owner)?;
}
Ok(directory)
}
fn open_or_create_child(
parent: &Dir,
name: impl AsRef<Path>,
owner: ExpectedOwner,
) -> io::Result<Dir> {
let name = name.as_ref();
let directory = match parent.open_dir_nofollow(name) {
Ok(directory) => directory,
Err(error) if error.kind() == ErrorKind::NotFound => {
match parent.create_dir(name) {
Ok(()) => {}
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
let directory = parent.open_dir_nofollow(name)?;
set_private_directory_permissions(&directory)?;
directory
}
Err(error) => return Err(error),
};
validate_directory(&directory, owner)?;
Ok(directory)
}
fn acquire_lock(directory: &Dir, owner: ExpectedOwner) -> io::Result<std::fs::File> {
let mut options = private_open_options();
options.read(true).write(true).create(true);
let file = directory.open_with(LOCK_FILE, &options)?;
validate_file(&file, owner)?;
set_private_file_permissions(&file)?;
let file = file.into_std();
fs2::FileExt::lock_exclusive(&file)?;
Ok(file)
}
fn validate_optional_device(directory: &Dir, owner: ExpectedOwner) -> io::Result<bool> {
let Some(bytes) = read_secure_file(directory, DEVICE_FILE, owner, MAX_DEVICE_BYTES)? else {
return Ok(false);
};
validate_device_bytes(&bytes)?;
Ok(true)
}
fn completion_marker_exists(directory: &Dir, owner: ExpectedOwner) -> io::Result<bool> {
let Some(bytes) =
read_secure_file(directory, COMPLETION_MARKER, owner, COMPLETION_BYTES.len())?
else {
return Ok(false);
};
validate_marker_bytes(&bytes)?;
Ok(true)
}
fn write_completion_marker(directory: &Dir, owner: ExpectedOwner) -> io::Result<()> {
persist_bytes_if_absent(
directory,
COMPLETION_MARKER,
COMPLETION_BYTES,
owner,
validate_marker_bytes,
)
}
fn persist_bytes_if_absent(
directory: &Dir,
destination: &str,
bytes: &[u8],
owner: ExpectedOwner,
validate: fn(&[u8]) -> io::Result<()>,
) -> io::Result<()> {
if let Some(existing) = read_secure_file(directory, destination, owner, MAX_DEVICE_BYTES)? {
return validate(&existing);
}
let temporary = format!("{TEMP_PREFIX}{}", Uuid::now_v7().simple());
let mut options = private_open_options();
options.write(true).create_new(true);
let mut file = directory.open_with(&temporary, &options)?;
validate_file(&file, owner)?;
set_private_file_permissions(&file)?;
if let Err(error) = file.write_all(bytes).and_then(|()| file.sync_all()) {
let _ = directory.remove_file(&temporary);
return Err(error);
}
let linked = match directory.hard_link(&temporary, directory, destination) {
Ok(()) => true,
Err(error) if error.kind() == ErrorKind::AlreadyExists => false,
Err(error) => {
let _ = directory.remove_file(&temporary);
return Err(error);
}
};
remove_file_if_present(directory, &temporary)?;
if linked {
sync_directory(directory)?;
}
let existing = read_secure_file(directory, destination, owner, MAX_DEVICE_BYTES)?
.ok_or_else(|| invalid_source("migration destination disappeared"))?;
validate(&existing)
}
fn read_secure_file(
directory: &Dir,
name: &str,
owner: ExpectedOwner,
maximum_bytes: usize,
) -> io::Result<Option<Vec<u8>>> {
let mut options = private_open_options();
options.read(true);
let file = match directory.open_with(name, &options) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error),
};
validate_file(&file, owner)?;
if file.metadata()?.len() > maximum_bytes as u64 {
return Err(invalid_source("migration file exceeds its size limit"));
}
let mut bytes = Vec::new();
file.into_std().take((maximum_bytes + 1) as u64).read_to_end(&mut bytes)?;
if bytes.len() > maximum_bytes {
return Err(invalid_source("migration file exceeds its size limit"));
}
Ok(Some(bytes))
}
fn cleanup_temporary_files(directory: &Dir) -> io::Result<()> {
for entry in directory.entries()? {
let entry = entry?;
let name = entry.file_name();
if !name.to_string_lossy().starts_with(TEMP_PREFIX) {
continue;
}
if entry.file_type()?.is_dir() {
return Err(invalid_source("migration temporary path is a directory"));
}
directory.remove_file_or_symlink(name)?;
}
Ok(())
}
fn remove_file_if_present(directory: &Dir, name: &str) -> io::Result<()> {
match directory.remove_file(name) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
}
fn private_open_options() -> OpenOptions {
let mut options = OpenOptions::new();
options.follow(FollowSymlinks::No);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
options.mode(0o600);
}
options
}
fn validate_directory(directory: &Dir, owner: ExpectedOwner) -> io::Result<()> {
let metadata = directory.dir_metadata()?;
if !metadata.is_dir() {
return Err(invalid_source("migration path is not a directory"));
}
validate_owner_and_mode(&metadata, owner)
}
fn validate_file(file: &File, owner: ExpectedOwner) -> io::Result<()> {
let metadata = file.metadata()?;
if !metadata.is_file() || CrossPlatformMetadataExt::nlink(&metadata) != 1 {
return Err(invalid_source("migration file link state is invalid"));
}
validate_owner_and_mode(&metadata, owner)
}
#[cfg(unix)]
fn validate_owner_and_mode(metadata: &Metadata, owner: ExpectedOwner) -> io::Result<()> {
use cap_std::fs::{MetadataExt, PermissionsExt};
if metadata.uid() != owner || metadata.permissions().mode() & 0o022 != 0 {
return Err(io::Error::new(
ErrorKind::PermissionDenied,
"migration path ownership or permissions are invalid",
));
}
Ok(())
}
#[cfg(not(unix))]
const fn validate_owner_and_mode(_metadata: &Metadata, _owner: ExpectedOwner) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn effective_owner() -> io::Result<ExpectedOwner> {
use std::os::unix::fs::MetadataExt;
let probe = tempfile::NamedTempFile::new()?;
Ok(probe.as_file().metadata()?.uid())
}
#[cfg(not(unix))]
const fn effective_owner() -> io::Result<ExpectedOwner> {
Ok(())
}
#[cfg(unix)]
fn set_private_directory_permissions(directory: &Dir) -> io::Result<()> {
use cap_std::fs::PermissionsExt;
directory.set_permissions(".", Permissions::from_mode(0o700))
}
#[cfg(not(unix))]
const fn set_private_directory_permissions(_directory: &Dir) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn set_private_file_permissions(file: &File) -> io::Result<()> {
use cap_std::fs::PermissionsExt;
file.set_permissions(Permissions::from_mode(0o600))
}
#[cfg(not(unix))]
const fn set_private_file_permissions(_file: &File) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn sync_directory(directory: &Dir) -> io::Result<()> {
directory.try_clone()?.into_std_file().sync_all()
}
#[cfg(not(unix))]
const fn sync_directory(_directory: &Dir) -> io::Result<()> {
Ok(())
}
fn validate_device_bytes(bytes: &[u8]) -> io::Result<()> {
DeviceIdentity::validate_stored_bytes(bytes, Path::new(DEVICE_FILE))
.map_err(|error| invalid_source(error.to_string()))
}
fn validate_marker_bytes(bytes: &[u8]) -> io::Result<()> {
if bytes == COMPLETION_BYTES {
Ok(())
} else {
Err(invalid_source("legacy sync migration marker is invalid"))
}
}
fn invalid_source(message: impl Into<String>) -> io::Error {
io::Error::new(ErrorKind::InvalidData, message.into())
}
#[cfg(test)]
#[path = "legacy_sync_migration_tests.rs"]
mod tests;
@@ -0,0 +1,159 @@
use std::{fs, path::Path};
use super::*;
const PUBLIC_KEY: &str = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
#[test]
fn copies_only_valid_device_once() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
fs::create_dir_all(source.join("nested"))?;
fs::write(source.join(DEVICE_FILE), device_bytes("Legacy"))?;
fs::write(source.join("bearer.token"), "secret")?;
fs::write(source.join("other.json"), "other")?;
migrate_default_sync_device(directory.path(), &stable)?;
let destination = stable.join("sync");
assert_eq!(fs::read(destination.join(DEVICE_FILE))?, device_bytes("Legacy"));
assert!(!destination.join("bearer.token").exists());
assert!(!destination.join("other.json").exists());
assert!(!destination.join("nested").exists());
fs::remove_file(destination.join(DEVICE_FILE))?;
fs::write(source.join(DEVICE_FILE), device_bytes("Changed"))?;
migrate_default_sync_device(directory.path(), &stable)?;
assert!(!destination.join(DEVICE_FILE).exists());
Ok(())
}
#[test]
fn preserves_a_valid_existing_destination() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
fs::create_dir_all(&source)?;
fs::create_dir_all(stable.join("sync"))?;
fs::write(source.join(DEVICE_FILE), device_bytes("Legacy"))?;
fs::write(stable.join("sync/device.json"), device_bytes("Current"))?;
migrate_default_sync_device(directory.path(), &stable)?;
assert_eq!(fs::read(stable.join("sync/device.json"))?, device_bytes("Current"));
assert!(stable.join("sync").join(COMPLETION_MARKER).exists());
Ok(())
}
#[test]
fn missing_source_retries_later() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let stable = stable_dir(directory.path());
migrate_default_sync_device(directory.path(), &stable)?;
assert!(!stable.join("sync").join(COMPLETION_MARKER).exists());
let source = source_dir(directory.path());
fs::create_dir_all(&source)?;
fs::write(source.join(DEVICE_FILE), device_bytes("Late"))?;
migrate_default_sync_device(directory.path(), &stable)?;
assert_eq!(fs::read(stable.join("sync/device.json"))?, device_bytes("Late"));
Ok(())
}
#[test]
fn rejects_invalid_and_oversized_sources() -> Result<(), Box<dyn std::error::Error>> {
for bytes in [b"{}".to_vec(), vec![b'a'; MAX_DEVICE_BYTES + 1]] {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
fs::create_dir_all(&source)?;
fs::write(source.join(DEVICE_FILE), bytes)?;
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
assert!(!stable.join("sync").join(COMPLETION_MARKER).exists());
}
Ok(())
}
#[cfg(unix)]
#[test]
fn rejects_source_ancestor_and_destination_links() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
for linked_component in 0..SOURCE_COMPONENTS.len() {
let directory = tempfile::tempdir()?;
let target = directory.path().join("source-target");
let mut real = target.clone();
for component in &SOURCE_COMPONENTS[linked_component + 1..] {
real.push(component);
}
fs::create_dir_all(&real)?;
fs::write(real.join(DEVICE_FILE), device_bytes("Linked"))?;
let mut link = directory.path().to_path_buf();
for component in &SOURCE_COMPONENTS[..linked_component] {
link.push(component);
}
fs::create_dir_all(&link)?;
link.push(SOURCE_COMPONENTS[linked_component]);
symlink(&target, &link)?;
assert!(
migrate_default_sync_device(directory.path(), &stable_dir(directory.path())).is_err()
);
}
let directory = tempfile::tempdir()?;
let stable = stable_dir(directory.path());
fs::create_dir_all(&stable)?;
symlink(directory.path().join("elsewhere"), stable.join("sync"))?;
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
Ok(())
}
#[cfg(unix)]
#[test]
fn rejects_linked_destination_files() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
for name in [DEVICE_FILE, COMPLETION_MARKER] {
let directory = tempfile::tempdir()?;
let stable = stable_dir(directory.path());
let destination = stable.join("sync");
let target = directory.path().join("target");
fs::create_dir_all(&destination)?;
fs::write(
&target,
if name == DEVICE_FILE { device_bytes("Target") } else { COMPLETION_BYTES.to_vec() },
)?;
symlink(&target, destination.join(name))?;
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
}
Ok(())
}
#[test]
fn rejects_hardlinked_source_and_destination() -> Result<(), Box<dyn std::error::Error>> {
for destination_link in [false, true] {
let directory = tempfile::tempdir()?;
let source = source_dir(directory.path());
let stable = stable_dir(directory.path());
let target = directory.path().join("target.json");
fs::create_dir_all(&source)?;
fs::write(&target, device_bytes("Target"))?;
if destination_link {
fs::create_dir_all(stable.join("sync"))?;
fs::hard_link(&target, stable.join("sync/device.json"))?;
} else {
fs::hard_link(&target, source.join(DEVICE_FILE))?;
}
assert!(migrate_default_sync_device(directory.path(), &stable).is_err());
}
Ok(())
}
fn source_dir(root: &Path) -> std::path::PathBuf {
root.join("default/servo/sync")
}
fn stable_dir(root: &Path) -> std::path::PathBuf {
root.join("profile_stable/servo")
}
fn device_bytes(name: &str) -> Vec<u8> {
format!(
r#"{{"device_id":"ely-legacy-device","public_key":"{PUBLIC_KEY}","device_name":"{name}","platform":"macos"}}"#,
)
.into_bytes()
}