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()
|
||||
}
|
||||
@@ -3,6 +3,7 @@ use std::{
|
||||
time::{SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ely_domain::ProfileId;
|
||||
use ely_sync_client::{
|
||||
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
|
||||
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
|
||||
@@ -34,6 +35,7 @@ impl SyncEngine {
|
||||
/// fresh device identity on first use, then keeps it stable across
|
||||
/// runs so the server's `user_devices` row stays bound.
|
||||
pub fn for_profile_dir(
|
||||
profile_id: &ProfileId,
|
||||
profile_data_dir: &Path,
|
||||
device_name: impl Into<String>,
|
||||
platform: impl Into<String>,
|
||||
@@ -46,7 +48,7 @@ impl SyncEngine {
|
||||
.join(".sync-key-locks");
|
||||
let identity =
|
||||
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
|
||||
let bearer_store = BearerTokenStore::new(sync_dir.join("bearer.token"));
|
||||
let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir);
|
||||
Ok(Self {
|
||||
api_config: ApiClientConfig::production(),
|
||||
bearer_store,
|
||||
@@ -60,10 +62,6 @@ impl SyncEngine {
|
||||
&self.identity
|
||||
}
|
||||
|
||||
pub fn bearer_path(&self) -> &Path {
|
||||
self.bearer_store.path()
|
||||
}
|
||||
|
||||
pub fn last_outcome(&self) -> Option<&SyncOutcome> {
|
||||
self.last_outcome.as_ref()
|
||||
}
|
||||
@@ -436,6 +434,7 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SyncEngineBuilder {
|
||||
pub profile_id: ProfileId,
|
||||
pub profile_data_dir: PathBuf,
|
||||
pub device_name: String,
|
||||
pub platform: String,
|
||||
@@ -443,7 +442,12 @@ pub struct SyncEngineBuilder {
|
||||
|
||||
impl SyncEngineBuilder {
|
||||
pub fn build(self) -> Result<SyncEngine, SyncClientError> {
|
||||
SyncEngine::for_profile_dir(&self.profile_data_dir, self.device_name, self.platform)
|
||||
SyncEngine::for_profile_dir(
|
||||
&self.profile_id,
|
||||
&self.profile_data_dir,
|
||||
self.device_name,
|
||||
self.platform,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,17 @@ fn default_sync_status_reflects_local_browser_state() -> Result<(), Box<dyn Erro
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_credentials_disable_cloud_uploads() -> Result<(), Box<dyn Error>> {
|
||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||
core.set_sync_connection_state(SyncConnectionState::CredentialUnavailable {
|
||||
message: "credential unavailable".to_string(),
|
||||
});
|
||||
|
||||
assert!(!core.cloud_sync_upload_enabled());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_object_policy_pauses_object_kind() -> Result<(), Box<dyn Error>> {
|
||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum SyncConnectionState {
|
||||
SignedOut,
|
||||
CredentialUnavailable { message: String },
|
||||
SignedIn,
|
||||
AwaitingDeviceApproval,
|
||||
SyncReady { last_synced_at_secs: u64 },
|
||||
@@ -104,7 +105,8 @@ impl SyncStatus {
|
||||
#[must_use]
|
||||
pub fn new(connection: SyncConnectionState, objects: Vec<SyncObjectStatus>) -> Self {
|
||||
let failed_objects = match &connection {
|
||||
SyncConnectionState::SyncError { .. } => 1,
|
||||
SyncConnectionState::CredentialUnavailable { .. }
|
||||
| SyncConnectionState::SyncError { .. } => 1,
|
||||
_ => 0,
|
||||
};
|
||||
Self { connection, pending_objects: 0, failed_objects, objects }
|
||||
|
||||
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
cap-fs-ext.workspace = true
|
||||
cap-std.workspace = true
|
||||
chacha20poly1305.workspace = true
|
||||
ed25519-dalek.workspace = true
|
||||
ely_domain = { path = "../ely_domain" }
|
||||
@@ -15,7 +17,7 @@ getrandom.workspace = true
|
||||
hkdf.workspace = true
|
||||
hmac.workspace = true
|
||||
hpke.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
@@ -24,5 +26,20 @@ ureq = { workspace = true, features = ["json"] }
|
||||
uuid = { workspace = true }
|
||||
zeroize.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { workspace = true, features = ["process"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
apple-native-keyring-store = { version = "1.0.0", features = ["keychain"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-native-keyring-store = "1.1.0"
|
||||
|
||||
[target.'cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))'.dependencies]
|
||||
zbus-secret-service-keyring-store = { version = "1.0.0", features = ["crypto-rust"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -1,31 +1,56 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, ErrorKind},
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::error::SyncClientError;
|
||||
use ely_domain::ProfileId;
|
||||
use fs2::FileExt;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Better Auth bearer token issued by `https://<base>/api/auth/*`. The
|
||||
/// token grants access to the per-user `withAuthenticatedApiControls`
|
||||
/// routes and, once the device is bound to the session, to the
|
||||
/// `withApprovedDeviceApiControls` routes used by sync.
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct BearerToken(String);
|
||||
use crate::{
|
||||
auth_files::{
|
||||
ensure_migration_marker, migration_marker_exists, open_lock_file, read_legacy_bytes,
|
||||
remove_file_if_present,
|
||||
},
|
||||
credential_store::{clear_secret, load_secret, save_secret},
|
||||
error::SyncClientError,
|
||||
};
|
||||
|
||||
const KEYCHAIN_SERVICE: &str = "com.elydora.ely-browser.auth.bearer.v1";
|
||||
const MIN_BEARER_TOKEN_BYTES: usize = 32;
|
||||
const MAX_BEARER_TOKEN_BYTES: usize = 2560;
|
||||
|
||||
/// Better Auth bearer token issued by `https://<base>/api/auth/*`.
|
||||
pub struct BearerToken(Zeroizing<String>);
|
||||
|
||||
impl Clone for BearerToken {
|
||||
fn clone(&self) -> Self {
|
||||
Self(Zeroizing::new(self.as_str().to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for BearerToken {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("BearerToken([REDACTED])")
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq for BearerToken {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.as_str() == other.as_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Eq for BearerToken {}
|
||||
|
||||
impl BearerToken {
|
||||
/// Construct a token from an existing string. Trims whitespace and
|
||||
/// enforces the same character envelope the worker validates so
|
||||
/// obviously-malformed tokens fail before we hit the network.
|
||||
pub fn new(value: impl Into<String>) -> Result<Self, SyncClientError> {
|
||||
let value = value.into();
|
||||
let value = Zeroizing::new(value.into());
|
||||
let trimmed = value.trim();
|
||||
if trimmed.is_empty() || !is_better_auth_bearer(trimmed) {
|
||||
return Err(SyncClientError::TokenStorage(
|
||||
"bearer token is not a Better Auth session token".to_string(),
|
||||
));
|
||||
return Err(storage_error("bearer token is not a Better Auth session token"));
|
||||
}
|
||||
Ok(Self(trimmed.to_string()))
|
||||
Ok(Self(Zeroizing::new(trimmed.to_string())))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
@@ -34,7 +59,7 @@ impl BearerToken {
|
||||
}
|
||||
|
||||
fn is_better_auth_bearer(token: &str) -> bool {
|
||||
let length_ok = (32..=4096).contains(&token.len());
|
||||
let length_ok = (MIN_BEARER_TOKEN_BYTES..=MAX_BEARER_TOKEN_BYTES).contains(&token.len());
|
||||
let charset_ok = token
|
||||
.as_bytes()
|
||||
.iter()
|
||||
@@ -42,100 +67,238 @@ fn is_better_auth_bearer(token: &str) -> bool {
|
||||
length_ok && charset_ok
|
||||
}
|
||||
|
||||
/// File-backed bearer token store. Lives in the per-profile data
|
||||
/// directory so a private window never inherits the standard
|
||||
/// profile's session — same isolation the rest of the runtime
|
||||
/// enforces. Writes go through a temp-rename so a partial write
|
||||
/// can't corrupt the persisted token.
|
||||
trait CredentialBackend {
|
||||
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String>;
|
||||
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String>;
|
||||
fn clear(&self, service: &str, account: &str) -> Result<(), String>;
|
||||
}
|
||||
|
||||
struct NativeCredentialBackend;
|
||||
|
||||
impl CredentialBackend for NativeCredentialBackend {
|
||||
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
||||
load_secret(service, account)
|
||||
}
|
||||
|
||||
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
|
||||
save_secret(service, account, secret)
|
||||
}
|
||||
|
||||
fn clear(&self, service: &str, account: &str) -> Result<(), String> {
|
||||
clear_secret(service, account)
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile-scoped native credential store with one-time plaintext-file migration.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BearerTokenStore {
|
||||
path: PathBuf,
|
||||
account: String,
|
||||
lock_path: PathBuf,
|
||||
migration_marker_path: PathBuf,
|
||||
legacy_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl BearerTokenStore {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path }
|
||||
pub fn new(profile_id: &ProfileId, profile_data_dir: &Path) -> Self {
|
||||
let sync_dir = profile_data_dir.join("sync");
|
||||
Self {
|
||||
account: profile_id.as_str().to_string(),
|
||||
lock_path: sync_dir.join("bearer.lock"),
|
||||
migration_marker_path: sync_dir.join("bearer.migrated"),
|
||||
legacy_paths: vec![sync_dir.join("bearer.token")],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
pub fn with_legacy_path(mut self, path: PathBuf) -> Self {
|
||||
if !self.legacy_paths.contains(&path) {
|
||||
self.legacy_paths.push(path);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<Option<BearerToken>, SyncClientError> {
|
||||
match fs::read_to_string(&self.path) {
|
||||
Ok(contents) => {
|
||||
if contents.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
BearerToken::new(contents).map(Some)
|
||||
}
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
|
||||
}
|
||||
self.load_with(&NativeCredentialBackend)
|
||||
}
|
||||
|
||||
pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> {
|
||||
if let Some(parent) = self.path.parent() {
|
||||
fs::create_dir_all(parent).map_err(io_err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("tmp");
|
||||
fs::write(&tmp, token.as_str()).map_err(io_err)?;
|
||||
fs::rename(&tmp, &self.path).map_err(io_err)?;
|
||||
Ok(())
|
||||
self.save_with(&NativeCredentialBackend, token)
|
||||
}
|
||||
|
||||
pub fn clear(&self) -> Result<(), SyncClientError> {
|
||||
remove_file_if_present(&self.path)?;
|
||||
remove_file_if_present(&self.path.with_extension("tmp"))
|
||||
self.clear_with(&NativeCredentialBackend)
|
||||
}
|
||||
|
||||
pub fn clear_if_matches(&self, token: &BearerToken) -> Result<bool, SyncClientError> {
|
||||
self.clear_if_matches_with(&NativeCredentialBackend, token)
|
||||
}
|
||||
|
||||
fn clear_if_matches_with<B: CredentialBackend>(
|
||||
&self,
|
||||
backend: &B,
|
||||
token: &BearerToken,
|
||||
) -> Result<bool, SyncClientError> {
|
||||
self.with_lock(|| {
|
||||
let current = load_backend_token(backend, &self.account)?;
|
||||
ensure_migration_marker(&self.migration_marker_path)?;
|
||||
self.cleanup_legacy_paths()?;
|
||||
if current.as_ref().is_some_and(|current| current != token) {
|
||||
return Ok(false);
|
||||
}
|
||||
backend.clear(KEYCHAIN_SERVICE, &self.account).map_err(storage_error)?;
|
||||
Ok(true)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_legacy_files(&self) -> Result<(), SyncClientError> {
|
||||
self.with_lock(|| self.cleanup_legacy_paths())
|
||||
}
|
||||
|
||||
fn load_with<B: CredentialBackend>(
|
||||
&self,
|
||||
backend: &B,
|
||||
) -> Result<Option<BearerToken>, SyncClientError> {
|
||||
self.with_lock(|| self.load_locked(backend))
|
||||
}
|
||||
|
||||
fn save_with<B: CredentialBackend>(
|
||||
&self,
|
||||
backend: &B,
|
||||
token: &BearerToken,
|
||||
) -> Result<(), SyncClientError> {
|
||||
self.with_lock(|| {
|
||||
ensure_migration_marker(&self.migration_marker_path)?;
|
||||
self.cleanup_legacy_paths()?;
|
||||
backend
|
||||
.save(KEYCHAIN_SERVICE, &self.account, token.as_str().as_bytes())
|
||||
.map_err(storage_error)
|
||||
})
|
||||
}
|
||||
|
||||
fn clear_with<B: CredentialBackend>(&self, backend: &B) -> Result<(), SyncClientError> {
|
||||
self.with_lock(|| {
|
||||
ensure_migration_marker(&self.migration_marker_path)?;
|
||||
self.cleanup_legacy_paths()?;
|
||||
backend.clear(KEYCHAIN_SERVICE, &self.account).map_err(storage_error)
|
||||
})
|
||||
}
|
||||
|
||||
fn load_locked<B: CredentialBackend>(
|
||||
&self,
|
||||
backend: &B,
|
||||
) -> Result<Option<BearerToken>, SyncClientError> {
|
||||
if let Some(token) = load_backend_token(backend, &self.account)? {
|
||||
ensure_migration_marker(&self.migration_marker_path)?;
|
||||
self.cleanup_legacy_paths()?;
|
||||
return Ok(Some(token));
|
||||
}
|
||||
|
||||
if migration_marker_exists(&self.migration_marker_path)? {
|
||||
self.cleanup_legacy_paths()?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut candidate = None;
|
||||
for path in &self.legacy_paths {
|
||||
if let Some(token) = read_legacy_token(path)? {
|
||||
candidate = Some(token);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let Some(token) = candidate else {
|
||||
self.cleanup_legacy_tmp_files()?;
|
||||
return Ok(None);
|
||||
};
|
||||
backend
|
||||
.save(KEYCHAIN_SERVICE, &self.account, token.as_str().as_bytes())
|
||||
.map_err(storage_error)?;
|
||||
if let Err(error) = verify_backend_token(backend, &self.account, &token) {
|
||||
return Err(rollback_migration(backend, &self.account, error));
|
||||
}
|
||||
if let Err(error) = ensure_migration_marker(&self.migration_marker_path) {
|
||||
if matches!(migration_marker_exists(&self.migration_marker_path), Ok(false)) {
|
||||
return Err(rollback_migration(backend, &self.account, error));
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
self.cleanup_legacy_paths()?;
|
||||
Ok(Some(token))
|
||||
}
|
||||
|
||||
fn cleanup_legacy_paths(&self) -> Result<(), SyncClientError> {
|
||||
for path in &self.legacy_paths {
|
||||
remove_file_if_present(path)?;
|
||||
remove_file_if_present(&path.with_extension("tmp"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cleanup_legacy_tmp_files(&self) -> Result<(), SyncClientError> {
|
||||
for path in &self.legacy_paths {
|
||||
remove_file_if_present(&path.with_extension("tmp"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn with_lock<T>(
|
||||
&self,
|
||||
operation: impl FnOnce() -> Result<T, SyncClientError>,
|
||||
) -> Result<T, SyncClientError> {
|
||||
let lock = open_lock_file(&self.lock_path)?;
|
||||
lock.lock_exclusive().map_err(|error| storage_error(error.to_string()))?;
|
||||
operation()
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_file_if_present(path: &Path) -> Result<(), SyncClientError> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
|
||||
fn load_backend_token<B: CredentialBackend>(
|
||||
backend: &B,
|
||||
account: &str,
|
||||
) -> Result<Option<BearerToken>, SyncClientError> {
|
||||
let Some(secret) = backend.load(KEYCHAIN_SERVICE, account).map_err(storage_error)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value =
|
||||
std::str::from_utf8(&secret).map_err(|error| storage_error(error.to_string()))?.to_string();
|
||||
BearerToken::new(value).map(Some)
|
||||
}
|
||||
|
||||
fn verify_backend_token<B: CredentialBackend>(
|
||||
backend: &B,
|
||||
account: &str,
|
||||
token: &BearerToken,
|
||||
) -> Result<(), SyncClientError> {
|
||||
let stored = load_backend_token(backend, account)?;
|
||||
if stored.as_ref() != Some(token) {
|
||||
return Err(storage_error("native credential read-back did not match"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_migration<B: CredentialBackend>(
|
||||
backend: &B,
|
||||
account: &str,
|
||||
cause: SyncClientError,
|
||||
) -> SyncClientError {
|
||||
match backend.clear(KEYCHAIN_SERVICE, account) {
|
||||
Ok(()) => cause,
|
||||
Err(rollback_error) => {
|
||||
storage_error(format!("{cause}; native credential rollback failed: {rollback_error}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn io_err(error: io::Error) -> SyncClientError {
|
||||
SyncClientError::TokenStorage(error.to_string())
|
||||
fn read_legacy_token(path: &Path) -> Result<Option<BearerToken>, SyncClientError> {
|
||||
let Some(bytes) = read_legacy_bytes(path, MAX_BEARER_TOKEN_BYTES + 1)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value =
|
||||
std::str::from_utf8(&bytes).map_err(|error| storage_error(error.to_string()))?.to_string();
|
||||
BearerToken::new(value).map(Some)
|
||||
}
|
||||
|
||||
fn storage_error(message: impl Into<String>) -> SyncClientError {
|
||||
SyncClientError::BearerCredentialStorage(message.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env::temp_dir;
|
||||
|
||||
#[test]
|
||||
fn rejects_obviously_broken_tokens() {
|
||||
assert!(BearerToken::new("").is_err());
|
||||
assert!(BearerToken::new(" ").is_err());
|
||||
assert!(BearerToken::new("short").is_err());
|
||||
// Spaces are not in the Better Auth bearer charset.
|
||||
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_better_auth_shape() -> Result<(), SyncClientError> {
|
||||
let token = format!("{}-{}_{}", "a".repeat(20), "b".repeat(20), "c".repeat(20));
|
||||
BearerToken::new(token).map(|_| ())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_store_round_trips() -> Result<(), SyncClientError> {
|
||||
let dir = temp_dir().join(format!("ely-token-{}", uuid::Uuid::now_v7().simple()));
|
||||
let store = BearerTokenStore::new(dir.join("token"));
|
||||
let token = BearerToken::new("a".repeat(64))?;
|
||||
assert_eq!(store.load()?, None);
|
||||
|
||||
store.save(&token)?;
|
||||
assert_eq!(store.load()?, Some(token.clone()));
|
||||
fs::write(store.path().with_extension("tmp"), token.as_str()).map_err(io_err)?;
|
||||
|
||||
store.clear()?;
|
||||
assert_eq!(store.load()?, None);
|
||||
assert!(!store.path().with_extension("tmp").exists());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[path = "auth_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
use std::{
|
||||
fs,
|
||||
io::{self, ErrorKind, Read, Write},
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use cap_fs_ext::{
|
||||
DirExt, FollowSymlinks, MetadataExt as CrossPlatformMetadataExt, OpenOptionsFollowExt,
|
||||
};
|
||||
use cap_std::{
|
||||
ambient_authority,
|
||||
fs::{Dir, File, OpenOptions, Permissions},
|
||||
};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::error::SyncClientError;
|
||||
|
||||
const MARKER_CONTENT: &[u8] = b"ely-bearer-migration-v1\n";
|
||||
|
||||
pub(super) fn read_legacy_bytes(
|
||||
path: &Path,
|
||||
maximum_bytes: usize,
|
||||
) -> Result<Option<Zeroizing<Vec<u8>>>, SyncClientError> {
|
||||
let Some((directory, name)) = open_parent(path, false)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(file) = open_existing(&directory, name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_private_file(&file)?;
|
||||
let mut bytes = Zeroizing::new(Vec::new());
|
||||
file.into_std().take((maximum_bytes + 1) as u64).read_to_end(&mut bytes).map_err(storage_io)?;
|
||||
if bytes.len() > maximum_bytes {
|
||||
return Err(storage_error("legacy bearer token is too large"));
|
||||
}
|
||||
Ok(Some(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn ensure_migration_marker(path: &Path) -> Result<(), SyncClientError> {
|
||||
let (directory, name) = open_parent(path, true)?
|
||||
.ok_or_else(|| storage_error("bearer migration marker parent is missing"))?;
|
||||
if let Some(file) = open_existing(&directory, name)? {
|
||||
return validate_migration_marker(file);
|
||||
}
|
||||
let temporary_path = marker_temporary_path(path)?;
|
||||
remove_file_if_present(&temporary_path)?;
|
||||
let temporary_name = temporary_path
|
||||
.file_name()
|
||||
.ok_or_else(|| storage_error("bearer migration marker temp name is missing"))?;
|
||||
let mut options = private_open_options();
|
||||
options.write(true).create_new(true);
|
||||
let mut file = directory.open_with(temporary_name, &options).map_err(storage_io)?;
|
||||
validate_private_file(&file)?;
|
||||
set_private_file_permissions(&file)?;
|
||||
file.write_all(MARKER_CONTENT).map_err(storage_io)?;
|
||||
file.sync_all().map_err(storage_io)?;
|
||||
drop(file);
|
||||
directory.rename(temporary_name, &directory, name).map_err(storage_io)?;
|
||||
sync_directory(&directory)
|
||||
}
|
||||
|
||||
pub(super) fn migration_marker_exists(path: &Path) -> Result<bool, SyncClientError> {
|
||||
let Some((directory, name)) = open_parent(path, false)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(file) = open_existing(&directory, name)? else {
|
||||
return Ok(false);
|
||||
};
|
||||
validate_migration_marker(file)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn validate_migration_marker(file: File) -> Result<(), SyncClientError> {
|
||||
validate_private_file(&file)?;
|
||||
let mut bytes = Vec::new();
|
||||
file.into_std()
|
||||
.take((MARKER_CONTENT.len() + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(storage_io)?;
|
||||
if bytes != MARKER_CONTENT {
|
||||
return Err(storage_error("bearer migration marker content is invalid"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn marker_temporary_path(path: &Path) -> Result<PathBuf, SyncClientError> {
|
||||
let mut name = path
|
||||
.file_name()
|
||||
.ok_or_else(|| storage_error("bearer migration marker name is missing"))?
|
||||
.to_os_string();
|
||||
name.push(".tmp");
|
||||
Ok(path.with_file_name(name))
|
||||
}
|
||||
|
||||
pub(super) fn remove_file_if_present(path: &Path) -> Result<(), SyncClientError> {
|
||||
let Some((directory, name)) = open_parent(path, false)? else {
|
||||
return Ok(());
|
||||
};
|
||||
let metadata = match directory.symlink_metadata(name) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(storage_io(error)),
|
||||
};
|
||||
if metadata.is_symlink() {
|
||||
return directory.remove_file_or_symlink(name).map_err(storage_io);
|
||||
}
|
||||
let file = open_existing(&directory, name)?.ok_or_else(|| {
|
||||
storage_error("legacy bearer token disappeared during cleanup validation")
|
||||
})?;
|
||||
validate_private_file(&file)?;
|
||||
drop(file);
|
||||
directory.remove_file_or_symlink(name).map_err(storage_io)
|
||||
}
|
||||
|
||||
pub(super) fn open_lock_file(path: &Path) -> Result<fs::File, SyncClientError> {
|
||||
let (directory, name) =
|
||||
open_parent(path, true)?.ok_or_else(|| storage_error("bearer lock parent is missing"))?;
|
||||
let mut options = private_open_options();
|
||||
options.read(true).write(true).create(true);
|
||||
let file = directory.open_with(name, &options).map_err(storage_io)?;
|
||||
validate_private_file(&file)?;
|
||||
set_private_file_permissions(&file)?;
|
||||
Ok(file.into_std())
|
||||
}
|
||||
|
||||
fn open_parent(
|
||||
path: &Path,
|
||||
create: bool,
|
||||
) -> Result<Option<(Dir, &std::ffi::OsStr)>, SyncClientError> {
|
||||
let parent = path.parent().ok_or_else(|| storage_error("credential file parent is missing"))?;
|
||||
let name = path.file_name().ok_or_else(|| storage_error("credential file name is missing"))?;
|
||||
let Some(directory) = open_directory_nofollow(parent, create)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
validate_private_directory(&directory)?;
|
||||
set_private_directory_permissions(&directory)?;
|
||||
Ok(Some((directory, name)))
|
||||
}
|
||||
|
||||
fn open_directory_nofollow(path: &Path, create: bool) -> Result<Option<Dir>, SyncClientError> {
|
||||
let mut root = PathBuf::new();
|
||||
let mut names = Vec::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::Prefix(prefix) => root.push(prefix.as_os_str()),
|
||||
Component::RootDir => root.push(std::path::MAIN_SEPARATOR_STR),
|
||||
Component::Normal(name) => names.push(name.to_os_string()),
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
return Err(storage_error("credential path traversal is invalid"));
|
||||
}
|
||||
}
|
||||
}
|
||||
if root.as_os_str().is_empty() {
|
||||
return Err(storage_error("credential paths must be absolute"));
|
||||
}
|
||||
let mut directory = Dir::open_ambient_dir(root, ambient_authority()).map_err(storage_io)?;
|
||||
#[cfg(windows)]
|
||||
let strict_component = names.len().saturating_sub(4);
|
||||
#[cfg(windows)]
|
||||
let mut require_nofollow = false;
|
||||
#[cfg(windows)]
|
||||
let mut component_index = 0;
|
||||
#[cfg(not(windows))]
|
||||
let mut require_nofollow = directory_requires_nofollow(&directory)?;
|
||||
for name in names {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
require_nofollow |= component_index >= strict_component;
|
||||
component_index += 1;
|
||||
}
|
||||
if create {
|
||||
match directory.create_dir(&name) {
|
||||
Ok(()) => {}
|
||||
Err(error) if error.kind() == ErrorKind::AlreadyExists => {}
|
||||
Err(error) => return Err(storage_io(error)),
|
||||
}
|
||||
}
|
||||
let opened = match directory.open_dir_nofollow(&name) {
|
||||
Ok(directory) => directory,
|
||||
Err(_) if !require_nofollow => match directory.open_dir(&name) {
|
||||
Ok(directory) => directory,
|
||||
Err(error) if !create && error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(storage_io(error)),
|
||||
},
|
||||
Err(error) if !create && error.kind() == ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(storage_io(error)),
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
require_nofollow |= directory_requires_nofollow(&opened)?;
|
||||
}
|
||||
directory = opened;
|
||||
}
|
||||
Ok(Some(directory))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn directory_requires_nofollow(directory: &Dir) -> Result<bool, SyncClientError> {
|
||||
use cap_std::fs::MetadataExt;
|
||||
|
||||
let metadata = directory.dir_metadata().map_err(storage_io)?;
|
||||
Ok(metadata.uid() == rustix::process::geteuid().as_raw() || metadata.mode() & 0o022 != 0)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
fn directory_requires_nofollow(_directory: &Dir) -> Result<bool, SyncClientError> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn open_existing(directory: &Dir, name: &std::ffi::OsStr) -> Result<Option<File>, SyncClientError> {
|
||||
let mut options = private_open_options();
|
||||
options.read(true);
|
||||
match directory.open_with(name, &options) {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
|
||||
Err(error) => Err(storage_io(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_private_directory(directory: &Dir) -> Result<(), SyncClientError> {
|
||||
let metadata = directory.dir_metadata().map_err(storage_io)?;
|
||||
if !metadata.is_dir() {
|
||||
return Err(storage_error("credential file parent is not a directory"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use cap_std::fs::MetadataExt;
|
||||
if metadata.uid() != rustix::process::geteuid().as_raw() {
|
||||
return Err(storage_error("credential file parent ownership is invalid"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_private_file(file: &File) -> Result<(), SyncClientError> {
|
||||
let metadata = file.metadata().map_err(storage_io)?;
|
||||
if !metadata.is_file() || CrossPlatformMetadataExt::nlink(&metadata) != 1 {
|
||||
return Err(storage_error("credential file link state is invalid"));
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use cap_std::fs::MetadataExt;
|
||||
if metadata.uid() != rustix::process::geteuid().as_raw() {
|
||||
return Err(storage_error("credential file ownership is invalid"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_private_directory_permissions(directory: &Dir) -> Result<(), SyncClientError> {
|
||||
#[cfg(unix)]
|
||||
directory
|
||||
.set_permissions(".", Permissions::from_std(fs::Permissions::from_mode(0o700)))
|
||||
.map_err(storage_io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_private_file_permissions(file: &File) -> Result<(), SyncClientError> {
|
||||
#[cfg(unix)]
|
||||
file.set_permissions(Permissions::from_std(fs::Permissions::from_mode(0o600)))
|
||||
.map_err(storage_io)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn sync_directory(directory: &Dir) -> Result<(), SyncClientError> {
|
||||
directory.try_clone().map_err(storage_io)?.into_std_file().sync_all().map_err(storage_io)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn sync_directory(_directory: &Dir) -> Result<(), SyncClientError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
fn storage_io(error: io::Error) -> SyncClientError {
|
||||
storage_error(error.to_string())
|
||||
}
|
||||
|
||||
fn storage_error(message: impl Into<String>) -> SyncClientError {
|
||||
SyncClientError::BearerCredentialStorage(message.into())
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
use std::{
|
||||
collections::BTreeMap,
|
||||
fs,
|
||||
sync::{Mutex, MutexGuard},
|
||||
};
|
||||
|
||||
use ely_domain::ProfileId;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MemoryCredentialBackend {
|
||||
values: Mutex<BTreeMap<(String, String), Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl CredentialBackend for MemoryCredentialBackend {
|
||||
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
||||
Ok(self
|
||||
.values()
|
||||
.get(&(service.to_string(), account.to_string()))
|
||||
.cloned()
|
||||
.map(Zeroizing::new))
|
||||
}
|
||||
|
||||
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
|
||||
self.values().insert((service.to_string(), account.to_string()), secret.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&self, service: &str, account: &str) -> Result<(), String> {
|
||||
self.values().remove(&(service.to_string(), account.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryCredentialBackend {
|
||||
fn values(&self) -> MutexGuard<'_, BTreeMap<(String, String), Vec<u8>>> {
|
||||
self.values.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
}
|
||||
|
||||
struct DroppingCredentialBackend;
|
||||
|
||||
impl CredentialBackend for DroppingCredentialBackend {
|
||||
fn load(&self, _service: &str, _account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn save(&self, _service: &str, _account: &str, _secret: &[u8]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct SaveFailureBackend;
|
||||
|
||||
impl CredentialBackend for SaveFailureBackend {
|
||||
fn load(&self, _service: &str, _account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn save(&self, _service: &str, _account: &str, _secret: &[u8]) -> Result<(), String> {
|
||||
Err("credential backend is locked".to_string())
|
||||
}
|
||||
|
||||
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct ClearFailureBackend {
|
||||
values: Mutex<BTreeMap<(String, String), Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl CredentialBackend for ClearFailureBackend {
|
||||
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
||||
Ok(self
|
||||
.values
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&(service.to_string(), account.to_string()))
|
||||
.cloned()
|
||||
.map(Zeroizing::new))
|
||||
}
|
||||
|
||||
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
|
||||
self.values
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert((service.to_string(), account.to_string()), secret.to_vec());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear(&self, _service: &str, _account: &str) -> Result<(), String> {
|
||||
Err("credential backend is locked".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_token_shape_boundaries() -> Result<(), SyncClientError> {
|
||||
assert!(BearerToken::new("").is_err());
|
||||
assert!(BearerToken::new("short").is_err());
|
||||
assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err());
|
||||
BearerToken::new("a".repeat(32))?;
|
||||
BearerToken::new("a".repeat(MAX_BEARER_TOKEN_BYTES))?;
|
||||
assert!(BearerToken::new("a".repeat(MAX_BEARER_TOKEN_BYTES + 1)).is_err());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_output_redacts_the_token() -> Result<(), SyncClientError> {
|
||||
let token = BearerToken::new("secret-session-token".repeat(3))?;
|
||||
let output = format!("{token:?}");
|
||||
|
||||
assert_eq!(output, "BearerToken([REDACTED])");
|
||||
assert!(!output.contains(token.as_str()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keychain_entry_wins_and_cleans_every_legacy_path() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
let old = directory.path().join("old/bearer.token");
|
||||
write_token(&stable, 'a')?;
|
||||
write_token(&old, 'b')?;
|
||||
write_token(&stable.with_extension("tmp"), 'c')?;
|
||||
write_token(&old.with_extension("tmp"), 'd')?;
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
let expected = BearerToken::new("e".repeat(64))?;
|
||||
backend.save(KEYCHAIN_SERVICE, profile_id.as_str(), expected.as_str().as_bytes())?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
|
||||
|
||||
assert_eq!(store.load_with(&backend)?, Some(expected));
|
||||
for path in [stable, old] {
|
||||
assert!(!path.exists());
|
||||
assert!(!path.with_extension("tmp").exists());
|
||||
}
|
||||
assert!(migration_marker_path(directory.path()).exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_legacy_token_precedes_old_default_source() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
let old = directory.path().join("old/bearer.token");
|
||||
write_token(&stable, 'a')?;
|
||||
write_token(&old, 'b')?;
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
|
||||
|
||||
assert_eq!(
|
||||
store.load_with(&backend)?.as_ref().map(BearerToken::as_str),
|
||||
Some("a".repeat(64).as_str())
|
||||
);
|
||||
assert!(!stable.exists());
|
||||
assert!(!old.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_keychain_readback_preserves_the_legacy_source() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
write_token(&stable, 'a')?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
|
||||
assert!(store.load_with(&DroppingCredentialBackend).is_err());
|
||||
assert!(stable.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_keychain_entry_finishes_a_crashed_migration() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
write_token(&stable, 'a')?;
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
backend.save(KEYCHAIN_SERVICE, profile_id.as_str(), "a".repeat(64).as_bytes())?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
|
||||
assert!(store.load_with(&backend)?.is_some());
|
||||
assert!(!stable.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_accounts_are_isolated_and_clear_is_idempotent() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let first_id = ProfileId::new();
|
||||
let second_id = ProfileId::new();
|
||||
let first = BearerTokenStore::new(&first_id, &directory.path().join("first"));
|
||||
let second = BearerTokenStore::new(&second_id, &directory.path().join("second"));
|
||||
let first_token = BearerToken::new("a".repeat(64))?;
|
||||
let second_token = BearerToken::new("b".repeat(64))?;
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
|
||||
first.save_with(&backend, &first_token)?;
|
||||
second.save_with(&backend, &second_token)?;
|
||||
assert_eq!(first.load_with(&backend)?, Some(first_token));
|
||||
assert_eq!(second.load_with(&backend)?, Some(second_token.clone()));
|
||||
first.clear_with(&backend)?;
|
||||
first.clear_with(&backend)?;
|
||||
assert_eq!(first.load_with(&backend)?, None);
|
||||
assert_eq!(second.load_with(&backend)?, Some(second_token));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conditional_clear_preserves_a_newer_profile_token() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let old = BearerToken::new("a".repeat(64))?;
|
||||
let current = BearerToken::new("b".repeat(64))?;
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
|
||||
store.save_with(&backend, &old)?;
|
||||
store.save_with(&backend, ¤t)?;
|
||||
assert!(!store.clear_if_matches_with(&backend, &old)?);
|
||||
assert_eq!(store.load_with(&backend)?, Some(current.clone()));
|
||||
assert!(store.clear_if_matches_with(&backend, ¤t)?);
|
||||
assert_eq!(store.load_with(&backend)?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clear_failure_preserves_the_credential_for_retry() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let token = BearerToken::new("a".repeat(64))?;
|
||||
let backend = ClearFailureBackend::default();
|
||||
|
||||
store.save_with(&backend, &token)?;
|
||||
assert!(store.clear_with(&backend).is_err());
|
||||
assert_eq!(store.load_with(&backend)?, Some(token));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_marker_blocks_recreated_legacy_tokens() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let old = directory.path().join("old/bearer.token");
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path()).with_legacy_path(old.clone());
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
let token = BearerToken::new("a".repeat(64))?;
|
||||
|
||||
store.save_with(&backend, &token)?;
|
||||
store.clear_with(&backend)?;
|
||||
write_token(&old, 'b')?;
|
||||
|
||||
assert_eq!(store.load_with(&backend)?, None);
|
||||
assert!(!old.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn cleanup_failure_prevents_a_direct_keychain_commit() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
let target = directory.path().join("target.tmp");
|
||||
write_token(&target, 'b')?;
|
||||
fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
|
||||
fs::hard_link(&target, stable.with_extension("tmp"))?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
let token = BearerToken::new("a".repeat(64))?;
|
||||
|
||||
assert!(store.save_with(&backend, &token).is_err());
|
||||
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_save_failure_retires_stale_legacy_credentials() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
write_token(&stable, 'a')?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let token = BearerToken::new("b".repeat(64))?;
|
||||
|
||||
assert!(store.save_with(&SaveFailureBackend, &token).is_err());
|
||||
assert!(!stable.exists());
|
||||
assert!(migration_marker_path(directory.path()).exists());
|
||||
assert_eq!(store.load_with(&MemoryCredentialBackend::default())?, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn migration_cleanup_failure_keeps_the_committed_keychain() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
let target = directory.path().join("target.tmp");
|
||||
write_token(&stable, 'a')?;
|
||||
write_token(&target, 'b')?;
|
||||
fs::hard_link(&target, stable.with_extension("tmp"))?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
|
||||
assert!(store.load_with(&backend).is_err());
|
||||
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_some());
|
||||
assert!(migration_marker_path(directory.path()).exists());
|
||||
fs::remove_file(stable.with_extension("tmp"))?;
|
||||
assert_eq!(
|
||||
store.load_with(&backend)?.as_ref().map(BearerToken::as_str),
|
||||
Some("a".repeat(64).as_str())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_migration_marker_never_retires_a_legacy_token() -> Result<(), Box<dyn std::error::Error>>
|
||||
{
|
||||
for marker in [b"".as_slice(), b"partial".as_slice()] {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
let marker_path = migration_marker_path(directory.path());
|
||||
write_token(&stable, 'a')?;
|
||||
fs::write(&marker_path, marker)?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
|
||||
assert!(store.load_with(&backend).is_err());
|
||||
assert!(stable.exists());
|
||||
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crashed_marker_temp_is_replaced_atomically() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let marker = migration_marker_path(directory.path());
|
||||
let marker_tmp = marker.with_file_name("bearer.migrated.tmp");
|
||||
fs::create_dir_all(marker.parent().ok_or("missing marker parent")?)?;
|
||||
fs::write(&marker_tmp, b"partial")?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
|
||||
store.clear_with(&MemoryCredentialBackend::default())?;
|
||||
|
||||
assert_eq!(fs::read(marker)?, b"ely-bearer-migration-v1\n");
|
||||
assert!(!marker_tmp.exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_tmp_is_removed_without_becoming_a_token() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
write_token(&stable.with_extension("tmp"), 'a')?;
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
|
||||
assert_eq!(store.load_with(&MemoryCredentialBackend::default())?, None);
|
||||
assert!(!stable.with_extension("tmp").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_and_hardlink_legacy_sources_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
for hard_link in [false, true] {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let target = directory.path().join("target.token");
|
||||
let stable = stable_legacy_path(directory.path());
|
||||
write_token(&target, 'a')?;
|
||||
fs::create_dir_all(stable.parent().ok_or("missing stable parent")?)?;
|
||||
if hard_link {
|
||||
fs::hard_link(&target, &stable)?;
|
||||
} else {
|
||||
symlink(&target, &stable)?;
|
||||
}
|
||||
let backend = MemoryCredentialBackend::default();
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
|
||||
assert!(store.load_with(&backend).is_err());
|
||||
assert!(backend.load(KEYCHAIN_SERVICE, profile_id.as_str())?.is_none());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_and_hardlink_lock_files_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::os::unix::fs::{PermissionsExt, symlink};
|
||||
|
||||
for hard_link in [false, true] {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let target = directory.path().join("target.lock");
|
||||
let lock = directory.path().join("sync/bearer.lock");
|
||||
fs::write(&target, b"lock-target")?;
|
||||
fs::set_permissions(&target, fs::Permissions::from_mode(0o644))?;
|
||||
fs::create_dir_all(lock.parent().ok_or("missing lock parent")?)?;
|
||||
if hard_link {
|
||||
fs::hard_link(&target, &lock)?;
|
||||
} else {
|
||||
symlink(&target, &lock)?;
|
||||
}
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
|
||||
assert!(store.load_with(&MemoryCredentialBackend::default()).is_err());
|
||||
assert_eq!(fs::metadata(&target)?.permissions().mode() & 0o777, 0o644);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinked_profile_directories_are_rejected() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let target = directory.path().join("target-profile");
|
||||
let linked_profile = directory.path().join("linked-profile");
|
||||
fs::create_dir_all(&target)?;
|
||||
symlink(&target, &linked_profile)?;
|
||||
let store = BearerTokenStore::new(&profile_id, &linked_profile);
|
||||
|
||||
assert!(store.load_with(&MemoryCredentialBackend::default()).is_err());
|
||||
assert!(!target.join("sync").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn native_keychain_round_trip() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let profile_id = ProfileId::new();
|
||||
let store = BearerTokenStore::new(&profile_id, directory.path());
|
||||
let token = BearerToken::new("n".repeat(64))?;
|
||||
let result = (|| {
|
||||
store.save(&token)?;
|
||||
assert_eq!(store.load()?, Some(token));
|
||||
Ok::<(), SyncClientError>(())
|
||||
})();
|
||||
store.clear()?;
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stable_legacy_path(profile_dir: &std::path::Path) -> std::path::PathBuf {
|
||||
profile_dir.join("sync/bearer.token")
|
||||
}
|
||||
|
||||
fn migration_marker_path(profile_dir: &std::path::Path) -> std::path::PathBuf {
|
||||
profile_dir.join("sync/bearer.migrated")
|
||||
}
|
||||
|
||||
fn write_token(path: &std::path::Path, character: char) -> Result<(), std::io::Error> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(path, character.to_string().repeat(64))
|
||||
}
|
||||
@@ -1,28 +1,174 @@
|
||||
use keyring::{Entry, Error as KeyringError};
|
||||
use keyring_core::{CredentialStore, Entry, Error as KeyringError};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
static NATIVE_STORE: Mutex<Option<Arc<CredentialStore>>> = Mutex::new(None);
|
||||
|
||||
pub(crate) fn load_secret(
|
||||
service: &str,
|
||||
account: &str,
|
||||
) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
||||
match entry(service, account)?.get_secret() {
|
||||
native_operation(service, account, |entry| match entry.get_secret() {
|
||||
Ok(secret) => Ok(Some(Zeroizing::new(secret))),
|
||||
Err(KeyringError::NoEntry) => Ok(None),
|
||||
Err(error) => Err(error.to_string()),
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn save_secret(service: &str, account: &str, secret: &[u8]) -> Result<(), String> {
|
||||
entry(service, account)?.set_secret(secret).map_err(|error| error.to_string())
|
||||
native_operation(service, account, |entry| entry.set_secret(secret))
|
||||
}
|
||||
|
||||
pub(crate) fn clear_secret(service: &str, account: &str) -> Result<(), String> {
|
||||
match entry(service, account)?.delete_credential() {
|
||||
native_operation(service, account, |entry| match entry.delete_credential() {
|
||||
Ok(()) | Err(KeyringError::NoEntry) => Ok(()),
|
||||
Err(error) => Err(error.to_string()),
|
||||
Err(error) => Err(error),
|
||||
})
|
||||
}
|
||||
|
||||
fn native_operation<T>(
|
||||
service: &str,
|
||||
account: &str,
|
||||
operation: impl Fn(&Entry) -> keyring_core::Result<T>,
|
||||
) -> Result<T, String> {
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
|
||||
{
|
||||
return retry_once(
|
||||
|| entry(service, account).and_then(|entry| operation(&entry)),
|
||||
retryable_store_error,
|
||||
clear_cached_store,
|
||||
)
|
||||
.map_err(|error| error.to_string());
|
||||
}
|
||||
#[cfg(not(all(
|
||||
unix,
|
||||
not(any(target_os = "macos", target_os = "ios", target_os = "android"))
|
||||
)))]
|
||||
{
|
||||
entry(service, account)
|
||||
.and_then(|entry| operation(&entry))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(service: &str, account: &str) -> Result<Entry, String> {
|
||||
Entry::new(service, account).map_err(|error| error.to_string())
|
||||
#[cfg(any(
|
||||
test,
|
||||
all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")))
|
||||
))]
|
||||
fn retry_once<T, E>(
|
||||
mut attempt: impl FnMut() -> Result<T, E>,
|
||||
should_retry: impl Fn(&E) -> bool,
|
||||
before_retry: impl FnOnce(),
|
||||
) -> Result<T, E> {
|
||||
let first = attempt();
|
||||
if first.as_ref().is_err_and(should_retry) {
|
||||
before_retry();
|
||||
attempt()
|
||||
} else {
|
||||
first
|
||||
}
|
||||
}
|
||||
|
||||
fn entry(service: &str, account: &str) -> keyring_core::Result<Entry> {
|
||||
let store = {
|
||||
let mut guard = NATIVE_STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if guard.is_none() {
|
||||
*guard = Some(platform_store()?);
|
||||
}
|
||||
guard.as_ref().cloned().ok_or(KeyringError::NoDefaultStore)?
|
||||
};
|
||||
build_entry(store.as_ref(), service, account)
|
||||
}
|
||||
|
||||
#[cfg(any(
|
||||
test,
|
||||
all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android")))
|
||||
))]
|
||||
fn retryable_store_error(error: &KeyringError) -> bool {
|
||||
matches!(
|
||||
error,
|
||||
KeyringError::PlatformFailure(_)
|
||||
| KeyringError::NoStorageAccess(_)
|
||||
| KeyringError::NoDefaultStore
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
|
||||
fn clear_cached_store() {
|
||||
let mut guard = NATIVE_STORE.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn build_entry(
|
||||
store: &CredentialStore,
|
||||
service: &str,
|
||||
account: &str,
|
||||
) -> keyring_core::Result<Entry> {
|
||||
let modifiers = std::collections::HashMap::from([("persistence", "Local")]);
|
||||
store.build(service, account, Some(&modifiers))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn build_entry(
|
||||
store: &CredentialStore,
|
||||
service: &str,
|
||||
account: &str,
|
||||
) -> keyring_core::Result<Entry> {
|
||||
store.build(service, account, None)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
|
||||
apple_native_keyring_store::keychain::Store::new().map(|store| store as Arc<CredentialStore>)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
|
||||
windows_native_keyring_store::Store::new().map(|store| store as Arc<CredentialStore>)
|
||||
}
|
||||
|
||||
#[cfg(all(unix, not(any(target_os = "macos", target_os = "ios", target_os = "android"))))]
|
||||
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
|
||||
zbus_secret_service_keyring_store::Store::new().map(|store| store as Arc<CredentialStore>)
|
||||
}
|
||||
|
||||
#[cfg(not(any(
|
||||
target_os = "macos",
|
||||
target_os = "windows",
|
||||
all(unix, not(any(target_os = "ios", target_os = "android")))
|
||||
)))]
|
||||
fn platform_store() -> keyring_core::Result<Arc<CredentialStore>> {
|
||||
Err(KeyringError::NoDefaultStore)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{KeyringError, retry_once, retryable_store_error};
|
||||
use std::cell::Cell;
|
||||
|
||||
#[test]
|
||||
fn retry_policy_targets_store_connectivity_errors() {
|
||||
assert!(retryable_store_error(&KeyringError::NoDefaultStore));
|
||||
assert!(!retryable_store_error(&KeyringError::NoEntry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retry_evicts_once_and_recreates_the_store_once() {
|
||||
let attempts = Cell::new(0);
|
||||
let evictions = Cell::new(0);
|
||||
let result = retry_once(
|
||||
|| {
|
||||
let attempt = attempts.get() + 1;
|
||||
attempts.set(attempt);
|
||||
Err::<(), _>(attempt)
|
||||
},
|
||||
|_| true,
|
||||
|| evictions.set(evictions.get() + 1),
|
||||
);
|
||||
|
||||
assert_eq!(result, Err(2));
|
||||
assert_eq!(attempts.get(), 2);
|
||||
assert_eq!(evictions.get(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::{
|
||||
|
||||
const PUBLIC_KEY_BYTES: usize = 32;
|
||||
const MAX_DEVICE_TEXT_CHARS: usize = 128;
|
||||
const MAX_STORED_IDENTITY_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// Public device identity persisted in the profile directory. Both private
|
||||
/// keys live in the macOS data-protection Keychain under `device_id`.
|
||||
@@ -33,6 +34,18 @@ pub struct DeviceIdentity {
|
||||
}
|
||||
|
||||
impl DeviceIdentity {
|
||||
pub fn validate_stored_bytes(bytes: &[u8], path: &Path) -> Result<(), SyncClientError> {
|
||||
if bytes.len() > MAX_STORED_IDENTITY_BYTES {
|
||||
return Err(key_error("stored device identity is too large"));
|
||||
}
|
||||
let contents = std::str::from_utf8(bytes)
|
||||
.map_err(|error| SyncClientError::TokenStorage(error.to_string()))?;
|
||||
match decode_stored_identity(contents, path)? {
|
||||
StoredIdentity::V2(identity) => identity.validate(),
|
||||
StoredIdentity::Legacy(identity) => identity.validate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a v2 identity and its Keychain secrets. A legacy public-only
|
||||
/// identity is rotated to a fresh device ID because its private key was
|
||||
/// never persisted and cannot prove device continuity.
|
||||
@@ -413,6 +426,31 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_identity_validation_is_bounded_and_schema_strict() -> Result<(), SyncClientError> {
|
||||
let (identity, _) = generate_key_material("Test".to_string(), "macos".to_string())?;
|
||||
let bytes = serde_json::to_vec(&identity).map_err(|error| {
|
||||
SyncClientError::TokenStorage(format!("device identity serialize: {error}"))
|
||||
})?;
|
||||
|
||||
DeviceIdentity::validate_stored_bytes(&bytes, Path::new("device.json"))?;
|
||||
assert!(
|
||||
DeviceIdentity::validate_stored_bytes(
|
||||
&vec![b'a'; MAX_STORED_IDENTITY_BYTES + 1],
|
||||
Path::new("device.json"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
DeviceIdentity::validate_stored_bytes(
|
||||
br#"{"device_id":"ely-test","unknown":true}"#,
|
||||
Path::new("device.json"),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[test]
|
||||
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use ureq::{Agent, AgentBuilder};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError};
|
||||
|
||||
@@ -86,20 +87,21 @@ pub fn verify_email_otp(
|
||||
match response {
|
||||
Ok(ok) => {
|
||||
let cookie_token = better_auth_cookie_token(ok.header("set-cookie"));
|
||||
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus {
|
||||
endpoint: endpoint.clone(),
|
||||
status: 200,
|
||||
body: error.to_string(),
|
||||
})?;
|
||||
let body =
|
||||
Zeroizing::new(ok.into_string().map_err(|error| SyncClientError::HttpStatus {
|
||||
endpoint: endpoint.clone(),
|
||||
status: 200,
|
||||
body: error.to_string(),
|
||||
})?);
|
||||
let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| {
|
||||
SyncClientError::Json { endpoint: endpoint.clone(), source: error }
|
||||
})?;
|
||||
let token = json.token.or(cookie_token).ok_or_else(|| {
|
||||
let token = json.token.map(Zeroizing::new).or(cookie_token).ok_or_else(|| {
|
||||
SyncClientError::TokenStorage(
|
||||
"sign-in response did not include a session token".to_string(),
|
||||
)
|
||||
})?;
|
||||
BearerToken::new(token)
|
||||
BearerToken::new(token.as_str())
|
||||
}
|
||||
Err(ureq::Error::Status(status, raw)) => {
|
||||
let body = raw.into_string().unwrap_or_default();
|
||||
@@ -117,14 +119,14 @@ fn build_agent() -> Agent {
|
||||
/// better-auth.session_token=<token>; …` header. Strip the cookie's
|
||||
/// attributes and return just the value. Multi-cookie responses are
|
||||
/// concatenated by `ureq` into a single header line per spec.
|
||||
fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<String> {
|
||||
fn better_auth_cookie_token(set_cookie: Option<&str>) -> Option<Zeroizing<String>> {
|
||||
let header = set_cookie?;
|
||||
for cookie in header.split(',') {
|
||||
let trimmed = cookie.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") {
|
||||
let token = rest.split(';').next()?.trim();
|
||||
if !token.is_empty() {
|
||||
return Some(token.to_string());
|
||||
return Some(Zeroizing::new(token.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,7 +141,10 @@ mod tests {
|
||||
fn picks_session_token_out_of_set_cookie_header() {
|
||||
let header =
|
||||
"better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax";
|
||||
assert_eq!(better_auth_cookie_token(Some(header)), Some("abc.def.ghi".to_string()),);
|
||||
assert_eq!(
|
||||
better_auth_cookie_token(Some(header)).as_deref().map(String::as_str),
|
||||
Some("abc.def.ghi"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -8,6 +8,9 @@ pub enum SyncClientError {
|
||||
#[error("Bearer token storage is unavailable: {0}")]
|
||||
TokenStorage(String),
|
||||
|
||||
#[error("Bearer credential storage is unavailable: {0}")]
|
||||
BearerCredentialStorage(String),
|
||||
|
||||
#[error("HTTP request failed for {endpoint}: {source}")]
|
||||
Http {
|
||||
endpoint: String,
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
//! the bearer token out-of-band and hand it to the client.
|
||||
|
||||
pub mod auth;
|
||||
mod auth_files;
|
||||
pub mod client;
|
||||
mod credential_store;
|
||||
pub mod device;
|
||||
|
||||
Reference in New Issue
Block a user