fix(sync): bind browser data to one account owner

This commit is contained in:
2026-07-10 10:46:52 -04:00
parent 46eac43326
commit 3be13ca295
16 changed files with 678 additions and 24 deletions
Generated
+1
View File
@@ -2534,6 +2534,7 @@ dependencies = [
"ely_sync_client",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"url",
"uuid",
+1
View File
@@ -963,6 +963,7 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传
- Desktop bearer 以 stable `ProfileId` 作为系统凭据 accountWindows 使用 Local persistence;旧明文文件在 credential read-back 与 durable marker 提交后清理,系统凭据不可用时阻断设备加载与 Sync upload。
- Desktop sign-out closes the authenticated-operation gate, drains active leases, revokes the exact server session, and conditionally clears the captured native credential; generation-stamped async results cannot restore stale auth or Sync state.
- Runtime `session_not_found` and `session_expired` responses conditionally clear the exact captured bearer inside `SyncEngine`; replacement credentials survive, stale Profile work converges through credential reprobe, and active Cloud Sync/device work resets before session-state reconciliation.
- One immutable Ely `user_id` owns the complete browser data root and its multi-Profile encrypted snapshot. Interactive OTP sign-in claims an unowned root after generation validation; every background device or Sync operation verifies the owner through `GET /api/devices` before device registration or cloud data access.
- 设备注册、rebind、批准、撤销与 Vault rotation。
- Signed Sync reset 和 signed account deletion。
- 管理所有 `/api/auth/*` 路由。
+24
View File
@@ -102,6 +102,30 @@ describe("device routes", () => {
]);
});
it("returns identity for an unbound session before device registration", async () => {
const d1 = testD1Database({
sessionRow: {
id: "session-01",
userId: "user-01",
expiresAt: "2099-01-01T00:00:00.000Z",
createdAt: "2026-01-01T00:00:00.000Z",
deviceId: null,
},
allRows: [],
});
const response = await handleRequest(
new Request("https://elydora.test/api/devices", {
headers: { authorization: `Bearer ${ACCESS_TOKEN}` },
}),
testEnv({ d1 }),
);
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { version: 1, user_id: "user-01", devices: [] });
assert.deepEqual(d1.binds, [["user-01"]]);
assert.deepEqual(d1.batches, []);
});
it("rejects unauthenticated device list requests before D1 reads", async () => {
const d1 = testD1Database([]);
const response = await handleRequest(
+36 -9
View File
@@ -11,8 +11,8 @@ use std::path::Path;
use ely_domain::ProfileId;
use ely_sync_client::{
ApiClientConfig, BearerToken, BearerTokenStore, SyncApiClient, SyncClientError, send_email_otp,
verify_email_otp,
ApiClientConfig, BearerToken, BearerTokenStore, SyncApiClient, SyncClientError, SyncOwnerStore,
send_email_otp, verify_email_otp,
};
use gpui::Context;
@@ -386,7 +386,21 @@ fn spawn_verify_otp(profile_id: ProfileId, email: String, otp: String, tx: SyncS
return;
}
};
let update = SyncStateUpdate::AuthVerified { profile_id, email, token };
let user_id = match SyncApiClient::new(config, token.clone())
.and_then(|client| client.authenticated_user_id())
{
Ok(user_id) => user_id,
Err(error) => {
retire_bearer(token);
let _ = tx.send(SyncStateUpdate::AuthError {
profile_id,
email,
message: error.to_string(),
});
return;
}
};
let update = SyncStateUpdate::AuthVerified { profile_id, email, user_id, token };
if let Err(error) = tx.send(update) {
retire_stale_auth_update(error.0.update);
}
@@ -407,18 +421,20 @@ pub(super) fn retire_stale_auth_update(update: SyncStateUpdate) {
};
std::thread::Builder::new()
.name("ely-sync-auth-retire".to_string())
.spawn(move || {
let client = SyncApiClient::new(ApiClientConfig::production(), token);
if let Ok(client) = client {
let _ = client.sign_out();
}
})
.spawn(move || retire_bearer(token))
.map(|_| ())
.unwrap_or_else(|error| {
tracing::warn!(target: "ely::sync", error = %error, "spawn auth retire failed");
});
}
fn retire_bearer(token: BearerToken) {
let client = SyncApiClient::new(ApiClientConfig::production(), token);
if let Ok(client) = client {
let _ = client.sign_out();
}
}
fn verified_bearer_from(update: SyncStateUpdate) -> Option<BearerToken> {
match update {
SyncStateUpdate::AuthVerified { token, .. } => Some(token),
@@ -428,17 +444,28 @@ fn verified_bearer_from(update: SyncStateUpdate) -> Option<BearerToken> {
pub(super) fn save_verified_bearer(
profile_id: &ProfileId,
user_id: &str,
token: &BearerToken,
default_profile_id: Option<&ProfileId>,
) -> Result<(), SyncClientError> {
let profile_root = default_profile_data_root().ok_or_else(|| {
SyncClientError::BearerCredentialStorage("profile data root is unavailable".to_string())
})?;
SyncOwnerStore::new(&profile_root).claim(user_id)?;
let profile_dir = sync_profile_data_dir(&profile_root, profile_id);
bearer_store_for_profile(profile_id, &profile_dir, &profile_root, default_profile_id)
.save(token)
}
pub(super) fn verified_session_persistence_message(error: &SyncClientError) -> String {
match error {
SyncClientError::SyncOwnerMismatch
| SyncClientError::SyncOwnerUnclaimed
| SyncClientError::SyncOwnerStorage(_) => error.to_string(),
_ => "System credential access failed.".to_string(),
}
}
#[cfg(test)]
#[path = "auth_tests.rs"]
mod tests;
+10 -1
View File
@@ -4,7 +4,7 @@ use ely_domain::ProfileId;
use super::super::{ShellState, sync_state::SyncStateUpdate};
use super::{
AuthFlowPhase, active_profile_sync_context_for, bearer_store_for_profile, normalize_email,
verified_bearer_from,
verified_bearer_from, verified_session_persistence_message,
};
#[test]
@@ -47,6 +47,7 @@ fn stale_verified_updates_retain_the_token_for_server_retirement()
let update = SyncStateUpdate::AuthVerified {
profile_id,
email: "user@example.com".to_string(),
user_id: "user-01".to_string(),
token: token.clone(),
};
@@ -54,6 +55,14 @@ fn stale_verified_updates_retain_the_token_for_server_retirement()
Ok(())
}
#[test]
fn owner_mismatch_is_actionable_in_the_account_form() {
assert_eq!(
verified_session_persistence_message(&ely_sync_client::SyncClientError::SyncOwnerMismatch),
"This browser data belongs to a different Ely account"
);
}
#[test]
fn private_profile_has_no_sync_auth_context() -> Result<(), Box<dyn std::error::Error>> {
let state =
+6 -3
View File
@@ -45,7 +45,7 @@ pub(crate) enum SyncStateUpdate {
SignOutSucceeded { profile_id: ProfileId },
SignOutFailed { profile_id: ProfileId, message: String },
AuthOtpSent { profile_id: ProfileId, email: String },
AuthVerified { profile_id: ProfileId, email: String, token: BearerToken },
AuthVerified { profile_id: ProfileId, email: String, user_id: String, token: BearerToken },
AuthError { profile_id: ProfileId, email: String, message: String },
}
@@ -321,7 +321,7 @@ impl ElyShell {
self.release_auth_flow_barrier();
}
}
SyncStateUpdate::AuthVerified { profile_id, email, token } => {
SyncStateUpdate::AuthVerified { profile_id, email, user_id, token } => {
let attempt_matches = active_profile_id(&self.state).as_ref()
== Some(&profile_id)
&& matches!(
@@ -336,12 +336,14 @@ impl ElyShell {
auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified {
profile_id,
email,
user_id,
token,
});
continue;
}
match auth::save_verified_bearer(
&profile_id,
&user_id,
&token,
self.default_profile_id.as_ref(),
) {
@@ -357,9 +359,10 @@ impl ElyShell {
auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified {
profile_id: profile_id.clone(),
email: email.clone(),
user_id,
token,
});
let message = "System credential access failed.".to_string();
let message = auth::verified_session_persistence_message(&error);
self.auth_flow_phase = auth::AuthFlowPhase::Error {
profile_id,
email,
+3
View File
@@ -14,5 +14,8 @@ thiserror.workspace = true
url.workspace = true
uuid.workspace = true
[dev-dependencies]
tempfile.workspace = true
[lints]
workspace = true
+29 -6
View File
@@ -8,7 +8,7 @@ use ely_sync_client::{
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError,
SyncLatestSnapshotDocument,
SyncLatestSnapshotDocument, SyncOwnerStore,
};
use crate::state::BrowserCore;
@@ -27,6 +27,7 @@ pub struct SyncEngine {
api_config: ApiClientConfig,
bearer_store: BearerTokenStore,
account_key_lock_dir: PathBuf,
owner_store: SyncOwnerStore,
identity: DeviceIdentity,
last_outcome: Option<SyncOutcome>,
}
@@ -41,12 +42,10 @@ impl SyncEngine {
device_name: impl Into<String>,
platform: impl Into<String>,
) -> Result<Self, SyncClientError> {
let browser_data_root = browser_data_root(profile_id, profile_data_dir)?;
let sync_dir = profile_data_dir.join("sync");
let account_key_lock_dir = profile_data_dir
.parent()
.and_then(Path::parent)
.unwrap_or(profile_data_dir)
.join(".sync-key-locks");
let account_key_lock_dir = browser_data_root.join(".sync-key-locks");
let owner_store = SyncOwnerStore::new(browser_data_root);
let identity =
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir);
@@ -54,6 +53,7 @@ impl SyncEngine {
api_config: ApiClientConfig::production(),
bearer_store,
account_key_lock_dir,
owner_store,
identity,
last_outcome: None,
})
@@ -107,6 +107,8 @@ impl SyncEngine {
client: SyncApiClient,
) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError>
{
let authenticated_user_id = client.authenticated_user_id()?;
self.owner_store.verify(&authenticated_user_id)?;
let idempotency_key = device_registration_idempotency_key(&self.identity);
let registration = match client.register_device(&self.identity, &idempotency_key) {
Ok(registration) => registration,
@@ -122,6 +124,11 @@ impl SyncEngine {
}
Err(error) => return Err(error),
};
if registration.version != 2 || registration.user_id != authenticated_user_id {
return Err(SyncClientError::DeviceTrust {
reason: "device registration account does not match authenticated session",
});
}
Ok((client, registration))
}
@@ -366,6 +373,22 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
format!("device-register:{}", identity.device_id)
}
fn browser_data_root<'a>(
profile_id: &ProfileId,
profile_data_dir: &'a Path,
) -> Result<&'a Path, SyncClientError> {
let profile_directory = profile_data_dir
.parent()
.filter(|_| profile_data_dir.file_name().is_some_and(|name| name == "servo"))
.filter(|directory| directory.file_name().is_some_and(|name| name == profile_id.as_str()))
.ok_or_else(|| {
SyncClientError::SyncOwnerStorage("sync profile data path is invalid".to_string())
})?;
profile_directory.parent().ok_or_else(|| {
SyncClientError::SyncOwnerStorage("browser data root is unavailable".to_string())
})
}
#[derive(Debug)]
pub struct SyncEngineBuilder {
pub profile_id: ProfileId,
@@ -107,10 +107,86 @@ fn reconcile_authenticated_result<T>(
#[cfg(test)]
mod tests {
use std::cell::Cell;
use std::{
cell::Cell,
io::{Read, Write},
net::TcpListener,
thread::{self, JoinHandle},
time::Duration,
};
use super::reconcile_authenticated_result;
use ely_sync_client::SyncClientError;
use super::{SyncEngine, reconcile_authenticated_result};
use crate::sync_engine::browser_data_root;
use ely_domain::ProfileId;
use ely_sync_client::{
ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SyncApiClient,
SyncClientError, SyncOwnerStore,
};
type IdentityServer = JoinHandle<std::io::Result<String>>;
#[test]
fn owner_mismatch_stops_before_device_registration() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let profile_id = ProfileId::new();
let profile_data_dir = directory.path().join(profile_id.as_str()).join("servo");
std::fs::create_dir_all(&profile_data_dir)?;
let owner_store = SyncOwnerStore::new(directory.path());
owner_store.claim("user-A")?;
let (base_url, server) = spawn_identity_server("user-B")?;
let engine = SyncEngine {
api_config: ApiClientConfig::custom(base_url.clone(), "auto"),
bearer_store: BearerTokenStore::new(&profile_id, &profile_data_dir),
account_key_lock_dir: directory.path().join(".sync-key-locks"),
owner_store,
identity: DeviceIdentity {
device_id: "device-01".to_string(),
public_key: "a".repeat(64),
wrapping_public_key: "b".repeat(64),
device_name: "Test".to_string(),
platform: "test".to_string(),
},
last_outcome: None,
};
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
let result = engine.registered_client(client);
assert!(matches!(result, Err(SyncClientError::SyncOwnerMismatch)));
let request = join_identity_server(server)?;
assert!(request.starts_with("GET /api/devices HTTP/1.1\r\n"));
Ok(())
}
#[test]
fn profile_data_path_must_resolve_to_the_global_root() {
let profile_id = ProfileId::new();
let valid = std::path::PathBuf::from("/profiles").join(profile_id.as_str()).join("servo");
assert!(matches!(
browser_data_root(&profile_id, &valid),
Ok(root) if root == std::path::Path::new("/profiles")
));
assert!(browser_data_root(&profile_id, std::path::Path::new("/servo")).is_err());
assert!(
browser_data_root(
&profile_id,
&std::path::PathBuf::from("/profiles")
.join(ProfileId::new().as_str())
.join("servo")
)
.is_err()
);
assert!(
browser_data_root(
&profile_id,
&std::path::PathBuf::from("/profiles").join(profile_id.as_str()).join("other")
)
.is_err()
);
}
#[test]
fn terminal_session_clears_the_captured_credential() -> Result<(), SyncClientError> {
@@ -156,4 +232,48 @@ mod tests {
assert!(matches!(result, Err(SyncClientError::SnapshotBusy)));
assert!(!clear_called.get());
}
fn spawn_identity_server(user_id: &str) -> Result<(String, IdentityServer), std::io::Error> {
let listener = TcpListener::bind("127.0.0.1:0")?;
listener.set_nonblocking(true)?;
let address = listener.local_addr()?;
let body = format!(r#"{{"version":1,"user_id":"{user_id}","devices":[]}}"#);
let server = thread::spawn(move || {
let (mut stream, _) = (0..100)
.find_map(|_| match listener.accept() {
Ok(connection) => Some(Ok(connection)),
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
None
}
Err(error) => Some(Err(error)),
})
.ok_or_else(|| std::io::Error::other("identity request was not received"))??;
let mut request = Vec::new();
let mut chunk = [0_u8; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let read = stream.read(&mut chunk)?;
if read == 0 || request.len() + read > 8192 {
return Err(std::io::Error::other("identity request headers are incomplete"));
}
request.extend_from_slice(&chunk[..read]);
}
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(response.as_bytes())?;
stream.flush()?;
String::from_utf8(request)
.map_err(|_| std::io::Error::other("identity request encoding is invalid"))
});
Ok((format!("http://{address}"), server))
}
fn join_identity_server(server: IdentityServer) -> Result<String, Box<dyn std::error::Error>> {
match server.join() {
Ok(result) => result.map_err(Into::into),
Err(_) => Err("identity server thread panicked".into()),
}
}
}
+11 -1
View File
@@ -1,7 +1,7 @@
use serde::Deserialize;
use super::{SyncApiClient, read_json_from_response};
use crate::error::SyncClientError;
use crate::{device_api::is_subject_id, error::SyncClientError};
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
@@ -17,6 +17,16 @@ struct AuthErrorDocument {
}
impl SyncApiClient {
pub fn authenticated_user_id(&self) -> Result<String, SyncClientError> {
let document = self.list_devices()?;
if document.version != 1 || !is_subject_id(&document.user_id) {
return Err(SyncClientError::DeviceTrust {
reason: "authenticated user identity is invalid",
});
}
Ok(document.user_id)
}
pub fn sign_out(&self) -> Result<(), SyncClientError> {
let endpoint = self.endpoint("/api/session/logout");
let response = self
@@ -12,6 +12,40 @@ use crate::{
type TestServer = JoinHandle<std::io::Result<()>>;
#[test]
fn authenticated_user_id_uses_the_read_only_device_list() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_authenticated_server(
"GET /api/devices HTTP/1.1\r\n",
"200 OK",
r#"{"version":1,"user_id":"user-01","devices":[]}"#,
)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
assert_eq!(client.authenticated_user_id()?, "user-01");
join_server(server)?;
for body in [
r#"{"version":2,"user_id":"user-01","devices":[]}"#,
r#"{"version":1,"user_id":"x","devices":[]}"#,
] {
let (base_url, server) =
spawn_authenticated_server("GET /api/devices HTTP/1.1\r\n", "200 OK", body)?;
let client = SyncApiClient::new(
ApiClientConfig::custom(base_url, "auto"),
BearerToken::new("a".repeat(64))?,
)?;
assert!(matches!(
client.authenticated_user_id(),
Err(crate::SyncClientError::DeviceTrust { .. })
));
join_server(server)?;
}
Ok(())
}
#[test]
fn sign_out_posts_the_bearer_and_validates_success() -> Result<(), Box<dyn Error>> {
let (base_url, server) = spawn_logout_server("200 OK", r#"{"version":1,"signed_out":true}"#)?;
+1 -1
View File
@@ -241,7 +241,7 @@ fn challenge_value<'a>(line: &'a str, name: &'static str) -> Result<&'a str, Syn
Ok(value)
}
fn is_subject_id(value: &str) -> bool {
pub(crate) fn is_subject_id(value: &str) -> bool {
(3..=128).contains(&value.len())
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
}
+9
View File
@@ -20,6 +20,15 @@ pub enum SyncClientError {
#[error("Authenticated session changed during reconciliation")]
SessionChanged,
#[error("Cloud Sync owner is unclaimed; sign in again to bind this browser data")]
SyncOwnerUnclaimed,
#[error("This browser data belongs to a different Ely account")]
SyncOwnerMismatch,
#[error("Cloud Sync owner storage is unavailable: {0}")]
SyncOwnerStorage(String),
#[error("HTTP request failed for {endpoint}: {source}")]
Http {
endpoint: String,
+2
View File
@@ -30,6 +30,7 @@ pub mod encryption;
pub mod error;
pub mod key_store;
pub mod snapshot;
mod sync_owner;
pub mod vault;
mod vault_bootstrap;
@@ -53,6 +54,7 @@ pub use snapshot::{
AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload,
SnapshotUploadRequest,
};
pub use sync_owner::SyncOwnerStore;
pub use vault::{
ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext,
WrappedAccountKey,
+308
View File
@@ -0,0 +1,308 @@
use std::{
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 uuid::Uuid;
use crate::{SyncClientError, device_api::is_subject_id};
const OWNER_FILE: &str = ".sync-owner-user-id";
const OWNER_TEMP_PREFIX: &str = ".sync-owner-tmp-";
const MAX_SUBJECT_ID_BYTES: usize = 128;
#[derive(Clone, Debug)]
pub struct SyncOwnerStore {
root: PathBuf,
}
impl SyncOwnerStore {
pub fn new(browser_data_root: &Path) -> Self {
Self { root: browser_data_root.to_path_buf() }
}
pub fn claim(&self, user_id: &str) -> Result<(), SyncClientError> {
validate_subject_id(user_id)?;
let directory = open_root(&self.root, true)?
.ok_or_else(|| storage("browser data root is unavailable"))?;
if let Some(owner) = read_owner(&directory)? {
return verify_owner(&owner, user_id);
}
persist_owner(&directory, user_id)
}
pub fn verify(&self, user_id: &str) -> Result<(), SyncClientError> {
validate_subject_id(user_id)?;
let Some(directory) = open_root(&self.root, false)? else {
return Err(SyncClientError::SyncOwnerUnclaimed);
};
let owner = read_owner(&directory)?.ok_or(SyncClientError::SyncOwnerUnclaimed)?;
verify_owner(&owner, user_id)
}
}
fn persist_owner(directory: &Dir, user_id: &str) -> Result<(), SyncClientError> {
let temporary = format!("{OWNER_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).map_err(storage_io)?;
validate_private_file(&file)?;
set_private_file_permissions(&file)?;
let write_result = file
.write_all(user_id.as_bytes())
.and_then(|()| file.write_all(b"\n"))
.and_then(|()| file.sync_all());
drop(file);
if let Err(error) = write_result {
let _ = remove_temporary(directory, &temporary);
return Err(storage_io(error));
}
match publish_owner(directory, &temporary) {
Ok(()) => sync_directory(directory)?,
Err(error) if error.kind() == ErrorKind::AlreadyExists => {
remove_temporary(directory, &temporary)?;
}
Err(error) => {
let _ = remove_temporary(directory, &temporary);
return Err(storage_io(error));
}
}
let owner = read_owner(directory)?.ok_or_else(|| storage("sync owner disappeared"))?;
verify_owner(&owner, user_id)
}
fn read_owner(directory: &Dir) -> Result<Option<String>, SyncClientError> {
let mut options = private_open_options();
options.read(true);
let file = match directory.open_with(OWNER_FILE, &options) {
Ok(file) => file,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(storage_io(error)),
};
validate_private_file(&file)?;
let mut bytes = Vec::new();
file.into_std()
.take((MAX_SUBJECT_ID_BYTES + 2) as u64)
.read_to_end(&mut bytes)
.map_err(storage_io)?;
if bytes.len() > MAX_SUBJECT_ID_BYTES + 1 || bytes.last() != Some(&b'\n') {
return Err(storage("sync owner record is invalid"));
}
bytes.pop();
let owner = String::from_utf8(bytes).map_err(|_| storage("sync owner record is invalid"))?;
validate_subject_id(&owner)?;
Ok(Some(owner))
}
fn open_root(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("browser data root traversal is invalid")),
}
}
if root.as_os_str().is_empty() {
return Err(storage("browser data root 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;
}
validate_private_directory(&directory)?;
if create {
set_private_directory_permissions(&directory)?;
}
Ok(Some(directory))
}
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("browser data root is invalid"));
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
if metadata.uid() != rustix::process::geteuid().as_raw() {
return Err(storage("browser data root 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("sync owner record is invalid"));
}
#[cfg(unix)]
{
use cap_std::fs::MetadataExt;
if metadata.uid() != rustix::process::geteuid().as_raw() || metadata.mode() & 0o077 != 0 {
return Err(storage("sync owner record permissions are invalid"));
}
}
Ok(())
}
#[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 set_private_directory_permissions(directory: &Dir) -> Result<(), SyncClientError> {
#[cfg(unix)]
directory
.set_permissions(".", Permissions::from_std(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(std::fs::Permissions::from_mode(0o600)))
.map_err(storage_io)?;
Ok(())
}
#[cfg(any(
target_vendor = "apple",
target_os = "linux",
target_os = "android",
target_os = "redox"
))]
fn publish_owner(directory: &Dir, temporary: &str) -> io::Result<()> {
use std::os::fd::AsFd;
rustix::fs::renameat_with(
directory.as_fd(),
temporary,
directory.as_fd(),
OWNER_FILE,
rustix::fs::RenameFlags::NOREPLACE,
)
.map_err(Into::into)
}
#[cfg(windows)]
fn publish_owner(directory: &Dir, temporary: &str) -> io::Result<()> {
directory.rename(temporary, directory, OWNER_FILE)
}
#[cfg(not(any(
target_vendor = "apple",
target_os = "linux",
target_os = "android",
target_os = "redox",
windows
)))]
fn publish_owner(directory: &Dir, temporary: &str) -> io::Result<()> {
directory.hard_link(temporary, directory, OWNER_FILE)?;
directory.remove_file(temporary)
}
fn remove_temporary(directory: &Dir, temporary: &str) -> Result<(), SyncClientError> {
match directory.remove_file(temporary) {
Ok(()) => Ok(()),
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
Err(error) => Err(storage_io(error)),
}
}
#[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(())
}
fn validate_subject_id(value: &str) -> Result<(), SyncClientError> {
if is_subject_id(value) {
return Ok(());
}
Err(storage("sync owner user identifier is invalid"))
}
fn verify_owner(owner: &str, user_id: &str) -> Result<(), SyncClientError> {
if owner == user_id { Ok(()) } else { Err(SyncClientError::SyncOwnerMismatch) }
}
fn storage_io(error: io::Error) -> SyncClientError {
storage(error.to_string())
}
fn storage(message: impl Into<String>) -> SyncClientError {
SyncClientError::SyncOwnerStorage(message.into())
}
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
@@ -0,0 +1,80 @@
use ely_sync_client::{SyncClientError, SyncOwnerStore};
#[test]
fn browser_data_root_keeps_one_sync_owner() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let store = SyncOwnerStore::new(directory.path());
assert!(matches!(store.verify("user-01"), Err(SyncClientError::SyncOwnerUnclaimed)));
store.claim("user-01")?;
store.claim("user-01")?;
SyncOwnerStore::new(directory.path()).verify("user-01")?;
assert!(matches!(store.verify("user-02"), Err(SyncClientError::SyncOwnerMismatch)));
Ok(())
}
#[test]
fn malformed_sync_owner_fails_closed() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
write_owner(directory.path(), "invalid owner id!")?;
let store = SyncOwnerStore::new(directory.path());
assert!(matches!(store.verify("user-01"), Err(SyncClientError::SyncOwnerStorage(_))));
Ok(())
}
#[test]
fn concurrent_claims_publish_exactly_one_owner() -> Result<(), Box<dyn std::error::Error>> {
let directory = tempfile::tempdir()?;
let root = std::sync::Arc::new(directory.path().to_path_buf());
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let threads = ["user-A", "user-B"].map(|user_id| {
let root = root.clone();
let barrier = barrier.clone();
std::thread::spawn(move || {
barrier.wait();
SyncOwnerStore::new(&root).claim(user_id).is_ok()
})
});
let outcomes = threads
.into_iter()
.map(|thread| thread.join().map_err(|_| "owner claim thread panicked"))
.collect::<Result<Vec<_>, _>>()?;
assert_eq!(outcomes.iter().filter(|outcome| **outcome).count(), 1);
let owner = std::fs::read_to_string(directory.path().join(".sync-owner-user-id"))?;
assert!(matches!(owner.as_str(), "user-A\n" | "user-B\n"));
Ok(())
}
#[cfg(unix)]
#[test]
fn linked_owner_records_fail_closed() -> Result<(), Box<dyn std::error::Error>> {
use std::os::unix::fs::symlink;
let directory = tempfile::tempdir()?;
let external = directory.path().join("external-owner");
write_private_file(&external, "user-01\n")?;
symlink(&external, directory.path().join(".sync-owner-user-id"))?;
assert!(matches!(
SyncOwnerStore::new(directory.path()).verify("user-01"),
Err(SyncClientError::SyncOwnerStorage(_))
));
Ok(())
}
fn write_owner(root: &std::path::Path, value: &str) -> Result<(), std::io::Error> {
write_private_file(&root.join(".sync-owner-user-id"), value)
}
fn write_private_file(path: &std::path::Path, value: &str) -> Result<(), std::io::Error> {
std::fs::write(path, value)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}