fix(auth): store bearer tokens in native credentials
This commit is contained in:
Generated
+118
-13
@@ -162,6 +162,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ambient-authority"
|
||||||
|
version = "0.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -1146,6 +1152,48 @@ dependencies = [
|
|||||||
"wayland-client",
|
"wayland-client",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cap-fs-ext"
|
||||||
|
version = "4.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b"
|
||||||
|
dependencies = [
|
||||||
|
"cap-primitives",
|
||||||
|
"cap-std",
|
||||||
|
"io-lifetimes 3.0.1",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cap-primitives"
|
||||||
|
version = "4.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdadbd7c002d3a484b35243669abdae85a0ebaded5a61117169dc3400f9a7ff0"
|
||||||
|
dependencies = [
|
||||||
|
"ambient-authority",
|
||||||
|
"fs-set-times",
|
||||||
|
"io-extras",
|
||||||
|
"io-lifetimes 3.0.1",
|
||||||
|
"ipnet",
|
||||||
|
"maybe-owned",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"rustix-linux-procfs",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
"winx",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cap-std"
|
||||||
|
version = "4.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608"
|
||||||
|
dependencies = [
|
||||||
|
"cap-primitives",
|
||||||
|
"io-extras",
|
||||||
|
"io-lifetimes 3.0.1",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cbc"
|
name = "cbc"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
@@ -2445,6 +2493,8 @@ name = "ely_app"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
|
"cap-fs-ext",
|
||||||
|
"cap-std",
|
||||||
"core-foundation 0.10.0",
|
"core-foundation 0.10.0",
|
||||||
"core-video",
|
"core-video",
|
||||||
"directories",
|
"directories",
|
||||||
@@ -2454,6 +2504,7 @@ dependencies = [
|
|||||||
"ely_domain",
|
"ely_domain",
|
||||||
"ely_servo_host",
|
"ely_servo_host",
|
||||||
"ely_sync_client",
|
"ely_sync_client",
|
||||||
|
"fs2",
|
||||||
"gpui",
|
"gpui",
|
||||||
"gpui-component",
|
"gpui-component",
|
||||||
"gpui-component-assets",
|
"gpui-component-assets",
|
||||||
@@ -2532,7 +2583,10 @@ dependencies = [
|
|||||||
name = "ely_sync_client"
|
name = "ely_sync_client"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"apple-native-keyring-store",
|
||||||
"base64",
|
"base64",
|
||||||
|
"cap-fs-ext",
|
||||||
|
"cap-std",
|
||||||
"chacha20poly1305",
|
"chacha20poly1305",
|
||||||
"ed25519-dalek 2.2.0",
|
"ed25519-dalek 2.2.0",
|
||||||
"ely_domain",
|
"ely_domain",
|
||||||
@@ -2541,13 +2595,17 @@ dependencies = [
|
|||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
"hmac 0.12.1",
|
"hmac 0.12.1",
|
||||||
"hpke",
|
"hpke",
|
||||||
"keyring",
|
"keyring-core",
|
||||||
|
"rustix 1.1.4",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"ureq",
|
"ureq",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"windows-native-keyring-store",
|
||||||
|
"zbus-secret-service-keyring-store",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3108,6 +3166,17 @@ dependencies = [
|
|||||||
"pkg-config",
|
"pkg-config",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs-set-times"
|
||||||
|
version = "0.20.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a"
|
||||||
|
dependencies = [
|
||||||
|
"io-lifetimes 2.0.4",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fs2"
|
name = "fs2"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -4999,6 +5068,28 @@ dependencies = [
|
|||||||
"rustversion",
|
"rustversion",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "io-extras"
|
||||||
|
version = "0.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f"
|
||||||
|
dependencies = [
|
||||||
|
"io-lifetimes 3.0.1",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "io-lifetimes"
|
||||||
|
version = "2.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "io-lifetimes"
|
||||||
|
version = "3.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "io-surface"
|
name = "io-surface"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -5241,18 +5332,6 @@ dependencies = [
|
|||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "keyring"
|
|
||||||
version = "4.1.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe"
|
|
||||||
dependencies = [
|
|
||||||
"apple-native-keyring-store",
|
|
||||||
"keyring-core",
|
|
||||||
"windows-native-keyring-store",
|
|
||||||
"zbus-secret-service-keyring-store",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "keyring-core"
|
name = "keyring-core"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -5662,6 +5741,12 @@ dependencies = [
|
|||||||
"regex-automata 0.4.14",
|
"regex-automata 0.4.14",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maybe-owned"
|
||||||
|
version = "0.3.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "maybe-rayon"
|
name = "maybe-rayon"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -7872,6 +7957,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix-linux-procfs"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"rustix 1.1.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.40"
|
version = "0.23.40"
|
||||||
@@ -12608,6 +12703,16 @@ version = "0.0.19"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winx"
|
||||||
|
version = "0.36.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.1",
|
||||||
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wio"
|
name = "wio"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
|
|||||||
+4
-1
@@ -19,6 +19,8 @@ rust-version = "1.95"
|
|||||||
|
|
||||||
[workspace.dependencies]
|
[workspace.dependencies]
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
cap-fs-ext = "4.0.2"
|
||||||
|
cap-std = "4.0.2"
|
||||||
chacha20poly1305 = "0.11.0"
|
chacha20poly1305 = "0.11.0"
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
dpi = "0.1"
|
dpi = "0.1"
|
||||||
@@ -32,7 +34,8 @@ hkdf = "0.12.4"
|
|||||||
hmac = "0.12.1"
|
hmac = "0.12.1"
|
||||||
hpke = { version = "0.14.0", default-features = false, features = ["alloc", "chacha", "getrandom", "x25519"] }
|
hpke = { version = "0.14.0", default-features = false, features = ["alloc", "chacha", "getrandom", "x25519"] }
|
||||||
image = "0.25.10"
|
image = "0.25.10"
|
||||||
keyring = "4.1.4"
|
keyring-core = "1.0.0"
|
||||||
|
rustix = "1.1.4"
|
||||||
servo = { version = "0.4.0", git = "https://github.com/servo/servo.git", rev = "c983c232385edf1170118b24637d2eb2564674d7" }
|
servo = { version = "0.4.0", git = "https://github.com/servo/servo.git", rev = "c983c232385edf1170118b24637d2eb2564674d7" }
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
semver = "1.0.28"
|
semver = "1.0.28"
|
||||||
|
|||||||
@@ -960,6 +960,7 @@ Better Auth 在 Cloudflare Workers 中初始化,D1 binding 作为 database 传
|
|||||||
- Google/GitHub OAuth 按环境启用。
|
- Google/GitHub OAuth 按环境启用。
|
||||||
- D1 session validation 与自定义 device/session binding。
|
- D1 session validation 与自定义 device/session binding。
|
||||||
- Bearer logout 精确删除当前 D1 session,并级联清理 session device context 与 rebind challenge。
|
- Bearer logout 精确删除当前 D1 session,并级联清理 session device context 与 rebind challenge。
|
||||||
|
- Desktop bearer 以 stable `ProfileId` 作为系统凭据 account,Windows 使用 Local persistence;旧明文文件在 credential read-back 与 durable marker 提交后清理,系统凭据不可用时阻断设备加载与 Sync upload。
|
||||||
- 设备注册、rebind、批准、撤销与 Vault rotation。
|
- 设备注册、rebind、批准、撤销与 Vault rotation。
|
||||||
- Signed Sync reset 和 signed account deletion。
|
- Signed Sync reset 和 signed account deletion。
|
||||||
- 管理所有 `/api/auth/*` 路由。
|
- 管理所有 `/api/auth/*` 路由。
|
||||||
@@ -1571,7 +1572,7 @@ Servo Host 需要实现:
|
|||||||
| ID | 模块 | 验收标准 |
|
| ID | 模块 | 验收标准 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| S-001 | Sync encryption | 服务端无法解密 Sync payload |
|
| S-001 | Sync encryption | 服务端无法解密 Sync payload |
|
||||||
| S-002 | Keychain | session token 和本地数据库密钥存入系统钥匙串 |
|
| S-002 | Keychain | session token 按 stable ProfileId 隔离存入原生系统凭据;明文 legacy token 完成可恢复的一次迁移后删除;凭据不可用时进入独立错误状态并暂停云端动作 |
|
||||||
| S-003 | Device revoke | 撤销设备后目标 sessions 失效、后续 Sync API 被拒绝、approved revoke 原子轮换 Vault generation |
|
| S-003 | Device revoke | 撤销设备后目标 sessions 失效、后续 Sync API 被拒绝、approved revoke 原子轮换 Vault generation |
|
||||||
| S-004 | Plugin sandbox | 插件无声明权限时无法访问 tabs/bookmarks/history/page |
|
| S-004 | Plugin sandbox | 插件无声明权限时无法访问 tabs/bookmarks/history/page |
|
||||||
| S-005 | Plugin signature | 市场插件必须签名验证通过 |
|
| S-005 | Plugin signature | 市场插件必须签名验证通过 |
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { primaryD1Session } from "./bindings.js";
|
|||||||
import { prefixedKvKey } from "./kv_keys.js";
|
import { prefixedKvKey } from "./kv_keys.js";
|
||||||
|
|
||||||
const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache";
|
const AUTH_SESSION_CACHE_NAMESPACE = "auth_session_cache";
|
||||||
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]{32,4096}$/;
|
const BEARER_TOKEN_PATTERN = /^[A-Za-z0-9._~+/=-]{32,2560}$/;
|
||||||
const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
const SUBJECT_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
||||||
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
const DEVICE_ID_PATTERN = /^[a-zA-Z0-9._:-]{3,128}$/;
|
||||||
const BETTER_AUTH_SESSION_QUERY = `
|
const BETTER_AUTH_SESSION_QUERY = `
|
||||||
|
|||||||
@@ -149,6 +149,23 @@ describe("api controls", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects bearer tokens larger than the native credential contract", async () => {
|
||||||
|
const d1Queries: string[] = [];
|
||||||
|
const response = await withAuthenticatedApiControls(
|
||||||
|
new Request("https://elydora.test/api/devices", {
|
||||||
|
headers: { authorization: `Bearer ${"A".repeat(2561)}` },
|
||||||
|
}),
|
||||||
|
testEnv({ d1: testD1Database({ queries: d1Queries }) }),
|
||||||
|
"devices.list",
|
||||||
|
["GET"],
|
||||||
|
() => Promise.resolve(jsonResponse({ ok: true }, 200)),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.status, 401);
|
||||||
|
assert.deepEqual(await response.json(), { error: "authorization_invalid" });
|
||||||
|
assert.deepEqual(d1Queries, []);
|
||||||
|
});
|
||||||
|
|
||||||
it("passes authenticated session context and records subject audit fields", async () => {
|
it("passes authenticated session context and records subject audit fields", async () => {
|
||||||
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
const tokenHash = await authTokenHash(ACCESS_TOKEN);
|
||||||
const auditEvents: ElyAnalyticsDataPoint[] = [];
|
const auditEvents: ElyAnalyticsDataPoint[] = [];
|
||||||
|
|||||||
@@ -10,12 +10,15 @@ live-site-smoke = []
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
ahash = "0.8"
|
ahash = "0.8"
|
||||||
|
cap-fs-ext.workspace = true
|
||||||
|
cap-std.workspace = true
|
||||||
directories.workspace = true
|
directories.workspace = true
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
ely_browser_core = { path = "../ely_browser_core" }
|
ely_browser_core = { path = "../ely_browser_core" }
|
||||||
ely_design_system = { path = "../ely_design_system" }
|
ely_design_system = { path = "../ely_design_system" }
|
||||||
ely_domain = { path = "../ely_domain" }
|
ely_domain = { path = "../ely_domain" }
|
||||||
ely_sync_client = { path = "../ely_sync_client" }
|
ely_sync_client = { path = "../ely_sync_client" }
|
||||||
|
fs2.workspace = true
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-component.workspace = true
|
gpui-component.workspace = true
|
||||||
gpui-component-assets.workspace = true
|
gpui-component-assets.workspace = true
|
||||||
@@ -29,6 +32,7 @@ tracing.workspace = true
|
|||||||
tracing-subscriber.workspace = true
|
tracing-subscriber.workspace = true
|
||||||
ureq.workspace = true
|
ureq.workspace = true
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
core-foundation = "0.10"
|
core-foundation = "0.10"
|
||||||
@@ -37,7 +41,6 @@ io-surface = "0.16"
|
|||||||
mach2 = "0.6"
|
mach2 = "0.6"
|
||||||
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
objc2-core-foundation = { version = "0.3.2", features = ["CFBase", "CFDictionary", "CFNumber", "CFString"] }
|
||||||
objc2-io-surface = "0.3.2"
|
objc2-io-surface = "0.3.2"
|
||||||
uuid.workspace = true
|
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
ely_servo_host = { path = "../ely_servo_host" }
|
ely_servo_host = { path = "../ely_servo_host" }
|
||||||
|
|||||||
@@ -139,29 +139,54 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Drop the persisted bearer token and reset the local form.
|
/// 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>) {
|
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) {
|
let active_profile_id = match active_profile_id_for(&self.state) {
|
||||||
Some(profile_id) => profile_id,
|
Some(profile_id) => profile_id,
|
||||||
None => return,
|
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 {
|
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;
|
return;
|
||||||
};
|
};
|
||||||
let profile_dir = sync_profile_data_dir(&profile_root, &active_profile_id);
|
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");
|
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 {
|
if let ShellState::Ready(core) = &mut self.state {
|
||||||
core.set_sync_connection_state(ely_domain::SyncConnectionState::SignedOut);
|
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 {
|
fn read_auth_email_input(&self, cx: &Context<Self>) -> String {
|
||||||
self.auth_email_input.read(cx).value().to_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)
|
core.snapshot().ok().map(|snapshot| snapshot.active_profile_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn clear_persisted_bearer(profile_dir: &Path) -> Result<(), SyncClientError> {
|
pub(super) fn clear_persisted_bearer(
|
||||||
BearerTokenStore::new(profile_dir.join("sync").join("bearer.token")).clear()
|
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>) {
|
fn spawn_send_otp(profile_id: ProfileId, email: String, tx: Sender<SyncStateUpdate>) {
|
||||||
@@ -252,7 +297,12 @@ fn spawn_verify_otp(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let mut engine =
|
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,
|
Ok(engine) => engine,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
let _ = tx.send(SyncStateUpdate::AuthError {
|
let _ = tx.send(SyncStateUpdate::AuthError {
|
||||||
@@ -284,7 +334,9 @@ mod tests {
|
|||||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||||
use ely_domain::ProfileId;
|
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;
|
use crate::shell::ShellState;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -334,4 +386,57 @@ mod tests {
|
|||||||
|
|
||||||
Ok(())
|
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,
|
render_reset_button, render_secondary_button, render_sign_out_button,
|
||||||
};
|
};
|
||||||
use super::{ElyShell, render_canvas_surface};
|
use super::{ElyShell, render_canvas_surface};
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
pub(super) fn render_sync_page(
|
pub(super) fn render_sync_page(
|
||||||
&mut self,
|
&mut self,
|
||||||
@@ -23,7 +22,10 @@ impl ElyShell {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> AnyElement {
|
) -> AnyElement {
|
||||||
if profile_allows_sync_controls(&snapshot.active_profile_kind)
|
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);
|
self.ensure_sync_devices_loaded(cx);
|
||||||
}
|
}
|
||||||
@@ -37,7 +39,6 @@ impl ElyShell {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_sync_body(
|
fn render_sync_body(
|
||||||
shell: &mut ElyShell,
|
shell: &mut ElyShell,
|
||||||
snapshot: &BrowserSnapshot,
|
snapshot: &BrowserSnapshot,
|
||||||
@@ -63,11 +64,9 @@ fn render_sync_body(
|
|||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn profile_allows_sync_controls(profile_kind: &ProfileKind) -> bool {
|
fn profile_allows_sync_controls(profile_kind: &ProfileKind) -> bool {
|
||||||
profile_kind == &ProfileKind::Standard
|
profile_kind == &ProfileKind::Standard
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_private_profile_card() -> AnyElement {
|
fn render_private_profile_card() -> AnyElement {
|
||||||
div()
|
div()
|
||||||
.p(px(16.0))
|
.p(px(16.0))
|
||||||
@@ -88,7 +87,6 @@ fn render_private_profile_card() -> AnyElement {
|
|||||||
)
|
)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_account_card(
|
fn render_account_card(
|
||||||
shell: &mut ElyShell,
|
shell: &mut ElyShell,
|
||||||
snapshot: &BrowserSnapshot,
|
snapshot: &BrowserSnapshot,
|
||||||
@@ -102,6 +100,11 @@ fn render_account_card(
|
|||||||
.child(render_card_heading("Account"))
|
.child(render_card_heading("Account"))
|
||||||
.children(account_form(shell, &snapshot.active_profile_id, cx))
|
.children(account_form(shell, &snapshot.active_profile_id, cx))
|
||||||
.into_any_element(),
|
.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::SignedIn
|
||||||
| SyncConnectionState::AwaitingDeviceApproval
|
| SyncConnectionState::AwaitingDeviceApproval
|
||||||
| SyncConnectionState::SyncReady { .. }
|
| SyncConnectionState::SyncReady { .. }
|
||||||
@@ -120,11 +123,13 @@ fn render_account_card(
|
|||||||
.text_color(rgb(colors::ink_3()))
|
.text_color(rgb(colors::ink_3()))
|
||||||
.child("End-to-end encrypted"),
|
.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))
|
.child(render_devices(shell, cx))
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement {
|
fn render_devices(shell: &mut ElyShell, cx: &mut Context<ElyShell>) -> AnyElement {
|
||||||
let loading = shell.sync_devices.is_loading();
|
let loading = shell.sync_devices.is_loading();
|
||||||
let header =
|
let header =
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ pub struct ElyShell {
|
|||||||
sync_upload_pending: bool,
|
sync_upload_pending: bool,
|
||||||
sync_upload_pending_merge: Option<PendingMergeUpload>,
|
sync_upload_pending_merge: Option<PendingMergeUpload>,
|
||||||
sync_retry_at: Option<std::time::Instant>,
|
sync_retry_at: Option<std::time::Instant>,
|
||||||
|
default_profile_id: Option<ProfileId>,
|
||||||
pub(crate) sync_devices: SyncDeviceUiState,
|
pub(crate) sync_devices: SyncDeviceUiState,
|
||||||
pub(crate) sync_verification_input: Entity<InputState>,
|
pub(crate) sync_verification_input: Entity<InputState>,
|
||||||
pub(crate) auth_email_input: Entity<InputState>,
|
pub(crate) auth_email_input: Entity<InputState>,
|
||||||
@@ -126,21 +127,23 @@ pub struct ElyShell {
|
|||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let mut default_profile_id = None;
|
||||||
let config = InitialBrowserConfig::ely_defaults()
|
let config = InitialBrowserConfig::ely_defaults()
|
||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
.and_then(|mut config| {
|
.and_then(|mut config| {
|
||||||
config.profile_id = Some(
|
let profile_id = crate::services::profile_identity::default_standard_profile_id()
|
||||||
crate::services::profile_identity::default_standard_profile_id()
|
.map_err(|error| error.to_string())?;
|
||||||
.map_err(|error| error.to_string())?,
|
config.profile_id = Some(profile_id.clone());
|
||||||
);
|
default_profile_id = Some(profile_id);
|
||||||
Ok(config)
|
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 {
|
pub fn new_private(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
Self::new_with_config(
|
Self::new_with_config(
|
||||||
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
|
InitialBrowserConfig::private_window().map_err(|error| error.to_string()),
|
||||||
|
None,
|
||||||
window,
|
window,
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
@@ -148,6 +151,7 @@ impl ElyShell {
|
|||||||
|
|
||||||
fn new_with_config(
|
fn new_with_config(
|
||||||
config: Result<InitialBrowserConfig, String>,
|
config: Result<InitialBrowserConfig, String>,
|
||||||
|
default_profile_id: Option<ProfileId>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -261,6 +265,7 @@ impl ElyShell {
|
|||||||
sync_upload_pending: false,
|
sync_upload_pending: false,
|
||||||
sync_upload_pending_merge: None,
|
sync_upload_pending_merge: None,
|
||||||
sync_retry_at: None,
|
sync_retry_at: None,
|
||||||
|
default_profile_id,
|
||||||
sync_devices: SyncDeviceUiState::default(),
|
sync_devices: SyncDeviceUiState::default(),
|
||||||
sync_verification_input,
|
sync_verification_input,
|
||||||
auth_email_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 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};
|
use super::{ElyShell, ShellState};
|
||||||
|
|
||||||
impl ElyShell {
|
impl ElyShell {
|
||||||
@@ -255,11 +257,11 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
|
fn trigger_cloud_sync_upload_with_merge(&mut self, mut merge: Option<PendingMergeUpload>) {
|
||||||
let active_profile_allows_sync = match &self.state {
|
let cloud_sync_enabled = match &self.state {
|
||||||
ShellState::Ready(core) => core.active_profile_allows_sync(),
|
ShellState::Ready(core) => core.cloud_sync_upload_enabled(),
|
||||||
ShellState::StartupError(_) => false,
|
ShellState::StartupError(_) => false,
|
||||||
};
|
};
|
||||||
if !active_profile_allows_sync {
|
if !cloud_sync_enabled {
|
||||||
self.sync_upload_scheduled = false;
|
self.sync_upload_scheduled = false;
|
||||||
self.clear_pending_cloud_sync_upload();
|
self.clear_pending_cloud_sync_upload();
|
||||||
return;
|
return;
|
||||||
@@ -352,6 +354,7 @@ fn run_sync_upload(
|
|||||||
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
|
inbox: std::sync::mpsc::Sender<SyncStateUpdate>,
|
||||||
) {
|
) {
|
||||||
let mut engine = match SyncEngine::for_profile_dir(
|
let mut engine = match SyncEngine::for_profile_dir(
|
||||||
|
&profile_id,
|
||||||
&profile_dir,
|
&profile_dir,
|
||||||
device_name,
|
device_name,
|
||||||
sync_platform_label(),
|
sync_platform_label(),
|
||||||
@@ -360,7 +363,7 @@ fn run_sync_upload(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
let message = error.to_string();
|
let message = error.to_string();
|
||||||
tracing::warn!(target: "ely::sync", error = %message, "could not initialise sync engine");
|
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;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -380,7 +383,10 @@ fn run_sync_upload(
|
|||||||
device_id = %device_id,
|
device_id = %device_id,
|
||||||
"sync device is awaiting approval",
|
"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 {
|
Ok(ely_browser_core::SyncOutcome::RemoteSnapshot {
|
||||||
snapshot_id,
|
snapshot_id,
|
||||||
@@ -454,12 +460,7 @@ fn run_sync_upload(
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
let message = error.to_string();
|
let message = error.to_string();
|
||||||
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
|
tracing::warn!(target: "ely::sync", error = %message, "snapshot upload failed");
|
||||||
let update = if message.contains("device_not_approved") {
|
let _ = inbox.send(sync_failure_update(profile_id, error));
|
||||||
SyncStateUpdate::AwaitingDeviceApproval { profile_id }
|
|
||||||
} else {
|
|
||||||
SyncStateUpdate::SyncError { profile_id, message }
|
|
||||||
};
|
|
||||||
let _ = inbox.send(update);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use gpui::Context;
|
|||||||
|
|
||||||
use crate::services::servo_profile_data::{default_profile_data_root, sync_profile_data_dir};
|
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};
|
use super::{ElyShell, ShellState, sync_state::SyncStateUpdate};
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
@@ -141,8 +141,12 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
let tx = self.sync_inbox_tx.clone();
|
let tx = self.sync_inbox_tx.clone();
|
||||||
spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || {
|
spawn_device_task("ely-sync-device-approve", profile_id.clone(), tx, move || {
|
||||||
let engine =
|
let engine = SyncEngine::for_profile_dir(
|
||||||
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
|
&profile_id,
|
||||||
|
&profile_dir,
|
||||||
|
device_name,
|
||||||
|
sync_platform_label(),
|
||||||
|
)?;
|
||||||
engine.approve_cloud_device(&device_id, &verification_code)?;
|
engine.approve_cloud_device(&device_id, &verification_code)?;
|
||||||
load_devices(profile_id, engine)
|
load_devices(profile_id, engine)
|
||||||
});
|
});
|
||||||
@@ -163,8 +167,12 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
let tx = self.sync_inbox_tx.clone();
|
let tx = self.sync_inbox_tx.clone();
|
||||||
spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || {
|
spawn_device_task("ely-sync-device-revoke", profile_id.clone(), tx, move || {
|
||||||
let engine =
|
let engine = SyncEngine::for_profile_dir(
|
||||||
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
|
&profile_id,
|
||||||
|
&profile_dir,
|
||||||
|
device_name,
|
||||||
|
sync_platform_label(),
|
||||||
|
)?;
|
||||||
engine.revoke_cloud_device(&device_id)?;
|
engine.revoke_cloud_device(&device_id)?;
|
||||||
load_devices(profile_id, engine)
|
load_devices(profile_id, engine)
|
||||||
});
|
});
|
||||||
@@ -181,8 +189,12 @@ impl ElyShell {
|
|||||||
}
|
}
|
||||||
let tx = self.sync_inbox_tx.clone();
|
let tx = self.sync_inbox_tx.clone();
|
||||||
spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || {
|
spawn_device_task("ely-sync-device-list", profile_id.clone(), tx, move || {
|
||||||
let engine =
|
let engine = SyncEngine::for_profile_dir(
|
||||||
SyncEngine::for_profile_dir(&profile_dir, device_name, sync_platform_label())?;
|
&profile_id,
|
||||||
|
&profile_dir,
|
||||||
|
device_name,
|
||||||
|
sync_platform_label(),
|
||||||
|
)?;
|
||||||
load_devices(profile_id, engine)
|
load_devices(profile_id, engine)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -191,7 +203,7 @@ impl ElyShell {
|
|||||||
let ShellState::Ready(core) = &self.state else {
|
let ShellState::Ready(core) = &self.state else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
if !core.active_profile_allows_sync() {
|
if !core.cloud_sync_upload_enabled() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let snapshot = core.snapshot().ok()?;
|
let snapshot = core.snapshot().ok()?;
|
||||||
@@ -225,10 +237,7 @@ fn spawn_device_task<F>(
|
|||||||
let worker_tx = tx.clone();
|
let worker_tx = tx.clone();
|
||||||
let worker_profile_id = profile_id.clone();
|
let worker_profile_id = profile_id.clone();
|
||||||
let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || {
|
let spawn_result = std::thread::Builder::new().name(thread_name).spawn(move || {
|
||||||
let update = task().unwrap_or_else(|error| SyncStateUpdate::DevicesError {
|
let update = task().unwrap_or_else(|error| device_failure_update(worker_profile_id, error));
|
||||||
profile_id: worker_profile_id,
|
|
||||||
message: error.to_string(),
|
|
||||||
});
|
|
||||||
let _ = worker_tx.send(update);
|
let _ = worker_tx.send(update);
|
||||||
});
|
});
|
||||||
if let Err(error) = spawn_result {
|
if let Err(error) = spawn_result {
|
||||||
@@ -257,4 +266,67 @@ mod tests {
|
|||||||
assert!(state.current_code().is_none());
|
assert!(state.current_code().is_none());
|
||||||
assert!(state.error().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 std::{path::Path, time::Duration};
|
||||||
|
|
||||||
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
|
use ely_domain::{ProfileId, ProfileKind, SyncConnectionState};
|
||||||
use ely_sync_client::{AuthenticatedSnapshotHead, DeviceRecord};
|
use ely_sync_client::{AuthenticatedSnapshotHead, BearerTokenStore, DeviceRecord};
|
||||||
use gpui::{Context, Timer};
|
use gpui::{Context, Timer};
|
||||||
|
|
||||||
use super::{ElyShell, ShellState, auth};
|
use super::{ElyShell, ShellState, auth};
|
||||||
|
|
||||||
|
mod legacy_sync_migration;
|
||||||
|
|
||||||
const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
|
const CLOUD_SYNC_UPLOAD_DEBOUNCE: Duration = Duration::from_millis(750);
|
||||||
const CAS_RETRY_LIMIT: u8 = 3;
|
const CAS_RETRY_LIMIT: u8 = 3;
|
||||||
const CAS_RETRY_DELAY: Duration = Duration::from_secs(2);
|
const CAS_RETRY_DELAY: Duration = Duration::from_secs(2);
|
||||||
@@ -25,11 +27,12 @@ pub(crate) struct PendingMergeUpload {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub(crate) enum SyncStateUpdate {
|
pub(crate) enum SyncStateUpdate {
|
||||||
SignedOut { profile_id: ProfileId },
|
SignedOut { profile_id: ProfileId },
|
||||||
AwaitingDeviceApproval { profile_id: ProfileId },
|
AwaitingDeviceApproval { profile_id: ProfileId, finishes_upload: bool },
|
||||||
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
|
RemoteSnapshot { profile_id: ProfileId, bytes: Vec<u8>, merge: PendingMergeUpload },
|
||||||
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
|
SyncReady { profile_id: ProfileId, last_synced_at_secs: u64 },
|
||||||
SyncBusy { profile_id: ProfileId },
|
SyncBusy { profile_id: ProfileId },
|
||||||
SyncError { profile_id: ProfileId, message: String },
|
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 },
|
DevicesLoaded { profile_id: ProfileId, devices: Vec<DeviceRecord>, current_code: String },
|
||||||
DevicesError { profile_id: ProfileId, message: String },
|
DevicesError { profile_id: ProfileId, message: String },
|
||||||
AuthOtpSent { profile_id: ProfileId, email: String },
|
AuthOtpSent { profile_id: ProfileId, email: String },
|
||||||
@@ -37,6 +40,45 @@ pub(crate) enum SyncStateUpdate {
|
|||||||
AuthError { profile_id: ProfileId, email: String, message: String },
|
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
|
/// Stable label for the current OS used by the device registration
|
||||||
/// payload. Defined once here so every off-thread call site agrees.
|
/// payload. Defined once here so every off-thread call site agrees.
|
||||||
pub(crate) const fn sync_platform_label() -> &'static str {
|
pub(crate) const fn sync_platform_label() -> &'static str {
|
||||||
@@ -128,7 +170,7 @@ impl ElyShell {
|
|||||||
let ShellState::Ready(core) = &mut self.state else {
|
let ShellState::Ready(core) = &mut self.state else {
|
||||||
return false;
|
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
|
/// Drain any sync upload outcomes the off-thread worker pushed
|
||||||
@@ -155,8 +197,8 @@ impl ElyShell {
|
|||||||
latest_connection = Some(SyncConnectionState::SignedOut);
|
latest_connection = Some(SyncConnectionState::SignedOut);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SyncStateUpdate::AwaitingDeviceApproval { profile_id } => {
|
SyncStateUpdate::AwaitingDeviceApproval { profile_id, finishes_upload } => {
|
||||||
upload_finished = true;
|
upload_finished |= finishes_upload;
|
||||||
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
||||||
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
|
latest_connection = Some(SyncConnectionState::AwaitingDeviceApproval);
|
||||||
}
|
}
|
||||||
@@ -216,6 +258,20 @@ impl ElyShell {
|
|||||||
latest_connection = Some(SyncConnectionState::SyncError { message });
|
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 } => {
|
SyncStateUpdate::DevicesLoaded { profile_id, devices, current_code } => {
|
||||||
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
if active_profile_id(&self.state).as_ref() == Some(&profile_id) {
|
||||||
self.sync_devices.set_ready(profile_id, devices, current_code);
|
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(
|
fn probe_initial_sync_state_at(
|
||||||
core: &mut ely_browser_core::BrowserCore,
|
core: &mut ely_browser_core::BrowserCore,
|
||||||
profile_root: &Path,
|
profile_root: &Path,
|
||||||
|
default_profile_id: Option<&ProfileId>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let Some(snapshot) = core.snapshot().ok() else {
|
let Some(snapshot) = core.snapshot().ok() else {
|
||||||
return false;
|
return false;
|
||||||
@@ -305,65 +362,46 @@ fn probe_initial_sync_state_at(
|
|||||||
&snapshot.active_profile_id,
|
&snapshot.active_profile_id,
|
||||||
);
|
);
|
||||||
if !core.active_profile_allows_sync() {
|
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");
|
tracing::warn!(target: "ely::sync", error = %error, "private bearer cleanup failed");
|
||||||
}
|
}
|
||||||
core.set_sync_connection_state(SyncConnectionState::SignedOut);
|
core.set_sync_connection_state(SyncConnectionState::SignedOut);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if snapshot.active_profile_name == "Default"
|
let is_default_profile = matches!(snapshot.active_profile_kind, ProfileKind::Standard)
|
||||||
&& 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!(
|
tracing::warn!(
|
||||||
target: "ely::sync",
|
target: "ely::sync",
|
||||||
error = %error,
|
error = %error,
|
||||||
source = %candidate.display(),
|
|
||||||
"legacy sync profile migration failed",
|
"legacy sync profile migration failed",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
let store = auth::bearer_store_for_profile(
|
||||||
|
&snapshot.active_profile_id,
|
||||||
fn copy_dir_recursive(source: &Path, destination: &Path) -> std::io::Result<()> {
|
&profile_dir,
|
||||||
std::fs::create_dir_all(destination)?;
|
profile_root,
|
||||||
for entry in std::fs::read_dir(source)? {
|
default_profile_id,
|
||||||
let entry = entry?;
|
);
|
||||||
let file_type = entry.file_type()?;
|
let (bearer_present, state) = match store.load() {
|
||||||
let destination_path = destination.join(entry.file_name());
|
Ok(Some(_)) => (true, SyncConnectionState::SignedIn),
|
||||||
if file_type.is_dir() {
|
Ok(None) => (false, SyncConnectionState::SignedOut),
|
||||||
copy_dir_recursive(&entry.path(), &destination_path)?;
|
Err(error) => {
|
||||||
} else if file_type.is_file() {
|
tracing::warn!(target: "ely::sync", error = %error, "bearer credential probe failed");
|
||||||
std::fs::copy(entry.path(), destination_path)?;
|
(
|
||||||
|
false,
|
||||||
|
SyncConnectionState::CredentialUnavailable {
|
||||||
|
message: "System credential access failed.".to_string(),
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
Ok(())
|
core.set_sync_connection_state(state);
|
||||||
|
bearer_present
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -371,59 +409,9 @@ mod tests {
|
|||||||
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
use ely_browser_core::{BrowserCore, InitialBrowserConfig};
|
||||||
use ely_domain::SyncConnectionState;
|
use ely_domain::SyncConnectionState;
|
||||||
|
|
||||||
use super::{
|
use super::{SyncStateUpdate, probe_initial_sync_state_at, sync_failure_update};
|
||||||
bearer_token_file_present, migrate_legacy_default_sync_dir, probe_initial_sync_state_at,
|
|
||||||
};
|
|
||||||
use crate::services::servo_profile_data::sync_profile_data_dir;
|
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]
|
#[test]
|
||||||
fn private_startup_clears_a_persisted_bearer() -> Result<(), Box<dyn std::error::Error>> {
|
fn private_startup_clears_a_persisted_bearer() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let directory = tempfile::tempdir()?;
|
let directory = tempfile::tempdir()?;
|
||||||
@@ -436,7 +424,7 @@ mod tests {
|
|||||||
std::fs::write(bearer_path.with_extension("tmp"), "b".repeat(64))?;
|
std::fs::write(bearer_path.with_extension("tmp"), "b".repeat(64))?;
|
||||||
core.set_sync_connection_state(SyncConnectionState::SignedIn);
|
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.exists());
|
||||||
assert!(!bearer_path.with_extension("tmp").exists());
|
assert!(!bearer_path.with_extension("tmp").exists());
|
||||||
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedOut);
|
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::create_dir_all(bearer_path.parent().ok_or("missing bearer parent")?)?;
|
||||||
std::fs::write(&bearer_path, "a".repeat(64))?;
|
std::fs::write(&bearer_path, "a".repeat(64))?;
|
||||||
|
|
||||||
assert!(probe_initial_sync_state_at(&mut core, directory.path()));
|
assert!(probe_initial_sync_state_at(&mut core, directory.path(), Some(&profile_id),));
|
||||||
assert!(bearer_path.exists());
|
assert!(!bearer_path.exists());
|
||||||
assert_eq!(core.snapshot()?.sync_status.connection(), &SyncConnectionState::SignedIn);
|
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(())
|
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},
|
time::{SystemTime, UNIX_EPOCH},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use ely_domain::ProfileId;
|
||||||
use ely_sync_client::{
|
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,
|
||||||
@@ -34,6 +35,7 @@ impl SyncEngine {
|
|||||||
/// fresh device identity on first use, then keeps it stable across
|
/// fresh device identity on first use, then keeps it stable across
|
||||||
/// runs so the server's `user_devices` row stays bound.
|
/// runs so the server's `user_devices` row stays bound.
|
||||||
pub fn for_profile_dir(
|
pub fn for_profile_dir(
|
||||||
|
profile_id: &ProfileId,
|
||||||
profile_data_dir: &Path,
|
profile_data_dir: &Path,
|
||||||
device_name: impl Into<String>,
|
device_name: impl Into<String>,
|
||||||
platform: impl Into<String>,
|
platform: impl Into<String>,
|
||||||
@@ -46,7 +48,7 @@ impl SyncEngine {
|
|||||||
.join(".sync-key-locks");
|
.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(sync_dir.join("bearer.token"));
|
let bearer_store = BearerTokenStore::new(profile_id, profile_data_dir);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
api_config: ApiClientConfig::production(),
|
api_config: ApiClientConfig::production(),
|
||||||
bearer_store,
|
bearer_store,
|
||||||
@@ -60,10 +62,6 @@ impl SyncEngine {
|
|||||||
&self.identity
|
&self.identity
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn bearer_path(&self) -> &Path {
|
|
||||||
self.bearer_store.path()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn last_outcome(&self) -> Option<&SyncOutcome> {
|
pub fn last_outcome(&self) -> Option<&SyncOutcome> {
|
||||||
self.last_outcome.as_ref()
|
self.last_outcome.as_ref()
|
||||||
}
|
}
|
||||||
@@ -436,6 +434,7 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String {
|
|||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct SyncEngineBuilder {
|
pub struct SyncEngineBuilder {
|
||||||
|
pub profile_id: ProfileId,
|
||||||
pub profile_data_dir: PathBuf,
|
pub profile_data_dir: PathBuf,
|
||||||
pub device_name: String,
|
pub device_name: String,
|
||||||
pub platform: String,
|
pub platform: String,
|
||||||
@@ -443,7 +442,12 @@ pub struct SyncEngineBuilder {
|
|||||||
|
|
||||||
impl SyncEngineBuilder {
|
impl SyncEngineBuilder {
|
||||||
pub fn build(self) -> Result<SyncEngine, SyncClientError> {
|
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(())
|
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]
|
#[test]
|
||||||
fn sync_object_policy_pauses_object_kind() -> Result<(), Box<dyn Error>> {
|
fn sync_object_policy_pauses_object_kind() -> Result<(), Box<dyn Error>> {
|
||||||
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
pub enum SyncConnectionState {
|
pub enum SyncConnectionState {
|
||||||
SignedOut,
|
SignedOut,
|
||||||
|
CredentialUnavailable { message: String },
|
||||||
SignedIn,
|
SignedIn,
|
||||||
AwaitingDeviceApproval,
|
AwaitingDeviceApproval,
|
||||||
SyncReady { last_synced_at_secs: u64 },
|
SyncReady { last_synced_at_secs: u64 },
|
||||||
@@ -104,7 +105,8 @@ impl SyncStatus {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn new(connection: SyncConnectionState, objects: Vec<SyncObjectStatus>) -> Self {
|
pub fn new(connection: SyncConnectionState, objects: Vec<SyncObjectStatus>) -> Self {
|
||||||
let failed_objects = match &connection {
|
let failed_objects = match &connection {
|
||||||
SyncConnectionState::SyncError { .. } => 1,
|
SyncConnectionState::CredentialUnavailable { .. }
|
||||||
|
| SyncConnectionState::SyncError { .. } => 1,
|
||||||
_ => 0,
|
_ => 0,
|
||||||
};
|
};
|
||||||
Self { connection, pending_objects: 0, failed_objects, objects }
|
Self { connection, pending_objects: 0, failed_objects, objects }
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ rust-version.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
base64.workspace = true
|
base64.workspace = true
|
||||||
|
cap-fs-ext.workspace = true
|
||||||
|
cap-std.workspace = true
|
||||||
chacha20poly1305.workspace = true
|
chacha20poly1305.workspace = true
|
||||||
ed25519-dalek.workspace = true
|
ed25519-dalek.workspace = true
|
||||||
ely_domain = { path = "../ely_domain" }
|
ely_domain = { path = "../ely_domain" }
|
||||||
@@ -15,7 +17,7 @@ getrandom.workspace = true
|
|||||||
hkdf.workspace = true
|
hkdf.workspace = true
|
||||||
hmac.workspace = true
|
hmac.workspace = true
|
||||||
hpke.workspace = true
|
hpke.workspace = true
|
||||||
keyring.workspace = true
|
keyring-core.workspace = true
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
@@ -24,5 +26,20 @@ ureq = { workspace = true, features = ["json"] }
|
|||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
zeroize.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]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
@@ -1,31 +1,56 @@
|
|||||||
use std::{
|
use std::{
|
||||||
fs,
|
fmt,
|
||||||
io::{self, ErrorKind},
|
|
||||||
path::{Path, PathBuf},
|
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
|
use crate::{
|
||||||
/// token grants access to the per-user `withAuthenticatedApiControls`
|
auth_files::{
|
||||||
/// routes and, once the device is bound to the session, to the
|
ensure_migration_marker, migration_marker_exists, open_lock_file, read_legacy_bytes,
|
||||||
/// `withApprovedDeviceApiControls` routes used by sync.
|
remove_file_if_present,
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
},
|
||||||
pub struct BearerToken(String);
|
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 {
|
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> {
|
pub fn new(value: impl Into<String>) -> Result<Self, SyncClientError> {
|
||||||
let value = value.into();
|
let value = Zeroizing::new(value.into());
|
||||||
let trimmed = value.trim();
|
let trimmed = value.trim();
|
||||||
if trimmed.is_empty() || !is_better_auth_bearer(trimmed) {
|
if trimmed.is_empty() || !is_better_auth_bearer(trimmed) {
|
||||||
return Err(SyncClientError::TokenStorage(
|
return Err(storage_error("bearer token is not a Better Auth session token"));
|
||||||
"bearer token is not a Better Auth session token".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Ok(Self(trimmed.to_string()))
|
Ok(Self(Zeroizing::new(trimmed.to_string())))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
@@ -34,7 +59,7 @@ impl BearerToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn is_better_auth_bearer(token: &str) -> bool {
|
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
|
let charset_ok = token
|
||||||
.as_bytes()
|
.as_bytes()
|
||||||
.iter()
|
.iter()
|
||||||
@@ -42,100 +67,238 @@ fn is_better_auth_bearer(token: &str) -> bool {
|
|||||||
length_ok && charset_ok
|
length_ok && charset_ok
|
||||||
}
|
}
|
||||||
|
|
||||||
/// File-backed bearer token store. Lives in the per-profile data
|
trait CredentialBackend {
|
||||||
/// directory so a private window never inherits the standard
|
fn load(&self, service: &str, account: &str) -> Result<Option<Zeroizing<Vec<u8>>>, String>;
|
||||||
/// profile's session — same isolation the rest of the runtime
|
fn save(&self, service: &str, account: &str, secret: &[u8]) -> Result<(), String>;
|
||||||
/// enforces. Writes go through a temp-rename so a partial write
|
fn clear(&self, service: &str, account: &str) -> Result<(), String>;
|
||||||
/// can't corrupt the persisted token.
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct BearerTokenStore {
|
pub struct BearerTokenStore {
|
||||||
path: PathBuf,
|
account: String,
|
||||||
|
lock_path: PathBuf,
|
||||||
|
migration_marker_path: PathBuf,
|
||||||
|
legacy_paths: Vec<PathBuf>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BearerTokenStore {
|
impl BearerTokenStore {
|
||||||
pub fn new(path: PathBuf) -> Self {
|
pub fn new(profile_id: &ProfileId, profile_data_dir: &Path) -> Self {
|
||||||
Self { path }
|
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 {
|
pub fn with_legacy_path(mut self, path: PathBuf) -> Self {
|
||||||
&self.path
|
if !self.legacy_paths.contains(&path) {
|
||||||
|
self.legacy_paths.push(path);
|
||||||
|
}
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load(&self) -> Result<Option<BearerToken>, SyncClientError> {
|
pub fn load(&self) -> Result<Option<BearerToken>, SyncClientError> {
|
||||||
match fs::read_to_string(&self.path) {
|
self.load_with(&NativeCredentialBackend)
|
||||||
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())),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> {
|
pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> {
|
||||||
if let Some(parent) = self.path.parent() {
|
self.save_with(&NativeCredentialBackend, token)
|
||||||
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(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn clear(&self) -> Result<(), SyncClientError> {
|
pub fn clear(&self) -> Result<(), SyncClientError> {
|
||||||
remove_file_if_present(&self.path)?;
|
self.clear_with(&NativeCredentialBackend)
|
||||||
remove_file_if_present(&self.path.with_extension("tmp"))
|
}
|
||||||
|
|
||||||
|
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> {
|
fn load_backend_token<B: CredentialBackend>(
|
||||||
match fs::remove_file(path) {
|
backend: &B,
|
||||||
Ok(()) => Ok(()),
|
account: &str,
|
||||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
) -> Result<Option<BearerToken>, SyncClientError> {
|
||||||
Err(error) => Err(SyncClientError::TokenStorage(error.to_string())),
|
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 {
|
fn read_legacy_token(path: &Path) -> Result<Option<BearerToken>, SyncClientError> {
|
||||||
SyncClientError::TokenStorage(error.to_string())
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
#[path = "auth_tests.rs"]
|
||||||
use super::*;
|
mod tests;
|
||||||
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(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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;
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
|
static NATIVE_STORE: Mutex<Option<Arc<CredentialStore>>> = Mutex::new(None);
|
||||||
|
|
||||||
pub(crate) fn load_secret(
|
pub(crate) fn load_secret(
|
||||||
service: &str,
|
service: &str,
|
||||||
account: &str,
|
account: &str,
|
||||||
) -> Result<Option<Zeroizing<Vec<u8>>>, String> {
|
) -> 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))),
|
Ok(secret) => Ok(Some(Zeroizing::new(secret))),
|
||||||
Err(KeyringError::NoEntry) => Ok(None),
|
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> {
|
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> {
|
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(()),
|
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> {
|
#[cfg(any(
|
||||||
Entry::new(service, account).map_err(|error| error.to_string())
|
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 PUBLIC_KEY_BYTES: usize = 32;
|
||||||
const MAX_DEVICE_TEXT_CHARS: usize = 128;
|
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
|
/// Public device identity persisted in the profile directory. Both private
|
||||||
/// keys live in the macOS data-protection Keychain under `device_id`.
|
/// keys live in the macOS data-protection Keychain under `device_id`.
|
||||||
@@ -33,6 +34,18 @@ pub struct DeviceIdentity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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
|
/// 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
|
/// identity is rotated to a fresh device ID because its private key was
|
||||||
/// never persisted and cannot prove device continuity.
|
/// never persisted and cannot prove device continuity.
|
||||||
@@ -413,6 +426,31 @@ mod tests {
|
|||||||
Ok(())
|
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")]
|
#[cfg(target_os = "macos")]
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
|
fn legacy_identity_rotates_to_new_device_id() -> Result<(), SyncClientError> {
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use ureq::{Agent, AgentBuilder};
|
use ureq::{Agent, AgentBuilder};
|
||||||
|
use zeroize::Zeroizing;
|
||||||
|
|
||||||
use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError};
|
use crate::{auth::BearerToken, client::ApiClientConfig, error::SyncClientError};
|
||||||
|
|
||||||
@@ -86,20 +87,21 @@ pub fn verify_email_otp(
|
|||||||
match response {
|
match response {
|
||||||
Ok(ok) => {
|
Ok(ok) => {
|
||||||
let cookie_token = better_auth_cookie_token(ok.header("set-cookie"));
|
let cookie_token = better_auth_cookie_token(ok.header("set-cookie"));
|
||||||
let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus {
|
let body =
|
||||||
|
Zeroizing::new(ok.into_string().map_err(|error| SyncClientError::HttpStatus {
|
||||||
endpoint: endpoint.clone(),
|
endpoint: endpoint.clone(),
|
||||||
status: 200,
|
status: 200,
|
||||||
body: error.to_string(),
|
body: error.to_string(),
|
||||||
})?;
|
})?);
|
||||||
let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| {
|
let json = serde_json::from_str::<VerifyOtpResponse>(&body).map_err(|error| {
|
||||||
SyncClientError::Json { endpoint: endpoint.clone(), source: 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(
|
SyncClientError::TokenStorage(
|
||||||
"sign-in response did not include a session token".to_string(),
|
"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)) => {
|
Err(ureq::Error::Status(status, raw)) => {
|
||||||
let body = raw.into_string().unwrap_or_default();
|
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
|
/// better-auth.session_token=<token>; …` header. Strip the cookie's
|
||||||
/// attributes and return just the value. Multi-cookie responses are
|
/// attributes and return just the value. Multi-cookie responses are
|
||||||
/// concatenated by `ureq` into a single header line per spec.
|
/// 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?;
|
let header = set_cookie?;
|
||||||
for cookie in header.split(',') {
|
for cookie in header.split(',') {
|
||||||
let trimmed = cookie.trim();
|
let trimmed = cookie.trim();
|
||||||
if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") {
|
if let Some(rest) = trimmed.strip_prefix("better-auth.session_token=") {
|
||||||
let token = rest.split(';').next()?.trim();
|
let token = rest.split(';').next()?.trim();
|
||||||
if !token.is_empty() {
|
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() {
|
fn picks_session_token_out_of_set_cookie_header() {
|
||||||
let header =
|
let header =
|
||||||
"better-auth.session_token=abc.def.ghi; Path=/; HttpOnly; Secure; SameSite=Lax";
|
"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]
|
#[test]
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ pub enum SyncClientError {
|
|||||||
#[error("Bearer token storage is unavailable: {0}")]
|
#[error("Bearer token storage is unavailable: {0}")]
|
||||||
TokenStorage(String),
|
TokenStorage(String),
|
||||||
|
|
||||||
|
#[error("Bearer credential storage is unavailable: {0}")]
|
||||||
|
BearerCredentialStorage(String),
|
||||||
|
|
||||||
#[error("HTTP request failed for {endpoint}: {source}")]
|
#[error("HTTP request failed for {endpoint}: {source}")]
|
||||||
Http {
|
Http {
|
||||||
endpoint: String,
|
endpoint: String,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
//! the bearer token out-of-band and hand it to the client.
|
//! the bearer token out-of-band and hand it to the client.
|
||||||
|
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
mod auth_files;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
mod credential_store;
|
mod credential_store;
|
||||||
pub mod device;
|
pub mod device;
|
||||||
|
|||||||
Reference in New Issue
Block a user