diff --git a/Cargo.toml b/Cargo.toml index a4e682b..0a50608 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/ely_design_system", "crates/ely_domain", "crates/ely_servo_host", + "crates/ely_sync_client", ] default-members = ["crates/ely_app"] resolver = "2" diff --git a/crates/ely_sync_client/Cargo.toml b/crates/ely_sync_client/Cargo.toml new file mode 100644 index 0000000..5be8b37 --- /dev/null +++ b/crates/ely_sync_client/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ely_sync_client" +version = "0.1.0" +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[dependencies] +ely_domain = { path = "../ely_domain" } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +ureq = { workspace = true, features = ["json"] } +uuid = { workspace = true } + +[lints] +workspace = true diff --git a/crates/ely_sync_client/src/auth.rs b/crates/ely_sync_client/src/auth.rs new file mode 100644 index 0000000..3f198ff --- /dev/null +++ b/crates/ely_sync_client/src/auth.rs @@ -0,0 +1,134 @@ +use std::{ + fs, + io::{self, ErrorKind}, + path::{Path, PathBuf}, +}; + +use crate::error::SyncClientError; + +/// Better Auth bearer token issued by `https:///api/auth/*`. The +/// token grants access to the per-user `withAuthenticatedApiControls` +/// routes and, once the device is bound to the session, to the +/// `withApprovedDeviceApiControls` routes used by sync. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct BearerToken(String); + +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) -> Result { + let value = value.into(); + let trimmed = value.trim(); + if trimmed.is_empty() || !is_better_auth_bearer(trimmed) { + return Err(SyncClientError::TokenStorage( + "bearer token is not a Better Auth session token".to_string(), + )); + } + Ok(Self(trimmed.to_string())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +fn is_better_auth_bearer(token: &str) -> bool { + let length_ok = (32..=4096).contains(&token.len()); + let charset_ok = token + .as_bytes() + .iter() + .all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b'~' | b'+' | b'/' | b'=' | b'-')); + length_ok && charset_ok +} + +/// File-backed bearer token store. Lives in the per-profile data +/// directory so a private window never inherits the standard +/// profile's session — same isolation the rest of the runtime +/// enforces. Writes go through a temp-rename so a partial write +/// can't corrupt the persisted token. +#[derive(Clone, Debug)] +pub struct BearerTokenStore { + path: PathBuf, +} + +impl BearerTokenStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + pub fn path(&self) -> &Path { + &self.path + } + + pub fn load(&self) -> Result, SyncClientError> { + match fs::read_to_string(&self.path) { + Ok(contents) => { + if contents.trim().is_empty() { + return Ok(None); + } + BearerToken::new(contents).map(Some) + } + Err(error) if error.kind() == ErrorKind::NotFound => Ok(None), + Err(error) => Err(SyncClientError::TokenStorage(error.to_string())), + } + } + + pub fn save(&self, token: &BearerToken) -> Result<(), SyncClientError> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).map_err(io_err)?; + } + let tmp = self.path.with_extension("tmp"); + fs::write(&tmp, token.as_str()).map_err(io_err)?; + fs::rename(&tmp, &self.path).map_err(io_err)?; + Ok(()) + } + + pub fn clear(&self) -> Result<(), SyncClientError> { + match fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == ErrorKind::NotFound => Ok(()), + Err(error) => Err(SyncClientError::TokenStorage(error.to_string())), + } + } +} + +fn io_err(error: io::Error) -> SyncClientError { + SyncClientError::TokenStorage(error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env::temp_dir; + + #[test] + fn rejects_obviously_broken_tokens() { + assert!(BearerToken::new("").is_err()); + assert!(BearerToken::new(" ").is_err()); + assert!(BearerToken::new("short").is_err()); + // Spaces are not in the Better Auth bearer charset. + assert!(BearerToken::new(format!("{}aaa bb", "a".repeat(40))).is_err()); + } + + #[test] + fn accepts_better_auth_shape() -> Result<(), SyncClientError> { + let token = format!("{}-{}_{}", "a".repeat(20), "b".repeat(20), "c".repeat(20)); + BearerToken::new(token).map(|_| ()) + } + + #[test] + fn token_store_round_trips() -> Result<(), SyncClientError> { + let dir = temp_dir().join(format!("ely-token-{}", uuid::Uuid::now_v7().simple())); + let store = BearerTokenStore::new(dir.join("token")); + let token = BearerToken::new("a".repeat(64))?; + assert_eq!(store.load()?, None); + + store.save(&token)?; + assert_eq!(store.load()?, Some(token.clone())); + + store.clear()?; + assert_eq!(store.load()?, None); + Ok(()) + } +} diff --git a/crates/ely_sync_client/src/client.rs b/crates/ely_sync_client/src/client.rs new file mode 100644 index 0000000..835f902 --- /dev/null +++ b/crates/ely_sync_client/src/client.rs @@ -0,0 +1,190 @@ +use std::time::Duration; + +use serde::de::DeserializeOwned; +use ureq::{Agent, AgentBuilder}; + +use crate::{ + auth::BearerToken, + device::{DeviceIdentity, DeviceListResponse, DeviceRegistration}, + error::SyncClientError, + snapshot::{SnapshotDownload, SnapshotUploadRequest}, +}; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +const USER_AGENT: &str = concat!("ELY Browser/", env!("CARGO_PKG_VERSION")); + +/// Resolved configuration for talking to a worker deployment. +#[derive(Clone, Debug)] +pub struct ApiClientConfig { + base_url: String, + region: String, +} + +impl ApiClientConfig { + /// Build the config used by the production deployment. The base URL + /// matches `wrangler.toml`'s `ELY_AUTH_BASE_URL`. + pub fn production() -> Self { + Self { + base_url: "https://ely-browser-cloud.zhangyanghaha0407.workers.dev".to_string(), + region: "auto".to_string(), + } + } + + pub fn custom(base_url: impl Into, region: impl Into) -> Self { + Self { base_url: base_url.into(), region: region.into() } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn region(&self) -> &str { + &self.region + } +} + +/// Bearer-token authenticated HTTP client targeting `ely-browser-cloud`. +pub struct SyncApiClient { + agent: Agent, + config: ApiClientConfig, + bearer: BearerToken, +} + +impl SyncApiClient { + pub fn new(config: ApiClientConfig, bearer: BearerToken) -> Result { + if !config.base_url.starts_with("https://") && !config.base_url.starts_with("http://") { + return Err(SyncClientError::InvalidBaseUrl { url: config.base_url.clone() }); + } + let agent = AgentBuilder::new().timeout(REQUEST_TIMEOUT).user_agent(USER_AGENT).build(); + Ok(Self { agent, config, bearer }) + } + + pub fn config(&self) -> &ApiClientConfig { + &self.config + } + + /// `POST /api/devices/register` — bind a freshly-generated device + /// identity to the current session. The response carries the + /// canonical device record from the worker's `user_devices` table, + /// including the `approval_status` that gates `/api/sync/*`. + pub fn register_device( + &self, + identity: &DeviceIdentity, + idempotency_key: &str, + ) -> Result { + let registration = DeviceRegistration { + device_id: &identity.device_id, + public_key: &identity.public_key, + device_name: &identity.device_name, + platform: &identity.platform, + idempotency_key, + }; + let endpoint = self.endpoint("/api/devices/register"); + let response = self + .agent + .post(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(®istration).map_err(|error| { + SyncClientError::Json { endpoint: endpoint.clone(), source: error } + })?); + let body = read_json_response::(&endpoint, response)?; + Ok(body) + } + + /// `GET /api/devices` — return every device bound to the user, + /// approved or otherwise. Used by the UI to render the pending + /// device-approval list. + pub fn list_devices(&self) -> Result { + let endpoint = self.endpoint("/api/devices"); + let response = self + .agent + .get(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .call(); + read_json_response::(&endpoint, response) + } + + /// `POST /api/sync/snapshot` — push the full per-user state. The + /// worker enforces logical-clock monotonicity, so callers must + /// pass a value strictly greater than the last accepted snapshot. + pub fn upload_snapshot( + &self, + request: &SnapshotUploadRequest<'_>, + ) -> Result { + let endpoint = self.endpoint("/api/sync/snapshot"); + let response = + self.agent + .post(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .set("Content-Type", "application/json") + .send_json(serde_json::to_value(request).map_err(|error| { + SyncClientError::Json { endpoint: endpoint.clone(), source: error } + })?); + read_json_response::(&endpoint, response) + } + + /// `GET /api/sync/snapshot?snapshot_id=…` — fetch the snapshot for + /// the named id. Returns the encoded payload plus the snapshot + /// metadata; callers should verify the payload hash before trusting + /// the bytes. + pub fn download_snapshot( + &self, + snapshot_id: &str, + ) -> Result { + let endpoint = self.endpoint(&format!("/api/sync/snapshot?snapshot_id={snapshot_id}")); + let response = self + .agent + .get(&endpoint) + .set("Authorization", &format!("Bearer {}", self.bearer.as_str())) + .call(); + read_json_response::(&endpoint, response) + } + + fn endpoint(&self, path: &str) -> String { + format!("{}{}", self.config.base_url.trim_end_matches('/'), path) + } +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct DeviceRecordDocument { + pub version: u32, + pub user_id: String, + pub device: crate::device::DeviceRecord, +} + +#[derive(Clone, Debug, serde::Deserialize)] +pub struct SnapshotUploadDocument { + pub version: u32, + pub user_id: String, + pub device_id: String, + pub snapshot: crate::snapshot::SnapshotDocument, +} + +fn read_json_response( + endpoint: &str, + response: Result, +) -> Result { + match response { + Ok(ok) => { + let status = ok.status(); + let body = ok.into_string().map_err(|error| SyncClientError::HttpStatus { + endpoint: endpoint.to_string(), + status, + body: error.to_string(), + })?; + serde_json::from_str::(&body).map_err(|error| SyncClientError::Json { + endpoint: endpoint.to_string(), + source: error, + }) + } + Err(ureq::Error::Status(status, raw)) => { + let body = raw.into_string().unwrap_or_default(); + Err(SyncClientError::HttpStatus { endpoint: endpoint.to_string(), status, body }) + } + Err(other) => { + Err(SyncClientError::Http { endpoint: endpoint.to_string(), source: Box::new(other) }) + } + } +} diff --git a/crates/ely_sync_client/src/device.rs b/crates/ely_sync_client/src/device.rs new file mode 100644 index 0000000..b363697 --- /dev/null +++ b/crates/ely_sync_client/src/device.rs @@ -0,0 +1,155 @@ +use std::{ + fs, + io::{self, ErrorKind}, + path::Path, +}; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::error::SyncClientError; + +/// Locally-stable device identity. Constructed once per profile data +/// directory and persisted so reinstalls don't trigger re-approval +/// requests — the same `device_id` is reused across runs. +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct DeviceIdentity { + pub device_id: String, + pub public_key: String, + pub device_name: String, + pub platform: String, +} + +impl DeviceIdentity { + /// Load the persisted identity, or create-and-save a new one. + /// The identity file lives at `path`; callers usually pick + /// `/sync/device.json`. + pub fn load_or_create( + path: &Path, + device_name: impl Into, + platform: impl Into, + ) -> Result { + match fs::read_to_string(path) { + Ok(contents) => { + let identity: Self = serde_json::from_str(&contents).map_err(|error| { + SyncClientError::TokenStorage(format!( + "device identity is corrupt at {}: {error}", + path.display() + )) + })?; + identity.validate()?; + Ok(identity) + } + Err(error) if error.kind() == ErrorKind::NotFound => { + let identity = Self::generate(device_name, platform); + identity.save(path)?; + Ok(identity) + } + Err(error) => Err(SyncClientError::TokenStorage(error.to_string())), + } + } + + pub fn generate(device_name: impl Into, platform: impl Into) -> Self { + let device_id = format!("ely-{}", Uuid::now_v7().simple()); + // Placeholder public key — Ed25519 device-bound signing is a + // backend feature still in design. The worker validates the + // shape but does not currently challenge it. + let public_key = format!("ed25519:{}", Uuid::now_v7().simple()); + Self { device_id, public_key, device_name: device_name.into(), platform: platform.into() } + } + + pub fn save(&self, path: &Path) -> Result<(), SyncClientError> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(io_err)?; + } + let tmp = path.with_extension("tmp"); + let serialized = serde_json::to_string_pretty(self).map_err(|error| { + SyncClientError::TokenStorage(format!("device identity serialize: {error}")) + })?; + fs::write(&tmp, serialized).map_err(io_err)?; + fs::rename(&tmp, path).map_err(io_err)?; + Ok(()) + } + + fn validate(&self) -> Result<(), SyncClientError> { + if !is_device_id_shape(&self.device_id) { + return Err(SyncClientError::TokenStorage( + "device_id does not match the Cloudflare worker pattern".to_string(), + )); + } + if self.public_key.trim().is_empty() { + return Err(SyncClientError::TokenStorage("device public_key is empty".to_string())); + } + if self.device_name.trim().is_empty() { + return Err(SyncClientError::TokenStorage("device_name is empty".to_string())); + } + if self.platform.trim().is_empty() { + return Err(SyncClientError::TokenStorage("platform is empty".to_string())); + } + Ok(()) + } +} + +fn is_device_id_shape(value: &str) -> bool { + (3..=128).contains(&value.len()) + && value + .as_bytes() + .iter() + .all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'.' | b'_' | b':' | b'-')) +} + +fn io_err(error: io::Error) -> SyncClientError { + SyncClientError::TokenStorage(error.to_string()) +} + +#[derive(Clone, Debug, Serialize)] +pub struct DeviceRegistration<'a> { + pub device_id: &'a str, + pub public_key: &'a str, + pub device_name: &'a str, + pub platform: &'a str, + pub idempotency_key: &'a str, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct DeviceListResponse { + pub version: u32, + pub user_id: String, + pub devices: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct DeviceRecord { + pub device_id: String, + pub device_name: String, + pub platform: String, + pub approval_status: String, + pub created_at: u64, + pub approved_at: Option, + pub last_active_at: Option, + pub revoked_at: Option, +} + +impl DeviceRecord { + pub fn is_approved(&self) -> bool { + self.approval_status == "approved" && self.revoked_at.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env::temp_dir; + + #[test] + fn identity_round_trips() -> Result<(), SyncClientError> { + let dir = temp_dir().join(format!("ely-device-{}", Uuid::now_v7().simple())); + let path = dir.join("device.json"); + let identity = DeviceIdentity::load_or_create(&path, "Test", "macos")?; + identity.validate()?; + + let again = DeviceIdentity::load_or_create(&path, "ignored", "ignored")?; + assert_eq!(identity, again); + Ok(()) + } +} diff --git a/crates/ely_sync_client/src/error.rs b/crates/ely_sync_client/src/error.rs new file mode 100644 index 0000000..3885884 --- /dev/null +++ b/crates/ely_sync_client/src/error.rs @@ -0,0 +1,33 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SyncClientError { + #[error("API base URL is invalid: {url}")] + InvalidBaseUrl { url: String }, + + #[error("Bearer token storage is unavailable: {0}")] + TokenStorage(String), + + #[error("HTTP request failed for {endpoint}: {source}")] + Http { + endpoint: String, + #[source] + source: Box, + }, + + #[error("HTTP {status} {endpoint}: {body}")] + HttpStatus { endpoint: String, status: u16, body: String }, + + #[error("JSON parsing failed for {endpoint}: {source}")] + Json { + endpoint: String, + #[source] + source: serde_json::Error, + }, + + #[error("Snapshot payload is too large: {bytes} bytes (max {limit})")] + SnapshotTooLarge { bytes: usize, limit: usize }, + + #[error("Snapshot base64 decode failed: {0}")] + SnapshotBase64(String), +} diff --git a/crates/ely_sync_client/src/lib.rs b/crates/ely_sync_client/src/lib.rs new file mode 100644 index 0000000..d193278 --- /dev/null +++ b/crates/ely_sync_client/src/lib.rs @@ -0,0 +1,37 @@ +//! HTTP client for the `ely-browser-cloud` Cloudflare worker. +//! +//! The worker exposes the device-bound sync API documented in +//! `cloudflare/src/sync_*.ts`. This crate is the renderer-side counterpart: +//! it owns the Better Auth bearer token, the locally-generated device +//! identity, and the JSON wire types needed to push and pull the user's +//! browser state. +//! +//! Scope today: +//! - Bearer-token authenticated requests via `ureq`. +//! - Device registration (`POST /api/devices/register`) and listing +//! (`GET /api/devices`). +//! - Sync snapshot upload (`POST /api/sync/snapshot`) and download +//! (`GET /api/sync/snapshot?snapshot_id=…`). +//! +//! Intentionally omitted (kept for follow-up work, not papered over here): +//! - The full Better Auth handshake (email + OTP / OAuth). Callers obtain +//! the bearer token out-of-band and hand it to the client. +//! - First-device approval bootstrap. The Cloudflare API rejects sync from +//! an unapproved device; the user must approve a freshly-registered +//! device from another already-approved device (or via direct D1 +//! operation), exactly as the backend enforces. +//! - Incremental change-log push/pull (`/api/sync/push` and `/api/sync/pull`). +//! The snapshot path is the simplest contract that round-trips the user's +//! entire state, so we start there. + +pub mod auth; +pub mod client; +pub mod device; +pub mod error; +pub mod snapshot; + +pub use auth::{BearerToken, BearerTokenStore}; +pub use client::{ApiClientConfig, SyncApiClient}; +pub use device::{DeviceIdentity, DeviceListResponse, DeviceRecord, DeviceRegistration}; +pub use error::SyncClientError; +pub use snapshot::{SnapshotDownload, SnapshotPayload, SnapshotUploadRequest}; diff --git a/crates/ely_sync_client/src/snapshot.rs b/crates/ely_sync_client/src/snapshot.rs new file mode 100644 index 0000000..e008665 --- /dev/null +++ b/crates/ely_sync_client/src/snapshot.rs @@ -0,0 +1,227 @@ +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::error::SyncClientError; + +/// Hard cap from `cloudflare/src/sync_snapshot.ts`: a single snapshot +/// upload may not exceed 10 MiB. We enforce the same limit client-side +/// so the user sees a typed error instead of a generic HTTP 400. +pub const MAX_SNAPSHOT_BYTES: usize = 10 * 1024 * 1024; + +/// Validated snapshot payload, ready to ship to `/api/sync/snapshot`. +#[derive(Clone, Debug)] +pub struct SnapshotPayload { + bytes: Vec, + payload_hash: String, +} + +impl SnapshotPayload { + /// Wrap `bytes` and pre-compute the SHA-256 the worker validates + /// before persisting the R2 object. Rejects payloads above the + /// 10 MiB limit so the caller sees the failure before any + /// network IO. + pub fn new(bytes: Vec) -> Result { + if bytes.is_empty() { + return Err(SyncClientError::SnapshotTooLarge { bytes: 0, limit: MAX_SNAPSHOT_BYTES }); + } + if bytes.len() > MAX_SNAPSHOT_BYTES { + return Err(SyncClientError::SnapshotTooLarge { + bytes: bytes.len(), + limit: MAX_SNAPSHOT_BYTES, + }); + } + let mut hasher = Sha256::new(); + hasher.update(&bytes); + let digest = hasher.finalize(); + let payload_hash = digest.iter().map(|byte| format!("{byte:02x}")).collect::(); + Ok(Self { bytes, payload_hash }) + } + + pub fn bytes(&self) -> &[u8] { + &self.bytes + } + + pub fn payload_hash(&self) -> &str { + &self.payload_hash + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct SnapshotUploadRequest<'a> { + pub version: u32, + pub snapshot_id: &'a str, + pub region: &'a str, + pub payload_hash: &'a str, + pub schema_rev: u32, + pub logical_clock: u64, + pub data_base64: String, +} + +impl<'a> SnapshotUploadRequest<'a> { + pub fn new( + snapshot_id: &'a str, + region: &'a str, + schema_rev: u32, + logical_clock: u64, + payload: &'a SnapshotPayload, + ) -> Self { + Self { + version: 1, + snapshot_id, + region, + payload_hash: payload.payload_hash(), + schema_rev, + logical_clock, + data_base64: encode_base64(payload.bytes()), + } + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SnapshotDownload { + pub version: u32, + pub user_id: String, + pub device_id: String, + pub snapshot: SnapshotDocument, + pub data_base64: String, +} + +impl SnapshotDownload { + /// Decode + hash-verify the returned base64 payload against the + /// snapshot's advertised `payload_hash`. Mirrors the contract the + /// worker enforces on upload — we re-check on download so a + /// tampered storage layer doesn't silently desync the user. + pub fn payload(&self) -> Result { + let bytes = decode_base64(&self.data_base64) + .map_err(|error| SyncClientError::SnapshotBase64(error.to_string()))?; + let payload = SnapshotPayload::new(bytes)?; + if payload.payload_hash() != self.snapshot.payload_hash { + return Err(SyncClientError::SnapshotBase64( + "downloaded snapshot hash does not match server-advertised hash".to_string(), + )); + } + Ok(payload) + } +} + +#[derive(Clone, Debug, Deserialize)] +pub struct SnapshotDocument { + pub snapshot_id: String, + pub r2_key: String, + pub payload_hash: String, + pub schema_rev: u32, + pub logical_clock: u64, + pub device_id: String, + pub size_bytes: u64, + pub created_at: u64, +} + +const BASE64_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +fn encode_base64(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let len = chunk.len(); + let b0 = chunk[0]; + let b1 = if len > 1 { chunk[1] } else { 0 }; + let b2 = if len > 2 { chunk[2] } else { 0 }; + out.push(BASE64_CHARS[(b0 >> 2) as usize] as char); + out.push(BASE64_CHARS[(((b0 & 0b11) << 4) | (b1 >> 4)) as usize] as char); + out.push(if len > 1 { + BASE64_CHARS[(((b1 & 0b1111) << 2) | (b2 >> 6)) as usize] as char + } else { + '=' + }); + out.push(if len > 2 { BASE64_CHARS[(b2 & 0b111111) as usize] as char } else { '=' }); + } + out +} + +#[derive(Debug)] +pub(crate) struct Base64DecodeError(pub String); + +impl std::fmt::Display for Base64DecodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +fn decode_base64(value: &str) -> Result, Base64DecodeError> { + let trimmed = value.trim(); + if !trimmed.len().is_multiple_of(4) { + return Err(Base64DecodeError("input length is not a multiple of 4".to_string())); + } + let mut out = Vec::with_capacity(trimmed.len() / 4 * 3); + let mut buffer = [0u32; 4]; + let mut buffer_len = 0; + let mut padding = 0; + for byte in trimmed.bytes() { + let value = match byte { + b'A'..=b'Z' => u32::from(byte - b'A'), + b'a'..=b'z' => u32::from(byte - b'a') + 26, + b'0'..=b'9' => u32::from(byte - b'0') + 52, + b'+' => 62, + b'/' => 63, + b'=' => { + padding += 1; + if padding > 2 { + return Err(Base64DecodeError("excess padding".to_string())); + } + 0 + } + _ => return Err(Base64DecodeError(format!("unexpected byte {byte:#x}"))), + }; + buffer[buffer_len] = value; + buffer_len += 1; + if buffer_len == 4 { + let combined = (buffer[0] << 18) | (buffer[1] << 12) | (buffer[2] << 6) | buffer[3]; + out.push(((combined >> 16) & 0xFF) as u8); + if padding < 2 { + out.push(((combined >> 8) & 0xFF) as u8); + } + if padding < 1 { + out.push((combined & 0xFF) as u8); + } + buffer_len = 0; + padding = 0; + } + } + if buffer_len != 0 { + return Err(Base64DecodeError("trailing partial quartet".to_string())); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_rejects_empty_and_oversized() { + assert!(SnapshotPayload::new(vec![]).is_err()); + let too_big = vec![0u8; MAX_SNAPSHOT_BYTES + 1]; + assert!(SnapshotPayload::new(too_big).is_err()); + } + + #[test] + fn payload_hash_matches_sha256() -> Result<(), SyncClientError> { + let payload = SnapshotPayload::new(b"ely".to_vec())?; + // Real SHA-256 of "ely" — verified at test time so a future + // hashing regression is caught immediately. + assert_eq!( + payload.payload_hash(), + "52fe440ae87b395cc46438ee9275fcadc0f98a048161345f53d6b0038976a6f5" + ); + Ok(()) + } + + #[test] + fn base64_round_trip() -> Result<(), Base64DecodeError> { + for sample in [b"".as_slice(), b"a", b"ab", b"abc", b"ely browser"] { + let encoded = encode_base64(sample); + let decoded = decode_base64(&encoded)?; + assert_eq!(decoded.as_slice(), sample); + } + Ok(()) + } +}