fix(sync): bind browser data to one account owner
This commit is contained in:
Generated
+1
@@ -2534,6 +2534,7 @@ dependencies = [
|
|||||||
"ely_sync_client",
|
"ely_sync_client",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"url",
|
"url",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
|||||||
@@ -963,6 +963,7 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传
|
|||||||
- Desktop bearer 以 stable `ProfileId` 作为系统凭据 account,Windows 使用 Local persistence;旧明文文件在 credential read-back 与 durable marker 提交后清理,系统凭据不可用时阻断设备加载与 Sync upload。
|
- Desktop bearer 以 stable `ProfileId` 作为系统凭据 account,Windows 使用 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.
|
- 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.
|
- 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。
|
- 设备注册、rebind、批准、撤销与 Vault rotation。
|
||||||
- Signed Sync reset 和 signed account deletion。
|
- Signed Sync reset 和 signed account deletion。
|
||||||
- 管理所有 `/api/auth/*` 路由。
|
- 管理所有 `/api/auth/*` 路由。
|
||||||
|
|||||||
@@ -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 () => {
|
it("rejects unauthenticated device list requests before D1 reads", async () => {
|
||||||
const d1 = testD1Database([]);
|
const d1 = testD1Database([]);
|
||||||
const response = await handleRequest(
|
const response = await handleRequest(
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ use std::path::Path;
|
|||||||
|
|
||||||
use ely_domain::ProfileId;
|
use ely_domain::ProfileId;
|
||||||
use ely_sync_client::{
|
use ely_sync_client::{
|
||||||
ApiClientConfig, BearerToken, BearerTokenStore, SyncApiClient, SyncClientError, send_email_otp,
|
ApiClientConfig, BearerToken, BearerTokenStore, SyncApiClient, SyncClientError, SyncOwnerStore,
|
||||||
verify_email_otp,
|
send_email_otp, verify_email_otp,
|
||||||
};
|
};
|
||||||
use gpui::Context;
|
use gpui::Context;
|
||||||
|
|
||||||
@@ -386,7 +386,21 @@ fn spawn_verify_otp(profile_id: ProfileId, email: String, otp: String, tx: SyncS
|
|||||||
return;
|
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) {
|
if let Err(error) = tx.send(update) {
|
||||||
retire_stale_auth_update(error.0.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()
|
std::thread::Builder::new()
|
||||||
.name("ely-sync-auth-retire".to_string())
|
.name("ely-sync-auth-retire".to_string())
|
||||||
.spawn(move || {
|
.spawn(move || retire_bearer(token))
|
||||||
let client = SyncApiClient::new(ApiClientConfig::production(), token);
|
|
||||||
if let Ok(client) = client {
|
|
||||||
let _ = client.sign_out();
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map(|_| ())
|
.map(|_| ())
|
||||||
.unwrap_or_else(|error| {
|
.unwrap_or_else(|error| {
|
||||||
tracing::warn!(target: "ely::sync", error = %error, "spawn auth retire failed");
|
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> {
|
fn verified_bearer_from(update: SyncStateUpdate) -> Option<BearerToken> {
|
||||||
match update {
|
match update {
|
||||||
SyncStateUpdate::AuthVerified { token, .. } => Some(token),
|
SyncStateUpdate::AuthVerified { token, .. } => Some(token),
|
||||||
@@ -428,17 +444,28 @@ fn verified_bearer_from(update: SyncStateUpdate) -> Option<BearerToken> {
|
|||||||
|
|
||||||
pub(super) fn save_verified_bearer(
|
pub(super) fn save_verified_bearer(
|
||||||
profile_id: &ProfileId,
|
profile_id: &ProfileId,
|
||||||
|
user_id: &str,
|
||||||
token: &BearerToken,
|
token: &BearerToken,
|
||||||
default_profile_id: Option<&ProfileId>,
|
default_profile_id: Option<&ProfileId>,
|
||||||
) -> Result<(), SyncClientError> {
|
) -> Result<(), SyncClientError> {
|
||||||
let profile_root = default_profile_data_root().ok_or_else(|| {
|
let profile_root = default_profile_data_root().ok_or_else(|| {
|
||||||
SyncClientError::BearerCredentialStorage("profile data root is unavailable".to_string())
|
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);
|
let profile_dir = sync_profile_data_dir(&profile_root, profile_id);
|
||||||
bearer_store_for_profile(profile_id, &profile_dir, &profile_root, default_profile_id)
|
bearer_store_for_profile(profile_id, &profile_dir, &profile_root, default_profile_id)
|
||||||
.save(token)
|
.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)]
|
#[cfg(test)]
|
||||||
#[path = "auth_tests.rs"]
|
#[path = "auth_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use ely_domain::ProfileId;
|
|||||||
use super::super::{ShellState, sync_state::SyncStateUpdate};
|
use super::super::{ShellState, sync_state::SyncStateUpdate};
|
||||||
use super::{
|
use super::{
|
||||||
AuthFlowPhase, active_profile_sync_context_for, bearer_store_for_profile, normalize_email,
|
AuthFlowPhase, active_profile_sync_context_for, bearer_store_for_profile, normalize_email,
|
||||||
verified_bearer_from,
|
verified_bearer_from, verified_session_persistence_message,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -47,6 +47,7 @@ fn stale_verified_updates_retain_the_token_for_server_retirement()
|
|||||||
let update = SyncStateUpdate::AuthVerified {
|
let update = SyncStateUpdate::AuthVerified {
|
||||||
profile_id,
|
profile_id,
|
||||||
email: "user@example.com".to_string(),
|
email: "user@example.com".to_string(),
|
||||||
|
user_id: "user-01".to_string(),
|
||||||
token: token.clone(),
|
token: token.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -54,6 +55,14 @@ fn stale_verified_updates_retain_the_token_for_server_retirement()
|
|||||||
Ok(())
|
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]
|
#[test]
|
||||||
fn private_profile_has_no_sync_auth_context() -> Result<(), Box<dyn std::error::Error>> {
|
fn private_profile_has_no_sync_auth_context() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let state =
|
let state =
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ pub(crate) enum SyncStateUpdate {
|
|||||||
SignOutSucceeded { profile_id: ProfileId },
|
SignOutSucceeded { profile_id: ProfileId },
|
||||||
SignOutFailed { profile_id: ProfileId, message: String },
|
SignOutFailed { profile_id: ProfileId, message: String },
|
||||||
AuthOtpSent { profile_id: ProfileId, email: 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 },
|
AuthError { profile_id: ProfileId, email: String, message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +321,7 @@ impl ElyShell {
|
|||||||
self.release_auth_flow_barrier();
|
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()
|
let attempt_matches = active_profile_id(&self.state).as_ref()
|
||||||
== Some(&profile_id)
|
== Some(&profile_id)
|
||||||
&& matches!(
|
&& matches!(
|
||||||
@@ -336,12 +336,14 @@ impl ElyShell {
|
|||||||
auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified {
|
auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified {
|
||||||
profile_id,
|
profile_id,
|
||||||
email,
|
email,
|
||||||
|
user_id,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
match auth::save_verified_bearer(
|
match auth::save_verified_bearer(
|
||||||
&profile_id,
|
&profile_id,
|
||||||
|
&user_id,
|
||||||
&token,
|
&token,
|
||||||
self.default_profile_id.as_ref(),
|
self.default_profile_id.as_ref(),
|
||||||
) {
|
) {
|
||||||
@@ -357,9 +359,10 @@ impl ElyShell {
|
|||||||
auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified {
|
auth::retire_stale_auth_update(SyncStateUpdate::AuthVerified {
|
||||||
profile_id: profile_id.clone(),
|
profile_id: profile_id.clone(),
|
||||||
email: email.clone(),
|
email: email.clone(),
|
||||||
|
user_id,
|
||||||
token,
|
token,
|
||||||
});
|
});
|
||||||
let message = "System credential access failed.".to_string();
|
let message = auth::verified_session_persistence_message(&error);
|
||||||
self.auth_flow_phase = auth::AuthFlowPhase::Error {
|
self.auth_flow_phase = auth::AuthFlowPhase::Error {
|
||||||
profile_id,
|
profile_id,
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -14,5 +14,8 @@ thiserror.workspace = true
|
|||||||
url.workspace = true
|
url.workspace = true
|
||||||
uuid.workspace = true
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tempfile.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use ely_sync_client::{
|
|||||||
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
|
AccountKey, ApiClientConfig, AuthenticatedSnapshotHead, BearerToken, BearerTokenStore,
|
||||||
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
|
DeviceIdentity, SNAPSHOT_ENCRYPTION_VERSION, SnapshotCryptoContext, SnapshotDownloadResult,
|
||||||
SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError,
|
SnapshotPayload, SnapshotUploadRequest, SnapshotUploadResult, SyncApiClient, SyncClientError,
|
||||||
SyncLatestSnapshotDocument,
|
SyncLatestSnapshotDocument, SyncOwnerStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::state::BrowserCore;
|
use crate::state::BrowserCore;
|
||||||
@@ -27,6 +27,7 @@ pub struct SyncEngine {
|
|||||||
api_config: ApiClientConfig,
|
api_config: ApiClientConfig,
|
||||||
bearer_store: BearerTokenStore,
|
bearer_store: BearerTokenStore,
|
||||||
account_key_lock_dir: PathBuf,
|
account_key_lock_dir: PathBuf,
|
||||||
|
owner_store: SyncOwnerStore,
|
||||||
identity: DeviceIdentity,
|
identity: DeviceIdentity,
|
||||||
last_outcome: Option<SyncOutcome>,
|
last_outcome: Option<SyncOutcome>,
|
||||||
}
|
}
|
||||||
@@ -41,12 +42,10 @@ impl SyncEngine {
|
|||||||
device_name: impl Into<String>,
|
device_name: impl Into<String>,
|
||||||
platform: impl Into<String>,
|
platform: impl Into<String>,
|
||||||
) -> Result<Self, SyncClientError> {
|
) -> Result<Self, SyncClientError> {
|
||||||
|
let browser_data_root = browser_data_root(profile_id, profile_data_dir)?;
|
||||||
let sync_dir = profile_data_dir.join("sync");
|
let sync_dir = profile_data_dir.join("sync");
|
||||||
let account_key_lock_dir = profile_data_dir
|
let account_key_lock_dir = browser_data_root.join(".sync-key-locks");
|
||||||
.parent()
|
let owner_store = SyncOwnerStore::new(browser_data_root);
|
||||||
.and_then(Path::parent)
|
|
||||||
.unwrap_or(profile_data_dir)
|
|
||||||
.join(".sync-key-locks");
|
|
||||||
let identity =
|
let identity =
|
||||||
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
|
DeviceIdentity::load_or_create(&sync_dir.join("device.json"), device_name, platform)?;
|
||||||
let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir);
|
let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir);
|
||||||
@@ -54,6 +53,7 @@ impl SyncEngine {
|
|||||||
api_config: ApiClientConfig::production(),
|
api_config: ApiClientConfig::production(),
|
||||||
bearer_store,
|
bearer_store,
|
||||||
account_key_lock_dir,
|
account_key_lock_dir,
|
||||||
|
owner_store,
|
||||||
identity,
|
identity,
|
||||||
last_outcome: None,
|
last_outcome: None,
|
||||||
})
|
})
|
||||||
@@ -107,6 +107,8 @@ impl SyncEngine {
|
|||||||
client: SyncApiClient,
|
client: SyncApiClient,
|
||||||
) -> Result<(SyncApiClient, ely_sync_client::client::DeviceRecordDocument), SyncClientError>
|
) -> 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 idempotency_key = device_registration_idempotency_key(&self.identity);
|
||||||
let registration = match client.register_device(&self.identity, &idempotency_key) {
|
let registration = match client.register_device(&self.identity, &idempotency_key) {
|
||||||
Ok(registration) => registration,
|
Ok(registration) => registration,
|
||||||
@@ -122,6 +124,11 @@ impl SyncEngine {
|
|||||||
}
|
}
|
||||||
Err(error) => return Err(error),
|
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))
|
Ok((client, registration))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,6 +373,22 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
|
|||||||
format!("device-register:{}", identity.device_id)
|
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)]
|
#[derive(Debug)]
|
||||||
pub struct SyncEngineBuilder {
|
pub struct SyncEngineBuilder {
|
||||||
pub profile_id: ProfileId,
|
pub profile_id: ProfileId,
|
||||||
|
|||||||
@@ -107,10 +107,86 @@ fn reconcile_authenticated_result<T>(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
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 super::{SyncEngine, reconcile_authenticated_result};
|
||||||
use ely_sync_client::SyncClientError;
|
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]
|
#[test]
|
||||||
fn terminal_session_clears_the_captured_credential() -> Result<(), SyncClientError> {
|
fn terminal_session_clears_the_captured_credential() -> Result<(), SyncClientError> {
|
||||||
@@ -156,4 +232,48 @@ mod tests {
|
|||||||
assert!(matches!(result, Err(SyncClientError::SnapshotBusy)));
|
assert!(matches!(result, Err(SyncClientError::SnapshotBusy)));
|
||||||
assert!(!clear_called.get());
|
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()),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use super::{SyncApiClient, read_json_from_response};
|
use super::{SyncApiClient, read_json_from_response};
|
||||||
use crate::error::SyncClientError;
|
use crate::{device_api::is_subject_id, error::SyncClientError};
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
@@ -17,6 +17,16 @@ struct AuthErrorDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SyncApiClient {
|
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> {
|
pub fn sign_out(&self) -> Result<(), SyncClientError> {
|
||||||
let endpoint = self.endpoint("/api/session/logout");
|
let endpoint = self.endpoint("/api/session/logout");
|
||||||
let response = self
|
let response = self
|
||||||
|
|||||||
@@ -12,6 +12,40 @@ use crate::{
|
|||||||
|
|
||||||
type TestServer = JoinHandle<std::io::Result<()>>;
|
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]
|
#[test]
|
||||||
fn sign_out_posts_the_bearer_and_validates_success() -> Result<(), Box<dyn Error>> {
|
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}"#)?;
|
let (base_url, server) = spawn_logout_server("200 OK", r#"{"version":1,"signed_out":true}"#)?;
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ fn challenge_value<'a>(line: &'a str, name: &'static str) -> Result<&'a str, Syn
|
|||||||
Ok(value)
|
Ok(value)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_subject_id(value: &str) -> bool {
|
pub(crate) fn is_subject_id(value: &str) -> bool {
|
||||||
(3..=128).contains(&value.len())
|
(3..=128).contains(&value.len())
|
||||||
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
|
&& value.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"._:-".contains(&byte))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,15 @@ pub enum SyncClientError {
|
|||||||
#[error("Authenticated session changed during reconciliation")]
|
#[error("Authenticated session changed during reconciliation")]
|
||||||
SessionChanged,
|
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}")]
|
#[error("HTTP request failed for {endpoint}: {source}")]
|
||||||
Http {
|
Http {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ pub mod encryption;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod key_store;
|
pub mod key_store;
|
||||||
pub mod snapshot;
|
pub mod snapshot;
|
||||||
|
mod sync_owner;
|
||||||
pub mod vault;
|
pub mod vault;
|
||||||
mod vault_bootstrap;
|
mod vault_bootstrap;
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ pub use snapshot::{
|
|||||||
AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload,
|
AuthenticatedSnapshot, AuthenticatedSnapshotHead, SnapshotDownload, SnapshotPayload,
|
||||||
SnapshotUploadRequest,
|
SnapshotUploadRequest,
|
||||||
};
|
};
|
||||||
|
pub use sync_owner::SyncOwnerStore;
|
||||||
pub use vault::{
|
pub use vault::{
|
||||||
ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext,
|
ACCOUNT_KEY_WRAP_SUITE, ACCOUNT_KEY_WRAP_VERSION, SyncVaultDocument, VaultContext,
|
||||||
WrappedAccountKey,
|
WrappedAccountKey,
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user