fix(auth): store bearer tokens in native credentials
This commit is contained in:
@@ -10,12 +10,15 @@ live-site-smoke = []
|
||||
|
||||
[dependencies]
|
||||
ahash = "0.8"
|
||||
cap-fs-ext.workspace = true
|
||||
cap-std.workspace = true
|
||||
directories.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
ely_browser_core = { path = "../ely_browser_core" }
|
||||
ely_design_system = { path = "../ely_design_system" }
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
ely_sync_client = { path = "../ely_sync_client" }
|
||||
fs2.workspace = true
|
||||
gpui.workspace = true
|
||||
gpui-component.workspace = true
|
||||
gpui-component-assets.workspace = true
|
||||
@@ -29,6 +32,7 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
ureq.workspace = true
|
||||
url.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
@@ -37,7 +41,6 @@ io-surface = "0.16"
|
||||
mach2 = "0.6"
|
||||
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
||||
objc2-io-surface = "0.3.2"
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ely_servo_host = { path = "../ely_servo_host" }
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user