diff --git a/docs/encrypted-cloud-sync-limited-scope.md b/docs/encrypted-cloud-sync-limited-scope.md new file mode 100644 index 000000000..122898874 --- /dev/null +++ b/docs/encrypted-cloud-sync-limited-scope.md @@ -0,0 +1,164 @@ +# Opt-in limited sync domain (SYNC-01/02/04/07/08) + +Status: **local domain/store seam implemented; remote synchronization blocked by the current singleton-vault contract.** This is not iOS, cloud deployment, platform-vault, two-device, or runtime acceptance. No Host/Swift changes are included. + +## Development checkpoint (2026-10-23) + +This unfinished experiment was moved onto Beta `60f67a9b` for a Draft PR. The transfer includes only the limited-sync domain/store implementation, its tests, and this document. Latest Beta local-migration storage and document parsing APIs are retained. Translation-target changes already live in the iOS Core patch and are not copied from the old worktree. + +Remote collection negotiation, authenticated transport, the iOS secure-vault adapter, confirmation metadata/UI, and real two-device acceptance remain unfinished. iOS continues to report `sync_scope_unavailable`; this branch does not enable remote synchronization. Keep the PR in Draft; do not merge this checkpoint. + +### Validation after the Beta transfer + +- `rustup run 1.99.0 cargo test --manifest-path openless-all/app/Cargo.toml -p openless-core --locked --offline --lib cloud_sync_e2ee --no-fail-fast`: compilation succeeded; **200 passed, 6 failed, 1201 filtered out**. +- All six failures occur in the limited-store tests at the shared `seeded()` fixture's full-store restore, with `RestoreRolledBack`, before the limited operation under test. Investigate compatibility with the current Beta restore/fixture contract; do not treat the original worktree's green result as validation of this branch. +- `python3 openless-all/ios/scripts/prepare-core.py`: passed; the existing isolated iOS Core patch still applies. +- `git diff --cached --check`: passed. +- No iOS device build, remote sync, real user-data restore, or two-device acceptance was performed for this checkpoint. + +## Unchanged default + +The desktop `EncryptedSyncConfig`, full service, consent `encrypted-full-snapshot-v1`, v1 wire DTOs/crypto, and full exporter retain their existing behavior. The only production change on the desktop construction path factors the same local-store composition into a shared helper; its root remains `encrypted-sync`. Full export still includes provider credentials, channels, private prompts, history and activity, as before. **Do not enable that service for the limited iOS requirement.** + +`AllowedSyncScope::default()` is `FullSnapshot`. Constructing the limited facade without the explicit `ConfirmedVocabularyModeStyleReferences` permission fails; it never falls back to full export. + +## Public contracts + +Existing public modules export these seams (no root `lib.rs` edit is needed): + +- `cloud_sync_e2ee::{LimitedSyncStore, LimitedSyncExport, SyncAccountProvider, VerifiedSyncAccount}`. +- `cloud_sync_e2ee_documents::{AllowedSyncScope, ConfirmedVocabularySelection, LimitedSyncDocuments, LimitedMergePreview, LIMITED_SYNC_NAMESPACE}`. +- The document module also re-exports its existing public argument types `Revision`, `SourceDevice`, `DocumentSet`, `LogicalDocument`, `Tombstone`, `DocumentKind`; callers no longer need inaccessible protocol-module paths. + +`MarketplaceService::verified_sync_account().await -> Result` reuses the existing verified `/user` identity routine. `github_id() -> &str` returns the canonical positive numeric ID. Serialization is only `{"githubId":"42"}`. There is no public constructor/deserializer, login authority, token accessor, or full-sync enablement. Every call rechecks the stored login token; previously returned values are immutable observations, not cached authorization. + +`SyncAccountProvider::verified_sync_account(&self) -> BoxFuture<'_, Result>` is implemented by `MarketplaceService`. Hosts normally inject the existing `Arc`, or an authority adapter delegating to it. The limited facade verifies the current account before/after capture and before restore. Host account-switch/sign-out orchestration must serialize/cancel its own in-flight operations; this domain facade does not replace that lifecycle. + +`LimitedSyncStore::build(config: EncryptedSyncConfig, data_dir: &Path, repositories: BackendRepositories, credentials: Arc, tasks: Arc, allowed: AllowedSyncScope, accounts: Arc) -> Result` composes **actual Core** local encryption, write gate, protected extension storage and journal backend without constructing the full service or starting network/automatic synchronization. It uses `encrypted-sync-limited-v1`, with its own durable device ID and hence distinct local key AAD, extension and journal identity. A Host selects one policy per repository instance: activity and credential stores bind the exact gate/device identity. Running both factories on the same already-bound repository is not an automatic migration path. Missing device IDs over existing protected ciphertext fail closed via the existing loader. Lost generation-state detection also checks the retained limited root; loss of the shared gate directory cannot silently reset the counter over limited protected state. + +`LimitedSyncStore::new(CoreSyncStore, AllowedSyncScope, Arc) -> DocumentResult` is the custom composition seam. Its injected store must have a dedicated limited-policy local device identity, not the full service's identity. Prefer `build`. + +Other facade methods: + +- `generation() -> DocumentResult`; `device() -> SourceDevice`. +- `bind_runtime_idle_probe(...)`, `bind_runtime_effects(...)`, `is_recovering()`, `recover_registered().await`: delegate to existing Core fences/recovery. Bind real runtime adapters before recovery/restore. +- `export(scope: SyncScope, confirmed: ConfirmedVocabularySelection).await -> DocumentResult`. +- `restore(desired: LimitedSyncDocuments, context: RestoreContext, confirmed: ConfirmedVocabularySelection).await -> DocumentResult`. +- `LimitedSyncExport { generation: Revision, documents: LimitedSyncDocuments }` contains no full snapshot accessor. + +### Confirmation is explicit, not inferred + +There is currently no universal confirmation bit in `DictionaryEntry`. In particular, the exact `LEARNED_VOCAB_NOTE` marker identifies auto-collected provenance, **not user consent**. `ConfirmedVocabularySelection::new(scope, generation, Vec)` takes the Host's explicit confirmed IDs; an empty list means none, never all. It rejects duplicate/invalid IDs and binds the exact origin/account/vault/key/device plus generation. Export/restore reject stale scope/generation (`StalePreview`) and selected IDs missing from that coherent local image (`SourceChanged`). After a local deletion, the Host supplies the remaining live confirmed IDs and materializes deletion marks from the separately protected limited baseline. + +**Required parent/Host work:** durable account/scope-bound confirmation metadata, confirmation UI, atomic/recoverable reconciliation of that metadata after import/deletion, and protected limited baseline persistence. Supplying IDs to this API alone is not completed confirmed-vocabulary synchronization. No ledger is invented in this component. + +## Limited plaintext domain format + +`LIMITED_SYNC_NAMESPACE = "confirmed-vocabulary-mode-style-references-v1"`. + +`LimitedSyncDocuments::to_secret_json() -> DocumentResult>>` emits a bounded **plaintext domain envelope for future authenticated encryption, never an HTTP request body**: + +```json +{ + "manifest": { + "schemaVersion": 1, + "namespace": "confirmed-vocabulary-mode-style-references-v1", + "serviceOrigin": "https://sync.example.test", + "ownerGithubId": "42", + "vaultId": "", + "keyId": "" + }, + "documents": { + "schemaVersion": 1, + "exportedAt": "", + "sourceDevice": {"id":"...","os":"...","arch":"...","appVersion":"..."}, + "documents": [ + {"kind":"dictionary","id":"word-id","schemaVersion":1, + "value":{"id":"word-id","phrase":"confirmed word","enabled":true,"createdAt":"...","sortIndex":0}}, + {"kind":"preferences","id":"defaultMode","schemaVersion":1,"value":"raw"}, + {"kind":"preferences","id":"enabledModes","schemaVersion":1,"value":["raw","light"]}, + {"kind":"preferences","id":"marketStyleReference","schemaVersion":1, + "value":{"id":"builtin.light","version":"2.0.0","originPackId":null}} + ], + "tombstones": [] + } +} +``` + +`marketStyleReference` is null for explicit no-selection, otherwise installed ID/version/optional upstream marketplace `originPackId`. It never carries prompts, examples, icons, model/provider/channel settings, author login, or style contents. No translation-target/private preference expansion is implied. The mode enum is the existing Core enum; duplicate mode entries are rejected. + +The dictionary whitelist is exactly ID, phrase, enabled, createdAt, sortIndex. Notes (including the learned marker), usage counts, and unconfirmed rows are excluded. New remote rows get `note = null`, `hits = 0`; matching local IDs retain their receiver-local note/count. Thus learned provenance is not transferred as implied confirmation; Host metadata integration remains required. Phrase equality across different stable IDs does not silently deduplicate or discard a user's row; existing stable-ID merge semantics remain authoritative. + +`LimitedSyncDocuments::from_authenticated_json(bytes, &scope, observed_revision)` is **post-authenticated-decryption** input, not a decryption/authentication API. The expected scope and revision must come from the future authenticated transport, never the untrusted envelope. Strict duplicate-key/depth/size decoding and manifest equality precede validation. Wrong namespace/schema/origin/account/vault/key and unknown envelope fields fail; all other document kinds, dictionary extension fields, private preferences and preference tombstones fail. Full `DocumentSet` input is not accepted as a limited envelope, nor is the limited envelope accepted by the current full protocol decoder. No migration/downgrade is automatic. + +## Merge, apply, and recovery semantics + +- `local.diff(optional_baseline, remote)` delegates to the existing Core three-way logical merge and returns `LimitedMergePreview` with redacted conflicts. It rejects differing manifests (including account/key); key-rotation baseline migration is not silently guessed. +- `preview.conflicts()` and `preview.resolve(&[ConflictChoice])` preserve the existing explicit-choice rules: stable IDs, no timestamp winner, edit/edit and delete/edit conflicts, missing tombstones rejected. `record_local_deletions(&baseline, deleted_at)` delegates to the existing tombstone ledger, scoped only to previously confirmed limited IDs. Full-snapshot tombstones are never treated as confirmation evidence. +- Restore overlays the selected dictionary IDs and exactly `defaultMode`, `enabledModes`, `activeStylePackId` onto a coherent **local-only full before image**. Every unselected local row and all unrelated preferences/history/activity/styles/channels/credentials are retained. A live remote ID or delete mark colliding with an unselected local row fails before writes. A selected local row omitted without an explicit remote tombstone fails `MissingTombstone` rather than being silently deleted. +- Style selection requires an installed, enabled pack with matching ID/version/origin reference. Missing/mismatched/disabled packs fail `InvalidReference` before writes; the Host may offer the existing marketplace download/verification and then capture a fresh preview. No fallback pack, provider switch, or raw prompt import occurs. This is reference/version identity, not a content-hash guarantee for locally edited packages. +- The final full local image uses existing `CoreSyncStore::restore_scope`: preflight, exact generation, runtime fence, exclusive shared gate, encrypted before/after journal, real runtime effects, complete read-back verification, rollback and startup recovery. It does not perform a sequence of independent settings saves. All settings outside the three whitelisted keys are preserved; the local full image **never becomes a limited remote payload**. +- An intervening ordinary mutation fails the preview generation check (and the existing restore lease checks again after overlay). Once restoring, normal repository/credential mutation permits are blocked. Partial credential/effect failure rolls back; rollback failure retains recovery-required and encrypted material. Wrong recovery keys cannot replay or clear the pending journal. + +## iOS secure-vault requirements (not implemented here) + +Even a limited restore journals/reapplies a complete **local** coherent before/after image to preserve unrelated data. Therefore the actual iOS `CredentialStore` must implement the existing fail-closed hooks, not enable full remote sync or return empty credentials: + +1. `bind_sync_gate`: retain the exact shared gate; reject rebinding to another gate. Every ordinary credential/channel mutation must hold and complete its registered mutation permit through actual asynchronous persistence, including uncertain results. +2. `export_sync_credentials_readonly`: one atomic vault/channel batch; missing/denied vault reads are errors. Core discards a mixed generation rather than exporting it. +3. `export_sync_credentials(&ExclusivePermit)` and `replace_sync_credentials(SyncCredentials, &ExclusivePermit)`: verify permit ownership, operate on the complete registered provider/channel batch, preserve unrelated OAuth/application entries, and surface partial/uncertain failure for the existing rollback/read-back path. Never return success from a no-op implementation. +4. `read_sync_secret`, `write_sync_secret`, `remove_sync_secret`: real app-private Keychain handling for the existing restricted `SyncSecretAccount` names. Local wrapping keys and remembered keys stay out of preference files, keyboard/shared containers, plaintext logs, backups-as-config, and remote payloads. Existing ciphertext with a missing key remains recovery-required; never generate a replacement over it. +5. Bind real runtime idle/effects adapters, recover before ordinary writes/recording, and keep account switch/sign-out serialized with in-flight Host operations. A platform-vault test and real device lifecycle verification remain separate acceptance gates. + +## Why no remote sync method is exposed + +Evidence in current client contract: + +- `cloud_sync_e2ee_protocol/transport.rs` hardcodes metadata `/v1/me/vault`, snapshot `/v1/me/vault/snapshot`, delete `/v1/me/vault`, receipts `/v1/me/operations/{id}`. There is no named collection parameter/header. +- `types.rs` makes `Capabilities` and `SnapshotUpload` strict unknown-field-rejecting DTOs. Snapshot `payloadSchemaVersion` is fixed at 1; capability negotiation does not advertise namespaces. Opaque ciphertext is still stored under the one vault/CAS identity. +- Local mock test `v1_contract_has_a_singleton_vault_and_no_named_scope_capability` records the actual singleton request and rejects an unnegotiated `collections` extension before auth. Existing mock upload/crypto tests establish v1 CAS/receipt/opaque encrypted-frame client behavior, **not** an actual server's acceptance of this new envelope. + +Therefore sending a limited encrypted payload to the current endpoint could replace the desktop full snapshot. A new consent string or encrypted manifest alone does not isolate that storage. This component deliberately has **no limited upload/sync-now/auto-sync path or success status**, and does not call the full service as a substitute. No real cloud was contacted and no server deployment is claimed. + +### Exact server/protocol work required + +A separately negotiated collection contract (e.g. a new v2 endpoint family) must specify all of: + +- Explicit namespace/schema advertisement and request selection; never default an unknown namespace to full. +- Account + namespace isolated vault allocation, metadata, CAS revision/ETag, snapshot create/read/replace, password rotation, delete and retention; existing full routes and remote data unchanged. +- Namespace-bound operations/idempotency keys/receipts and retries; a receipt from a different collection must not reconcile a pending operation. +- Namespace/schema bound into authenticated crypto context and the encrypted manifest; reuse the actual Core crypto algorithms/framing/auth/transport/recovery machinery, with vectors and wrong-scope tests. No ad-hoc new crypto. +- Distinct local baseline/pending/key/device bindings. Standard limited local construction already separates its root/device identity; the full baseline must never be reused as a limited confirmation/deletion baseline. Define key-rotation and any user-approved migration explicitly. +- Local mock contracts proving independent full and limited snapshots, conflicts, delete/rotation/unknown outcomes and rejection of wrong scope/account/key. Then independent deployment and real two-device/platform acceptance. + +The endpoint example is a required design shape, **not an invented claim that a server implements it**. Backend ownership/negotiation is still external to this component. + +## Parent/root integration and desktop opt-in + +- No shared root entry patch is needed for Core `lib.rs`: existing modules are public. Integrate only the changed E2EE files and new scope document; do not replace inherited dirty `credentials.rs`, `lib.rs`, desktop, Android or iOS files from this checkout. +- Optional documentation entry, parent-owned: append a link from `docs/encrypted-cloud-sync.md` to this file, explicitly labeled domain-only / remote blocked. +- Parent-owned T integration: review/register `translation_target_languages` for the existing full policy and exclude serde-skipped `translation_target_languages_present` from the persistent-field coverage scanner. Do not serialize the marker or add either field to this limited whitelist. T files are not copied or modified here; this checkout's scoped test result predates their integration. +- Do not set the existing backend `encrypted_sync` option to enable iOS limited sync. The account facade can be consumed independently now; limited local domain composition additionally requires the real vault/effects/confirmation seams above. +- A future desktop opt-in consumer must explicitly choose the limited policy/factory instead of the full factory for that repository lifetime, keep its limited confirmed ledger/baseline separate, and negotiate the new namespace. Existing desktop users remain on the full default without wire/local-store migration. + +## Remaining domain limitations + +The minimal adapter deliberately captures the existing complete coherent local image before projecting the whitelist. Thus excluded-store readability, existing full-capture size limits, real vault hooks and platform effects are still prerequisites; an oversized/unreadable history can block limited capture rather than being silently truncated or bypassed. Narrow read-only capture optimization is not included. This does not export the excluded stores. + +The standard factories are alternatives, not simultaneous full/limited services on one already-bound repository. There is no automatic migration of an active full local store/journal, and no transport/key-rotation baseline migration. The custom `new` seam requires the caller to respect the dedicated-identity contract. Imported confirmation/provenance metadata and account lifecycle still require parent/Host integration. None of these gaps is represented as cloud success. + +## Validation boundary + +Tests use temporary repositories, memory/test vaults, real local Core encryption/journals and loopback protocol/account servers. They do not use user data, real cloud, microphone, simulator, permissions, or immutable dependency inputs. Test/build success is not product/runtime acceptance; the parent owns integration, independent review and final acceptance. + +Historical validation in the source worktree, before the Beta transfer (not the result for this branch): + +```sh +RUSTC="$(rustup which --toolchain 1.98.0 rustc)" \ +RUSTDOC="$(rustup which --toolchain 1.98.0 rustdoc)" \ +CARGO_TARGET_DIR="$PWD/target/scoped-sync" \ +cargo test --manifest-path openless-all/app/Cargo.toml -p openless-core \ + --locked --offline --lib cloud_sync_e2ee --no-fail-fast +``` + +Result: **181 passed, 0 failed, 0 ignored; 997 unrelated tests filtered**. Includes 14 new source-named tests across limited document/store tests, numeric identity, singleton protocol mock and retained-limited-root lost-counter regression. Existing full default service/crypto/vector/transport/restore tests remain green. Separate `cargo test --doc` passed with **0 doctests** (not extra behavioral coverage). `git diff --check` and empty staged diff passed. Full unfiltered Core/Host/iOS and the combined T registration seam were not validated in this lane. diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs index f5ce14cf0..78b87ff9b 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/adapter.rs @@ -29,6 +29,37 @@ pub(crate) fn build( events: crate::events::BackendEventPublisher, tasks: Arc, ) -> SyncResult<(super::EncryptedSyncService, Arc)> { + let credential_store_for_service = credentials.clone(); + let (origin, local, store) = build_store( + config, + data_dir, + repositories, + credentials, + tasks, + "encrypted-sync", + )?; + let service = super::EncryptedSyncService::new( + super::SyncServiceConfig { + origin, + github_client_id, + }, + marketplace, + local, + store.clone(), + credential_store_for_service, + events, + ); + Ok((service, store)) +} + +pub(super) fn build_store( + config: super::EncryptedSyncConfig, + data_dir: &Path, + repositories: crate::BackendRepositories, + credentials: Arc, + tasks: Arc, + root_name: &str, +) -> SyncResult<(String, LocalStorage, Arc)> { let origin = url::Url::parse(&config.service_origin) .map_err(|_| super::error("unsupported_protocol"))?; if origin.scheme() != "https" @@ -42,7 +73,7 @@ pub(crate) fn build( return Err(super::error("unsupported_protocol")); } let origin = origin.origin().ascii_serialization(); - let root = data_dir.join("encrypted-sync"); + let root = data_dir.join(root_name); log::error!( "[e2ee-adapter] build start data_dir={} root={}", data_dir.display(), @@ -80,7 +111,6 @@ pub(crate) fn build( credentials.clone(), ); log::error!("[e2ee-adapter] stage=store_new"); - let credential_store_for_service = credentials.clone(); let store = Arc::new( CoreSyncStore::new( repositories, @@ -97,18 +127,7 @@ pub(crate) fn build( })?, ); log::error!("[e2ee-adapter] stage=store_ok"); - let service = super::EncryptedSyncService::new( - super::SyncServiceConfig { - origin, - github_client_id, - }, - marketplace, - local, - store.clone(), - credential_store_for_service, - events, - ); - Ok((service, store)) + Ok((origin, local, store)) } /// Build the native document store without requiring a configured sync account. diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/limited.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/limited.rs new file mode 100644 index 000000000..cbb2de793 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/limited.rs @@ -0,0 +1,194 @@ +//! Narrow Host injection seam for opt-in limited domain data. Deliberately has +//! no upload method: the existing remote protocol exposes a singleton full vault. + +use crate::cloud_sync_e2ee_documents::{ + AllowedSyncScope, ConfirmedVocabularySelection, DocumentError, DocumentResult, + LimitedSyncDocuments, RestoreContext, Revision, SyncScope, +}; +use crate::cloud_sync_e2ee_store::CoreSyncStore; +use crate::{marketplace::MarketplaceService, BackendError}; +use futures_util::future::BoxFuture; +use std::sync::Arc; + +/// Verified immutable numeric identity. Display login and OAuth secrets are not exposed. +/// Obtain a fresh value from MarketplaceService; do not persist it as authorization. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifiedSyncAccount { + github_id: String, +} +impl VerifiedSyncAccount { + pub fn github_id(&self) -> &str { + &self.github_id + } +} + +/// Inject the actual account authority, without constructing/enabling full sync. +/// Implementations must revalidate sign-out/account switch on every call; never use a +/// cached display login. Hosts normally inject their existing MarketplaceService. +pub trait SyncAccountProvider: Send + Sync { + fn verified_sync_account(&self) -> BoxFuture<'_, Result>; +} +impl MarketplaceService { + pub async fn verified_sync_account(&self) -> Result { + let (_, account) = self.sync_identity().await?; + Ok(VerifiedSyncAccount { + github_id: account.github_id.as_str().into(), + }) + } +} +impl SyncAccountProvider for MarketplaceService { + fn verified_sync_account(&self) -> BoxFuture<'_, Result> { + Box::pin(MarketplaceService::verified_sync_account(self)) + } +} + +#[derive(Debug, Clone)] +pub struct LimitedSyncExport { + pub generation: Revision, + pub documents: LimitedSyncDocuments, +} + +#[derive(Clone)] +pub struct LimitedSyncStore { + store: CoreSyncStore, + accounts: Arc, +} +impl LimitedSyncStore { + /// Compose the actual Core protected local store without constructing or enabling the + /// full-snapshot service. Choose one policy per repository instance: its activity/vault + /// gate binds one device identity. The limited root has a separate durable device ID, + /// wrapping-key AAD, extension buckets and journal identity from desktop full sync. + #[allow(clippy::too_many_arguments)] + pub fn build( + config: super::EncryptedSyncConfig, + data_dir: &std::path::Path, + repositories: crate::BackendRepositories, + credentials: Arc, + tasks: Arc, + allowed: AllowedSyncScope, + accounts: Arc, + ) -> Result { + if allowed != AllowedSyncScope::ConfirmedVocabularyModeStyleReferences { + return Err(super::error("unsupported_protocol")); + } + let (_, _, store) = super::adapter::build_store( + config, + data_dir, + repositories, + credentials, + tasks, + super::LIMITED_LOCAL_ROOT, + )?; + Self::new((*store).clone(), allowed, accounts).map_err(super::document_error) + } + + /// Inject an already composed, dedicated limited-policy store. Do not reuse a full + /// service's local device identity; use `build` for the standard protected composition. + pub fn new( + store: CoreSyncStore, + allowed: AllowedSyncScope, + accounts: Arc, + ) -> DocumentResult { + if allowed != AllowedSyncScope::ConfirmedVocabularyModeStyleReferences { + return Err(DocumentError::Unsupported); + } + Ok(Self { store, accounts }) + } + + pub fn generation(&self) -> DocumentResult { + self.store.generation() + } + pub fn device(&self) -> crate::cloud_sync_e2ee_documents::SourceDevice { + self.store.device() + } + pub fn bind_runtime_idle_probe( + &self, + probe: Arc bool + Send + Sync>, + ) -> DocumentResult<()> { + self.store.bind_runtime_idle_probe(probe) + } + pub fn bind_runtime_effects( + &self, + effects: Arc, + ) -> DocumentResult<()> { + self.store.bind_runtime_effects(effects) + } + pub async fn recover_registered(&self) -> DocumentResult<()> { + self.store.recover_registered().await + } + pub fn is_recovering(&self) -> bool { + self.store.is_recovering() + } + + async fn check_account(&self, scope: &SyncScope) -> DocumentResult<()> { + let account = self + .accounts + .verified_sync_account() + .await + .map_err(|_| DocumentError::Locked)?; + if account.github_id() != scope.owner_github_id { + return Err(DocumentError::InvalidReference); + } + Ok(()) + } + + /// Coherent local capture, projected before being returned to Host. No sync-vault I/O; + /// the account provider may perform authentication I/O. Capture still requires real + /// credential gate/batch hooks, never an empty-vault fallback. + pub async fn export( + &self, + scope: SyncScope, + confirmed: ConfirmedVocabularySelection, + ) -> DocumentResult { + self.check_account(&scope).await?; + let full = self.store.export_scope(scope.clone()).await?; + self.check_account(&scope).await?; + Ok(LimitedSyncExport { + generation: full.generation, + documents: LimitedSyncDocuments::project(&full, &scope, &confirmed)?, + }) + } + + /// Explicit restore/merge result, not last-writer-wins. Caller supplies the generation + /// reviewed in its preview; all later local mutations cause StalePreview. Existing Core + /// exclusive gate, encrypted before/after journal, recovery and rollback remain authoritative. + pub async fn restore( + &self, + desired: LimitedSyncDocuments, + context: RestoreContext, + confirmed: ConfirmedVocabularySelection, + ) -> DocumentResult { + self.check_account(&context.scope).await?; + let full = self.store.export_scope(context.scope.clone()).await?; + if full.generation != context.local_generation { + return Err(DocumentError::StalePreview); + } + let overlaid = + desired.overlay(&full, &context.scope, context.observed_revision, &confirmed)?; + self.check_account(&context.scope).await?; + let scope = context.scope.clone(); + let restored = self.store.restore_scope(overlaid, context).await?; + Ok(LimitedSyncExport { + generation: restored.generation, + documents: desired.project_restored(&restored, &scope)?, + }) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + pub(crate) struct TestAccount(pub(crate) &'static str); + impl SyncAccountProvider for TestAccount { + fn verified_sync_account( + &self, + ) -> BoxFuture<'_, Result> { + Box::pin(async { + Ok(VerifiedSyncAccount { + github_id: self.0.into(), + }) + }) + } + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs index 6cfc6ddc7..0539071b0 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/mod.rs @@ -3,7 +3,11 @@ mod adapter; mod dto; +mod limited; mod local; +#[cfg(test)] +pub(crate) use limited::tests::TestAccount; +pub use limited::{LimitedSyncExport, LimitedSyncStore, SyncAccountProvider, VerifiedSyncAccount}; mod service; #[cfg(test)] mod tests; @@ -13,6 +17,7 @@ pub use dto::*; pub(crate) use service::{EncryptedSyncService, SyncServiceConfig}; pub const DEFAULT_SYNC_SERVICE_ORIGIN: &str = "https://apic.openless.top:9443"; +pub(crate) const LIMITED_LOCAL_ROOT: &str = "encrypted-sync-limited-v1"; #[derive(Debug, Clone)] pub struct EncryptedSyncConfig { diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs index df1706573..f7dfce8fb 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee/tests.rs @@ -2061,3 +2061,43 @@ async fn configuration_failure_discards_cached_identity_and_memory_key() { EnableStep::Unlock ); } + +#[tokio::test] +async fn public_verified_account_facade_does_not_enable_or_authenticate_full_sync() { + use crate::domains::MarketplaceApi; + let server = Server::start().await; + let fixture = Fixture::new(&server).await; + let account = fixture.marketplace.verified_sync_account().await.unwrap(); + assert_eq!(account.github_id(), OWNER); + assert_eq!( + serde_json::to_value(&account).unwrap(), + json!({"githubId": OWNER}) + ); + assert!(!fixture.service.status().enabled); + { + let state = server.state.lock().unwrap(); + assert_eq!( + state.requests, 1, + "only the real account /user authority was contacted" + ); + assert_eq!(state.github_auth_requests, 0); + assert_eq!(state.token_auth_requests, 0); + assert!(state.puts.is_empty()); + } + server.state.lock().unwrap().owner = "999".into(); + assert_eq!( + fixture + .marketplace + .verified_sync_account() + .await + .unwrap() + .github_id(), + "999" + ); + // The previously returned immutable value remains 42, never a mutable display-login alias. + assert_eq!(account.github_id(), OWNER); + fixture.marketplace.logout().await.unwrap(); + let requests = server.state.lock().unwrap().requests; + assert!(fixture.marketplace.verified_sync_account().await.is_err()); + assert_eq!(server.state.lock().unwrap().requests, requests); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/limited.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/limited.rs new file mode 100644 index 000000000..6379cb8bd --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/limited.rs @@ -0,0 +1,540 @@ +//! Opt-in domain format, not a second remote protocol. V1's singleton vault +//! cannot safely transport this alongside the desktop full snapshot. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeSet; +use zeroize::Zeroizing; + +use super::*; +use crate::cloud_sync_e2ee_protocol::types::{DocumentKind, DocumentSet, Revision}; + +pub const LIMITED_SYNC_NAMESPACE: &str = "confirmed-vocabulary-mode-style-references-v1"; +const STYLE_REFERENCE: &str = "marketStyleReference"; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct StyleReference { + id: String, + version: String, + origin_pack_id: Option, +} +impl StyleReference { + fn from_style(doc: &LogicalDocument) -> DocumentResult { + let record: StylePackRecord = serde_json::from_value(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + let pack = record.pack.expose(); + let reference = Self { + id: doc.id.clone(), + version: pack + .get("version") + .and_then(Value::as_str) + .ok_or(DocumentError::InvalidDocument)? + .into(), + origin_pack_id: pack + .get("originPackId") + .filter(|value| !value.is_null()) + .map(|value| { + value + .as_str() + .map(str::to_owned) + .ok_or(DocumentError::InvalidDocument) + }) + .transpose()?, + }; + reference.validate()?; + Ok(reference) + } + fn validate(&self) -> DocumentResult<()> { + super::validate::identifier(&self.id)?; + if self.version.is_empty() + || self.version.len() > 128 + || self.version.chars().any(char::is_control) + { + return Err(DocumentError::InvalidDocument); + } + if let Some(origin) = &self.origin_pack_id { + super::validate::identifier(origin)?; + } + Ok(()) + } +} +fn style_reference(value: &Value) -> DocumentResult> { + let reference: Option = + serde_json::from_value(value.clone()).map_err(|_| DocumentError::InvalidDocument)?; + if let Some(reference) = &reference { + reference.validate()?; + } + Ok(reference) +} + +/// Host permission, not a remote capability. Existing desktop behavior remains the default. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum AllowedSyncScope { + #[default] + FullSnapshot, + ConfirmedVocabularyModeStyleReferences, +} + +/// Explicit Host confirmation decision, bound to the exact local capture. Empty means +/// no confirmed local rows, NEVER all rows. The Host owns durable confirmation metadata. +#[derive(Clone)] +pub struct ConfirmedVocabularySelection { + scope: SyncScope, + generation: Revision, + ids: BTreeSet, +} +impl ConfirmedVocabularySelection { + pub fn new(scope: SyncScope, generation: Revision, ids: Vec) -> DocumentResult { + super::validate::validate_scope(&scope)?; + if ids.len() > MAX_DOCUMENTS { + return Err(DocumentError::PayloadTooLarge); + } + let mut unique = BTreeSet::new(); + for id in ids { + super::validate::identifier(&id)?; + if !unique.insert(id) { + return Err(DocumentError::DuplicateId); + } + } + Ok(Self { + scope, + generation, + ids: unique, + }) + } + pub(crate) fn check(&self, full: &ExportedDocuments, scope: &SyncScope) -> DocumentResult<()> { + if self.scope != *scope || self.generation != full.generation { + return Err(DocumentError::StalePreview); + } + if self.ids.iter().any(|id| { + !full + .documents + .documents() + .documents + .iter() + .any(|doc| doc.kind == DocumentKind::Dictionary && doc.id == *id) + }) { + return Err(DocumentError::SourceChanged); + } + Ok(()) + } +} +impl std::fmt::Debug for ConfirmedVocabularySelection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ConfirmedVocabularySelection([REDACTED])") + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Manifest { + schema_version: u32, + namespace: String, + service_origin: String, + owner_github_id: String, + vault_id: String, + key_id: String, +} +impl Manifest { + fn for_scope(scope: &SyncScope) -> DocumentResult { + super::validate::validate_scope(scope)?; + Ok(Self { + schema_version: 1, + namespace: LIMITED_SYNC_NAMESPACE.into(), + service_origin: scope.service_origin.clone(), + owner_github_id: scope.owner_github_id.clone(), + vault_id: scope.vault_id.clone(), + key_id: scope.key_id.clone(), + }) + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Envelope { + manifest: Manifest, + documents: DocumentSet, +} + +/// Only validated limited data can be serialized; no full snapshot accessor is exposed. +#[derive(Clone)] +pub struct LimitedSyncDocuments { + manifest: Manifest, + documents: ValidatedSyncDocuments, +} +impl std::fmt::Debug for LimitedSyncDocuments { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("LimitedSyncDocuments([REDACTED])") + } +} + +fn allowed(kind: DocumentKind, id: &str) -> bool { + kind == DocumentKind::Dictionary + || kind == DocumentKind::Preferences + && matches!(id, "defaultMode" | "enabledModes" | STYLE_REFERENCE) +} + +fn validate_limited( + set: DocumentSet, + revision: Revision, +) -> DocumentResult { + for doc in &set.documents { + if !allowed(doc.kind, &doc.id) { + return Err(DocumentError::ExcludedField); + } + if doc.kind == DocumentKind::Dictionary { + let row = doc + .value + .as_object() + .ok_or(DocumentError::InvalidDocument)?; + if row.keys().any(|key| { + !matches!( + key.as_str(), + "id" | "phrase" | "enabled" | "createdAt" | "sortIndex" + ) + }) { + return Err(DocumentError::ExcludedField); + } + if row.get("enabled").and_then(Value::as_bool).is_none() + || row.get("createdAt").and_then(Value::as_str).is_none() + { + return Err(DocumentError::InvalidDocument); + } + } else if doc.id == STYLE_REFERENCE { + style_reference(&doc.value)?; + } else if doc.id == "defaultMode" { + serde_json::from_value::(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + } else if doc.id == "enabledModes" { + let modes = serde_json::from_value::>(doc.value.clone()) + .map_err(|_| DocumentError::InvalidDocument)?; + if modes + .iter() + .enumerate() + .any(|(index, mode)| modes[..index].contains(mode)) + { + return Err(DocumentError::DuplicateId); + } + } + } + for mark in &set.tombstones { + // These native settings cannot be deleted; selecting an empty style is explicit. + if mark.kind != DocumentKind::Dictionary { + return Err(DocumentError::ExcludedField); + } + } + for id in ["defaultMode", "enabledModes", STYLE_REFERENCE] { + if !set + .documents + .iter() + .any(|doc| doc.kind == DocumentKind::Preferences && doc.id == id) + { + return Err(DocumentError::InvalidDocument); + } + } + validate_sync_documents(set, revision) +} + +impl LimitedSyncDocuments { + /// Project only committed vocabulary rows, never unconfirmed candidate stores. + /// Notes and usage counters are receiver-local; prompts and style contents are absent. + pub(crate) fn project( + full: &ExportedDocuments, + scope: &SyncScope, + confirmed: &ConfirmedVocabularySelection, + ) -> DocumentResult { + confirmed.check(full, scope)?; + let mut set = full.documents.documents().clone(); + let selected_id = set + .documents + .iter() + .find(|doc| doc.kind == DocumentKind::Preferences && doc.id == "activeStylePackId") + .and_then(|doc| doc.value.as_str()) + .ok_or(DocumentError::InvalidDocument)?; + let reference = if selected_id.is_empty() { + None + } else { + Some(StyleReference::from_style( + set.documents + .iter() + .find(|doc| doc.kind == DocumentKind::StylePacks && doc.id == selected_id) + .ok_or(DocumentError::InvalidReference)?, + )?) + }; + let reference = + serde_json::to_value(reference).map_err(|_| DocumentError::InvalidDocument)?; + set.documents.retain(|doc| { + doc.kind == DocumentKind::Dictionary && confirmed.ids.contains(&doc.id) + || doc.kind == DocumentKind::Preferences + && matches!( + doc.id.as_str(), + "defaultMode" | "enabledModes" | "activeStylePackId" + ) + }); + for doc in &mut set.documents { + if doc.kind == DocumentKind::Dictionary { + let row = doc + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + row.retain(|key, _| { + matches!( + key.as_str(), + "id" | "phrase" | "enabled" | "createdAt" | "sortIndex" + ) + }); + } else if doc.id == "activeStylePackId" { + doc.id = STYLE_REFERENCE.into(); + doc.value = reference.clone(); + } + } + // Only the separately protected limited baseline may establish confirmed deletions. + // Full-snapshot tombstones do not prove that an entry was ever confirmed. + set.tombstones.clear(); + Ok(Self { + manifest: Manifest::for_scope(scope)?, + documents: validate_limited(set, full.documents.observed_revision())?, + }) + } + + /// Plaintext domain bytes for a future namespaced encrypted transport, NEVER an HTTP body. + pub fn to_secret_json(&self) -> DocumentResult>> { + let bytes = serde_json::to_vec(&Envelope { + manifest: self.manifest.clone(), + documents: self.documents.documents().clone(), + }) + .map(Zeroizing::new) + .map_err(|_| DocumentError::InvalidDocument)?; + if bytes.len() > MAX_JSON_BYTES { + return Err(DocumentError::PayloadTooLarge); + } + Ok(bytes) + } + + /// Call only after authenticated decryption. Scope/revision come from authenticated metadata, + /// not fields supplied by the remote plaintext. Strict decoding rejects duplicate keys. + pub fn from_authenticated_json( + bytes: &[u8], + scope: &SyncScope, + revision: Revision, + ) -> DocumentResult { + crate::cloud_sync_e2ee_protocol::types::validate_secret_json(bytes, MAX_JSON_BYTES) + .map_err(|_| DocumentError::InvalidDocument)?; + let envelope: Envelope = + serde_json::from_slice(bytes).map_err(|_| DocumentError::InvalidDocument)?; + if envelope.manifest != Manifest::for_scope(scope)? { + return Err(DocumentError::InvalidReference); + } + Ok(Self { + manifest: envelope.manifest, + documents: validate_limited(envelope.documents, revision)?, + }) + } + + pub(crate) fn project_restored( + &self, + full: &ExportedDocuments, + scope: &SyncScope, + ) -> DocumentResult { + let ids = self + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Dictionary) + .map(|doc| doc.id.clone()) + .collect(); + let confirmed = ConfirmedVocabularySelection::new(scope.clone(), full.generation, ids)?; + let mut projected = Self::project(full, scope, &confirmed)?; + let mut set = projected.documents.documents().clone(); + set.tombstones = self.documents.documents().tombstones.clone(); + projected.documents = validate_limited(set, self.documents.observed_revision())?; + Ok(projected) + } + + pub fn diff( + &self, + baseline: Option<&Self>, + remote: &Self, + ) -> DocumentResult { + if self.manifest != remote.manifest + || baseline.is_some_and(|base| base.manifest != self.manifest) + { + return Err(DocumentError::InvalidReference); + } + Ok(LimitedMergePreview { + manifest: self.manifest.clone(), + preview: diff_sync_documents( + baseline.map(|base| &base.documents), + &self.documents, + &remote.documents, + )?, + }) + } + + /// Keep a limited baseline separately from the desktop full baseline. Missing IDs require + /// explicit tombstones; timestamps never decide which device wins. + pub fn record_local_deletions( + &self, + baseline: &Self, + deleted_at: &str, + ) -> DocumentResult { + if self.manifest != baseline.manifest { + return Err(DocumentError::InvalidReference); + } + let documents = record_missing_tombstones( + self.documents.documents().clone(), + &baseline.documents, + deleted_at, + )?; + Ok(Self { + manifest: self.manifest.clone(), + documents: validate_limited( + documents.documents().clone(), + documents.observed_revision(), + )?, + }) + } + + pub(crate) fn overlay( + &self, + full: &ExportedDocuments, + scope: &SyncScope, + revision: Revision, + confirmed: &ConfirmedVocabularySelection, + ) -> DocumentResult { + confirmed.check(full, scope)?; + if self.manifest != Manifest::for_scope(scope)? + || self.documents.observed_revision() != revision + { + return Err(DocumentError::StalePreview); + } + let mut set = full.documents.documents().clone(); + let old_rows = set + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Dictionary) + .map(|doc| (doc.id.clone(), doc.value.clone())) + .collect::>(); + for id in self + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Dictionary) + .map(|doc| &doc.id) + .chain( + self.documents + .documents() + .tombstones + .iter() + .map(|mark| &mark.id), + ) + { + if old_rows.contains_key(id) && !confirmed.ids.contains(id) { + return Err(DocumentError::InvalidReference); + } + } + set.documents.retain(|doc| { + !(doc.kind == DocumentKind::Dictionary && confirmed.ids.contains(&doc.id)) + && !(doc.kind == DocumentKind::Preferences + && matches!( + doc.id.as_str(), + "defaultMode" | "enabledModes" | "activeStylePackId" + )) + }); + let remote_ids: BTreeSet<_> = self + .documents + .documents() + .documents + .iter() + .filter(|doc| doc.kind == DocumentKind::Dictionary) + .map(|doc| &doc.id) + .chain( + self.documents + .documents() + .tombstones + .iter() + .map(|mark| &mark.id), + ) + .collect(); + if confirmed.ids.iter().any(|id| !remote_ids.contains(id)) { + return Err(DocumentError::MissingTombstone); + } + set.tombstones.retain(|mark| { + mark.kind != DocumentKind::Dictionary + || !confirmed.ids.contains(&mark.id) && !remote_ids.contains(&mark.id) + }); + for source in &self.documents.documents().documents { + let mut doc = source.clone(); + if doc.kind == DocumentKind::Dictionary { + let row = doc + .value + .as_object_mut() + .ok_or(DocumentError::InvalidDocument)?; + row.insert( + "note".into(), + old_rows + .get(&doc.id) + .and_then(|old| old.get("note")) + .cloned() + .unwrap_or(Value::Null), + ); + row.insert( + "hits".into(), + old_rows + .get(&doc.id) + .and_then(|old| old.get("hits")) + .cloned() + .unwrap_or(Value::from(0)), + ); + } else if doc.id == STYLE_REFERENCE { + let reference = style_reference(&doc.value)?; + if let Some(reference) = &reference { + let installed = set + .documents + .iter() + .find(|style| { + style.kind == DocumentKind::StylePacks && style.id == reference.id + }) + .ok_or(DocumentError::InvalidReference)?; + if StyleReference::from_style(installed)? != *reference + || installed.value["pack"]["enabled"] != true + { + return Err(DocumentError::InvalidReference); + } + } + doc.id = "activeStylePackId".into(); + doc.value = + Value::String(reference.map(|reference| reference.id).unwrap_or_default()); + } + set.documents.push(doc); + } + set.tombstones + .extend(self.documents.documents().tombstones.clone()); + // Full validation requires the referenced style to be installed. Missing styles fail + // without changing settings; Host can download/verify via the existing marketplace. + validate_sync_documents(set, revision) + } +} + +pub struct LimitedMergePreview { + manifest: Manifest, + preview: MergePreview, +} +impl LimitedMergePreview { + pub fn conflicts(&self) -> &[RedactedConflict] { + self.preview.conflicts() + } + pub fn resolve(self, choices: &[ConflictChoice]) -> DocumentResult { + let documents = self.preview.resolve(choices)?; + Ok(LimitedSyncDocuments { + manifest: self.manifest, + documents: validate_limited( + documents.documents().clone(), + documents.observed_revision(), + )?, + }) + } +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/limited_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/limited_tests.rs new file mode 100644 index 000000000..b44183845 --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/limited_tests.rs @@ -0,0 +1,245 @@ +use super::*; +use serde_json::{json, Value}; + +fn scope() -> SyncScope { + SyncScope { + service_origin: "https://sync.example.test".into(), + owner_github_id: "42".into(), + vault_id: uuid::Uuid::new_v4().to_string(), + key_id: uuid::Uuid::new_v4().to_string(), + device_id: "device-a".into(), + } +} +fn full() -> ExportedDocuments { + let mut snapshot = super::tests::channel_snapshot(); + snapshot.preferences = SecretJson::new( + json!({"defaultMode":"raw", "enabledModes":["raw","light"], "activeStylePackId":"", "styleSystemPrompts":{"light":"PRIVATE-PROMPT"}, "themeMode":"dark"}), + ); + snapshot.dictionary = vec![ + SecretJson::new( + json!({"id":"confirmed", "phrase":"approved-word", "enabled":true, "note":"PRIVATE-NOTE", "hits":999, "createdAt":"2026-10-16T00:00:00Z"}), + ), + SecretJson::new( + json!({"id":"candidate", "phrase":"UNCONFIRMED-WORD", "enabled":true, "note":crate::shared_types::LEARNED_VOCAB_NOTE, "createdAt":"2026-10-16T00:00:00Z"}), + ), + ]; + snapshot.history = vec![SecretJson::new( + json!({"id":"history", "rawTranscript":"PRIVATE-HISTORY", "finalText":"PRIVATE-HISTORY", "mode":"raw", "insertStatus":"notRequested", "createdAt":"2026-10-16T00:00:00Z"}), + )]; + export_snapshot(snapshot).unwrap() +} +fn limited(full: &ExportedDocuments, scope: &SyncScope) -> LimitedSyncDocuments { + let selection = + ConfirmedVocabularySelection::new(scope.clone(), full.generation, vec!["confirmed".into()]) + .unwrap(); + LimitedSyncDocuments::project(full, scope, &selection).unwrap() +} +fn value(documents: &LimitedSyncDocuments) -> Value { + serde_json::from_slice(&documents.to_secret_json().unwrap()).unwrap() +} +fn parse(value: Value, scope: &SyncScope) -> DocumentResult { + LimitedSyncDocuments::from_authenticated_json( + &serde_json::to_vec(&value).unwrap(), + scope, + Revision::new(3), + ) +} + +#[test] +fn limited_export_never_contains_full_snapshot_secrets_or_unconfirmed_vocabulary() { + assert_eq!(AllowedSyncScope::default(), AllowedSyncScope::FullSnapshot); + let full = full(); + let scope = scope(); + let raw_full = serde_json::to_string(full.documents.documents()).unwrap(); + for marker in [ + "fixture-secret-original", + "PRIVATE-HISTORY", + "PRIVATE-PROMPT", + "PRIVATE-NOTE", + "UNCONFIRMED-WORD", + ] { + assert!( + raw_full.contains(marker), + "full default must remain unchanged: {marker}" + ); + } + let raw_limited = + String::from_utf8(limited(&full, &scope).to_secret_json().unwrap().to_vec()).unwrap(); + for marker in [ + "fixture-secret-original", + "PRIVATE-HISTORY", + "PRIVATE-PROMPT", + "PRIVATE-NOTE", + "UNCONFIRMED-WORD", + "hits", + "channels", + "styleSystemPrompts", + "device_profile", + ] { + assert!(!raw_limited.contains(marker), "excluded: {marker}"); + } + assert!(raw_limited.contains("approved-word")); + let none = ConfirmedVocabularySelection::new(scope.clone(), full.generation, vec![]).unwrap(); + assert!(!String::from_utf8( + LimitedSyncDocuments::project(&full, &scope, &none) + .unwrap() + .to_secret_json() + .unwrap() + .to_vec() + ) + .unwrap() + .contains("approved-word")); +} + +#[test] +fn wrong_manifest_scope_account_key_version_and_forbidden_documents_fail_closed() { + let full = full(); + let scope = scope(); + let original = limited(&full, &scope); + for (key, wrong) in [ + ("namespace", json!("encrypted-full-snapshot-v1")), + ("schemaVersion", json!(2)), + ("ownerGithubId", json!("999")), + ("keyId", json!(uuid::Uuid::new_v4().to_string())), + ("vaultId", json!(uuid::Uuid::new_v4().to_string())), + ("serviceOrigin", json!("https://other.example.test")), + ] { + let mut changed = value(&original); + changed["manifest"][key] = wrong; + assert!(parse(changed, &scope).is_err(), "{key}"); + } + let mut changed = value(&original); + changed["documents"]["documents"].as_array_mut().unwrap().push(json!({"id":"styleSystemPrompts", "kind":"preferences", "schemaVersion":1, "value":{"light":"private"}})); + assert_eq!( + parse(changed, &scope).unwrap_err(), + DocumentError::ExcludedField + ); + for kind in [ + DocumentKind::ProviderCredentials, + DocumentKind::Channels, + DocumentKind::History, + DocumentKind::Activity, + DocumentKind::StylePacks, + DocumentKind::VocabularyPresets, + DocumentKind::DeviceProfile, + DocumentKind::UiPreferences, + DocumentKind::Corrections, + ] { + let mut changed = value(&original); + changed["documents"]["documents"] + .as_array_mut() + .unwrap() + .push(json!({"id":"forbidden", "kind":kind, "schemaVersion":1, "value":null})); + assert_eq!( + parse(changed, &scope).unwrap_err(), + DocumentError::ExcludedField + ); + } + for key in ["note", "hits", "rawAudio", "apiKey"] { + let mut changed = value(&original); + let dictionary = changed["documents"]["documents"] + .as_array_mut() + .unwrap() + .iter_mut() + .find(|doc| doc["id"] == "confirmed") + .unwrap(); + dictionary["value"][key] = json!("private"); + assert_eq!( + parse(changed, &scope).unwrap_err(), + DocumentError::ExcludedField + ); + } + assert!( + DocumentSet::from_json_bytes(&original.to_secret_json().unwrap()).is_err(), + "the full protocol must not silently accept the limited envelope" + ); + let raw_full = serde_json::to_vec(full.documents.documents()).unwrap(); + assert!( + LimitedSyncDocuments::from_authenticated_json(&raw_full, &scope, Revision::new(3)).is_err() + ); + let bytes = original.to_secret_json().unwrap(); + let duplicate = String::from_utf8(bytes.to_vec()).unwrap().replacen( + "\"schemaVersion\":1", + "\"schemaVersion\":1,\"schemaVersion\":1", + 1, + ); + assert!(LimitedSyncDocuments::from_authenticated_json( + duplicate.as_bytes(), + &scope, + Revision::new(3) + ) + .is_err()); +} + +#[test] +fn confirmation_selection_requires_exact_scope_generation_and_existing_ids() { + let full = full(); + let scope = scope(); + for selection in [ + ConfirmedVocabularySelection::new( + scope.clone(), + Revision::new(0), + vec!["confirmed".into()], + ) + .unwrap(), + ConfirmedVocabularySelection::new( + super::limited_tests::scope(), + full.generation, + vec!["confirmed".into()], + ) + .unwrap(), + ] { + assert_eq!( + LimitedSyncDocuments::project(&full, &scope, &selection).unwrap_err(), + DocumentError::StalePreview + ); + } + let missing = + ConfirmedVocabularySelection::new(scope.clone(), full.generation, vec!["missing".into()]) + .unwrap(); + assert_eq!( + LimitedSyncDocuments::project(&full, &scope, &missing).unwrap_err(), + DocumentError::SourceChanged + ); +} + +#[test] +fn limited_conflicts_and_deletions_reuse_core_three_way_rules() { + let full = full(); + let scope = scope(); + let baseline = limited(&full, &scope); + let mut left = value(&baseline); + let mut right = value(&baseline); + for (set, phrase) in [(&mut left, "left"), (&mut right, "right")] { + for doc in set["documents"]["documents"].as_array_mut().unwrap() { + if doc["id"] == "confirmed" { + doc["value"]["phrase"] = json!(phrase); + } + } + } + let left = parse(left, &scope).unwrap(); + let right = parse(right, &scope).unwrap(); + let preview = left.diff(Some(&baseline), &right).unwrap(); + assert_eq!(preview.conflicts().len(), 1); + let choice = ConflictChoice { + conflict_id: preview.conflicts()[0].conflict_id.clone(), + side: ConflictSide::Remote, + }; + let merged = preview.resolve(&[choice]).unwrap(); + assert!(String::from_utf8(merged.to_secret_json().unwrap().to_vec()) + .unwrap() + .contains("right")); + let selection = + ConfirmedVocabularySelection::new(scope.clone(), full.generation, vec![]).unwrap(); + let deleted = LimitedSyncDocuments::project(&full, &scope, &selection) + .unwrap() + .record_local_deletions(&baseline, "2026-10-16T00:00:00Z") + .unwrap(); + let json = value(&deleted); + assert_eq!(json["documents"]["tombstones"].as_array().unwrap().len(), 1); + assert_eq!(json["documents"]["tombstones"][0]["id"], "confirmed"); + assert_eq!( + deleted.diff(Some(&baseline), &right).unwrap().conflicts()[0].reason, + ConflictReason::DeleteModify + ); +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs index 282df893a..720076dfd 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_documents/mod.rs @@ -1,17 +1,24 @@ //! Client-side logical documents for encrypted sync. No network or UI effects live here. mod export; +mod limited; mod merge; pub mod registry; mod restore; mod types; mod validate; -pub use crate::cloud_sync_e2ee_protocol::types::{DocumentKind, Revision}; +pub use crate::cloud_sync_e2ee_protocol::types::{ + DocumentKind, DocumentSet, LogicalDocument, Revision, SourceDevice, Tombstone, +}; pub use export::{ activity_record, export_provider_credentials_for_sync, export_snapshot, export_style_packs, export_sync_documents, secret_rows, ui_preferences, vocabulary_records, }; +pub use limited::{ + AllowedSyncScope, ConfirmedVocabularySelection, LimitedMergePreview, LimitedSyncDocuments, + LIMITED_SYNC_NAMESPACE, +}; pub use merge::{ diff_local_replace_documents, diff_sync_documents, record_missing_tombstones, ConflictChoice, ConflictReason, ConflictSide, MergePreview, RedactedConflict, @@ -90,6 +97,8 @@ pub fn serialize_sync_documents(documents: &ValidatedSyncDocuments) -> DocumentR serde_json::to_vec(documents.documents()).map_err(|_| DocumentError::InvalidDocument) } +#[cfg(test)] +mod limited_tests; #[cfg(test)] mod restore_tests; #[cfg(test)] diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs index a146d991d..0661495a9 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/transport.rs @@ -1481,6 +1481,36 @@ mod tests { assert_eq!(requests[1].headers["accept-encoding"], "identity"); } + #[tokio::test] + async fn v1_contract_has_a_singleton_vault_and_no_named_scope_capability() { + let server = FakeServer::start(vec![ + caps_reply(), + Reply::json("200 OK", empty_metadata()).header("ETag", "\"zero\""), + ]) + .await; + let transport = Transport::for_test(&server.origin, CLIENT_ID) + .await + .unwrap(); + transport + .metadata(&session(&transport, OWNER), None) + .await + .unwrap(); + let requests = server.finish().await; + assert_eq!(requests[1].path, "/v1/me/vault"); + assert!(!requests[1].headers.contains_key("x-sync-namespace")); + + // A scope-aware server requires a new negotiated wire contract. Merely sending + // opaque bytes to this route would still replace the desktop's single vault. + let mut extended = capabilities(); + extended["collections"] = json!(["confirmed-vocabulary-mode-style-references-v1"]); + let server = FakeServer::start(vec![Reply::json("200 OK", extended)]).await; + assert!(matches!( + Transport::for_test(&server.origin, CLIENT_ID).await, + Err(Error::InvalidWire(_)) + )); + assert_eq!(server.finish().await.len(), 1); + } + #[tokio::test] async fn wrong_oauth_capability_or_duplicate_wire_key_prevents_authentication() { let mut wrong = capabilities(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs index 001525ac0..5b6af30c7 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_protocol/types.rs @@ -1168,6 +1168,11 @@ fn parse_strict_json(bytes: &[u8], limit: usize) -> Result { .map_err(|_| Error::InvalidWire("json trailing data"))?; Ok(value) } +pub(crate) fn validate_secret_json(bytes: &[u8], limit: usize) -> Result<()> { + // StrictValue erases the validation tree on every return path. + parse_strict_json(bytes, limit).map(|_| ()) +} + pub(crate) fn parse_wire(bytes: &[u8], limit: usize) -> Result { if bytes.len() > limit { return Err(Error::PayloadTooLarge); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs index 0ab61270f..9f7b2af99 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/gate.rs @@ -382,6 +382,24 @@ pub struct SyncWriteGate { runtime_blocked: AtomicBool, } +fn contains_existing_sync_state(directory: &Path) -> DocumentResult { + for root in [ + directory.to_path_buf(), + directory.with_file_name(crate::cloud_sync_e2ee::LIMITED_LOCAL_ROOT), + ] { + match std::fs::read_dir(root) { + Ok(mut entries) => { + if entries.next().is_some() { + return Ok(true); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => return Err(DocumentError::JournalUnavailable), + } + } + Ok(false) +} + impl SyncWriteGate { /// An unreadable/corrupt counter is an error, never a reset to zero. pub fn open(state_path: PathBuf) -> DocumentResult> { @@ -395,10 +413,7 @@ impl SyncWriteGate { if initialized_path.exists() || (directory.file_name().and_then(|name| name.to_str()) == Some("encrypted-sync") - && std::fs::read_dir(directory) - .map_err(|_| DocumentError::JournalUnavailable)? - .next() - .is_some()) + && contains_existing_sync_state(directory)?) { return Err(DocumentError::RecoveryRequired); } @@ -1073,6 +1088,24 @@ mod tests { )); } + #[test] + fn lost_counter_never_resets_over_a_retained_limited_policy_root() { + let temp = Temp::new(); + let limited = temp.0.join(crate::cloud_sync_e2ee::LIMITED_LOCAL_ROOT); + std::fs::create_dir_all(&limited).unwrap(); + let ciphertext = limited.join("fixture.enc"); + std::fs::write(&ciphertext, b"retained-limited-ciphertext").unwrap(); + assert!(matches!( + open_for_data_dir(&temp.0), + Err(DocumentError::RecoveryRequired) + )); + assert!(!temp.0.join("encrypted-sync/generation.json").exists()); + assert_eq!( + std::fs::read(ciphertext).unwrap(), + b"retained-limited-ciphertext" + ); + } + #[test] fn runtime_recovery_flag_does_not_wait_for_state_lock_or_file_io() { let temp = Temp::new(); diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/limited_tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/limited_tests.rs new file mode 100644 index 000000000..fa117743c --- /dev/null +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/limited_tests.rs @@ -0,0 +1,511 @@ +use super::*; +use crate::cloud_sync_e2ee::{LimitedSyncStore, TestAccount}; + +fn facade(f: &Fixture, account: &'static str) -> LimitedSyncStore { + LimitedSyncStore::new( + f.store.clone(), + AllowedSyncScope::ConfirmedVocabularyModeStyleReferences, + Arc::new(TestAccount(account)), + ) + .unwrap() +} +fn selection(f: &Fixture, generation: Revision, ids: &[&str]) -> ConfirmedVocabularySelection { + ConfirmedVocabularySelection::new( + f.scope.clone(), + generation, + ids.iter().map(|id| (*id).into()).collect(), + ) + .unwrap() +} +async fn seeded() -> Fixture { + let f = Fixture::new(); + let before = f.store.export_scope(f.scope.clone()).await.unwrap(); + let mut set = before.documents.documents().clone(); + for (kind, id, value) in [ + ( + DocumentKind::Dictionary, + "confirmed", + json!({"id":"confirmed","phrase":"Approved","enabled":true,"note":"PRIVATE-NOTE","hits":17,"createdAt":"2026-10-16T00:00:00Z","sortIndex":0}), + ), + ( + DocumentKind::Dictionary, + "unconfirmed", + json!({"id":"unconfirmed","phrase":"Candidate","enabled":true,"note":crate::shared_types::LEARNED_VOCAB_NOTE,"hits":3,"createdAt":"2026-10-16T00:00:00Z","sortIndex":1}), + ), + ( + DocumentKind::History, + "private-history", + json!({"id":"private-history","createdAt":"2026-10-16T00:00:00Z","source":"quick_note","rawTranscript":"PRIVATE-HISTORY","finalText":"PRIVATE-HISTORY","mode":"raw","insertStatus":"notRequested","hasAudioRecording":false,"sortIndex":0}), + ), + ( + DocumentKind::Channels, + "llm:fixture", + json!({"id":"fixture","namespace":"llm","providerType":"requesty","name":"Private channel","enabled":true,"order":0,"active":true}), + ), + ( + DocumentKind::ProviderCredentials, + "llm:fixture", + json!({"channelId":"fixture","namespace":"llm","accounts":{"ark.api_key":"PRIVATE-API-KEY"}}), + ), + ] { + set_doc(&mut set, kind, id, value); + } + f.store + .restore_scope(validate(set), f.context(&before)) + .await + .unwrap(); + f +} +fn changed( + remote: &LimitedSyncDocuments, + f: &Fixture, + change: impl FnOnce(&mut Value), +) -> LimitedSyncDocuments { + let mut value: Value = serde_json::from_slice(&remote.to_secret_json().unwrap()).unwrap(); + change(&mut value); + LimitedSyncDocuments::from_authenticated_json( + &serde_json::to_vec(&value).unwrap(), + &f.scope, + Revision::new(3), + ) + .unwrap() +} +fn set_phrase(value: &mut Value, phrase: &str) { + for doc in value["documents"]["documents"].as_array_mut().unwrap() { + if doc["id"] == "confirmed" { + doc["value"]["phrase"] = json!(phrase); + } + } +} + +#[tokio::test] +async fn limited_restore_changes_only_confirmed_vocabulary_and_reference_preferences() { + let f = seeded().await; + let full = f.store.export_scope(f.scope.clone()).await.unwrap(); + let api = facade(&f, "42"); + assert!(LimitedSyncStore::new( + f.store.clone(), + AllowedSyncScope::default(), + Arc::new(TestAccount("42")) + ) + .is_err()); + let limited = api + .export( + f.scope.clone(), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let raw = String::from_utf8(limited.documents.to_secret_json().unwrap().to_vec()).unwrap(); + for marker in [ + "PRIVATE-HISTORY", + "PRIVATE-API-KEY", + "PRIVATE-NOTE", + "Candidate", + ] { + assert!(!raw.contains(marker)); + } + let remote = changed(&limited.documents, &f, |value| { + set_phrase(value, "Remote approved"); + for doc in value["documents"]["documents"].as_array_mut().unwrap() { + if doc["id"] == "defaultMode" { + doc["value"] = json!("formal"); + } + if doc["id"] == "marketStyleReference" { + doc["value"] = json!({"id":"builtin.light","version":"2.0.0","originPackId":null}); + } + } + }); + api.restore( + remote, + f.context(&full), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let rows = f.repo.vocabulary.list().unwrap(); + assert_eq!( + rows.iter() + .find(|row| row.id == "confirmed") + .unwrap() + .phrase, + "Remote approved" + ); + assert_eq!( + rows.iter() + .find(|row| row.id == "confirmed") + .unwrap() + .note + .as_deref(), + Some("PRIVATE-NOTE") + ); + assert_eq!( + rows.iter().find(|row| row.id == "confirmed").unwrap().hits, + 17 + ); + assert_eq!( + rows.iter() + .find(|row| row.id == "unconfirmed") + .unwrap() + .phrase, + "Candidate" + ); + let after = f.store.export_scope(f.scope.clone()).await.unwrap(); + let untouched = |set: &DocumentSet| { + set.documents + .iter() + .filter(|doc| { + doc.kind != DocumentKind::Dictionary + && !(doc.kind == DocumentKind::Preferences + && matches!(doc.id.as_str(), "defaultMode" | "activeStylePackId")) + }) + .cloned() + .collect::>() + }; + assert_eq!( + untouched(full.documents.documents()), + untouched(after.documents.documents()) + ); +} + +#[tokio::test] +async fn limited_wrong_account_key_generation_missing_style_and_unconfirmed_collision_do_not_write() +{ + let f = seeded().await; + let full = f.store.export_scope(f.scope.clone()).await.unwrap(); + let api = facade(&f, "42"); + let limited = api + .export( + f.scope.clone(), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let remote = changed(&limited.documents, &f, |value| { + set_phrase(value, "Must not write") + }); + assert_eq!( + facade(&f, "999") + .restore( + remote.clone(), + f.context(&full), + selection(&f, full.generation, &["confirmed"]) + ) + .await + .unwrap_err(), + DocumentError::InvalidReference + ); + let mut context = f.context(&full); + context.scope.key_id = uuid::Uuid::new_v4().to_string(); + assert!(api + .restore( + remote.clone(), + context, + selection(&f, full.generation, &["confirmed"]) + ) + .await + .is_err()); + let stale = selection(&f, Revision::new(full.generation.get() - 1), &["confirmed"]); + assert_eq!( + api.restore(remote.clone(), f.context(&full), stale) + .await + .unwrap_err(), + DocumentError::StalePreview + ); + let missing_style = changed(&limited.documents, &f, |value| { + for doc in value["documents"]["documents"].as_array_mut().unwrap() { + if doc["id"] == "marketStyleReference" { + doc["value"] = json!({"id":"not-installed","version":"1.0.0","originPackId":null}); + } + } + }); + assert_eq!( + api.restore( + missing_style, + f.context(&full), + selection(&f, full.generation, &["confirmed"]) + ) + .await + .unwrap_err(), + DocumentError::InvalidReference + ); + let omitted = changed(&limited.documents, &f, |value| { + value["documents"]["documents"] + .as_array_mut() + .unwrap() + .retain(|doc| doc["id"] != "confirmed"); + }); + assert_eq!( + api.restore( + omitted, + f.context(&full), + selection(&f, full.generation, &["confirmed"]) + ) + .await + .unwrap_err(), + DocumentError::MissingTombstone + ); + for tombstone in [false, true] { + let collision = changed(&limited.documents, &f, |value| { + if tombstone { + value["documents"]["tombstones"] = json!([{"id":"unconfirmed","kind":"dictionary","deletedAt":"2026-10-16T00:00:00Z","baseRevision":"3"}]); + } else { + value["documents"]["documents"].as_array_mut().unwrap().push(json!({"id":"unconfirmed","kind":"dictionary","schemaVersion":1,"value":{"id":"unconfirmed","phrase":"bad overwrite","enabled":true,"createdAt":"2026-10-16T00:00:00Z","sortIndex":1}})); + } + }); + assert_eq!( + api.restore( + collision, + f.context(&full), + selection(&f, full.generation, &["confirmed"]) + ) + .await + .unwrap_err(), + DocumentError::InvalidReference + ); + } + let after = f.store.export_scope(f.scope.clone()).await.unwrap(); + assert_eq!( + after.documents.documents().documents, + full.documents.documents().documents + ); + assert_eq!(after.generation, full.generation); + // A real ordinary write after preview is retained, never overwritten by stale restore. + f.repo + .preferences + .update(|prefs| prefs.show_capsule = !prefs.show_capsule) + .unwrap(); + assert_eq!( + api.restore( + remote, + f.context(&full), + selection(&f, full.generation, &["confirmed"]) + ) + .await + .unwrap_err(), + DocumentError::StalePreview + ); +} + +#[tokio::test] +async fn limited_deletion_removes_only_confirmed_rows_and_keeps_unconfirmed_rows() { + let f = seeded().await; + let full = f.store.export_scope(f.scope.clone()).await.unwrap(); + let api = facade(&f, "42"); + let limited = api + .export( + f.scope.clone(), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let remote = changed(&limited.documents, &f, |value| { + value["documents"]["documents"] + .as_array_mut() + .unwrap() + .retain(|doc| doc["id"] != "confirmed"); + value["documents"]["tombstones"] = json!([{"id":"confirmed","kind":"dictionary","deletedAt":"2026-10-16T00:00:00Z","baseRevision":"3"}]); + }); + let restored = api + .restore( + remote, + f.context(&full), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let returned: Value = + serde_json::from_slice(&restored.documents.to_secret_json().unwrap()).unwrap(); + assert_eq!(returned["documents"]["tombstones"][0]["id"], "confirmed"); + let rows = f.repo.vocabulary.list().unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "unconfirmed"); +} + +#[tokio::test] +async fn limited_partial_failure_uses_existing_encrypted_rollback_and_recovery() { + for failures in [vec![true, false], vec![true, true, false]] { + let f = seeded().await; + let full = f.store.export_scope(f.scope.clone()).await.unwrap(); + let api = facade(&f, "42"); + let limited = api + .export( + f.scope.clone(), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let remote = changed(&limited.documents, &f, |value| { + set_phrase(value, "Roll back me") + }); + f.credentials + .failures + .lock() + .unwrap() + .extend(failures.clone()); + let error = api + .restore( + remote, + f.context(&full), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap_err(); + assert_eq!( + error, + if failures.len() == 2 { + DocumentError::RestoreRolledBack + } else { + DocumentError::RecoveryRequired + } + ); + if failures.len() == 3 { + f.store.recover_registered().await.unwrap(); + } + let after = f.store.export_scope(f.scope.clone()).await.unwrap(); + assert_eq!( + after.documents.documents().documents, + full.documents.documents().documents + ); + assert_eq!(after.generation, full.generation); + } +} + +#[tokio::test] +async fn limited_restore_blocks_concurrent_repository_and_credential_mutations() { + let f = seeded().await; + let full = f.store.export_scope(f.scope.clone()).await.unwrap(); + let api = facade(&f, "42"); + let limited = api + .export( + f.scope.clone(), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let remote = changed(&limited.documents, &f, |value| { + set_phrase(value, "Gated update") + }); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + *f.effects.wait.lock().unwrap() = Some(barrier.clone()); + let context = f.context(&full); + let confirmed = selection(&f, full.generation, &["confirmed"]); + let restore = tokio::spawn(async move { api.restore(remote, context, confirmed).await }); + barrier.wait().await; + assert!(!restore.is_finished()); + assert!(f.repo.vocabulary.add("Concurrent".into(), None).is_err()); + assert!(f.store.inner.gate.begin_mutation().is_err()); + // Fixture effects also assert that the credential store's exact shared gate is held. + barrier.wait().await; + restore.await.unwrap().unwrap(); +} + +#[tokio::test] +async fn limited_wrong_journal_key_cannot_replay_or_clear_pending_local_recovery() { + let f = seeded().await; + let full = f.store.export_scope(f.scope.clone()).await.unwrap(); + let api = facade(&f, "42"); + let limited = api + .export( + f.scope.clone(), + selection(&f, full.generation, &["confirmed"]), + ) + .await + .unwrap(); + let remote = changed(&limited.documents, &f, |value| { + set_phrase(value, "Pending image") + }); + f.credentials.failures.lock().unwrap().extend([true, true]); + assert_eq!( + api.restore( + remote, + f.context(&full), + selection(&f, full.generation, &["confirmed"]) + ) + .await + .unwrap_err(), + DocumentError::RecoveryRequired + ); + let dictionary_path = f._temp.0.join("dictionary.json"); + // Snapshot real local file bytes even when the normal API is recovery-fenced. + let dictionary_before = std::fs::read(&dictionary_path).unwrap(); + let preferences_before = std::fs::read(f._temp.0.join("preferences.json")).unwrap(); + let pending_before = f.store.inner.gate.registered_recovery_scopes().unwrap(); + let wrong_extensions = Arc::new(Extensions { + values: Mutex::new(f.extensions.values.lock().unwrap().clone()), + protector: Arc::new(CryptoJournalProtector::new(Arc::new( + DerivedKey::from_secret_bytes(Zeroizing::new([99; 32])), + ))), + key_requested: AtomicBool::new(false), + ui_revision: Mutex::new(f.extensions.ui_revision.lock().unwrap().clone()), + }); + let wrong_store = CoreSyncStore::new( + f.repo.clone(), + Arc::new(f.credentials.clone()), + f._temp.0.clone(), + f.store.device(), + f.store.inner.gate.clone(), + wrong_extensions, + Arc::new(crate::TokioTaskSpawner), + ) + .unwrap(); + wrong_store + .bind_runtime_idle_probe(Arc::new(|| true)) + .unwrap(); + wrong_store + .bind_runtime_effects(Arc::new(f.effects.clone())) + .unwrap(); + assert!(wrong_store.recover_registered().await.is_err()); + assert_eq!(std::fs::read(&dictionary_path).unwrap(), dictionary_before); + assert_eq!( + std::fs::read(f._temp.0.join("preferences.json")).unwrap(), + preferences_before + ); + assert_eq!( + f.store.inner.gate.registered_recovery_scopes().unwrap(), + pending_before + ); + assert!(f.store.is_recovering()); + f.store.recover_registered().await.unwrap(); + let recovered = f.store.export_scope(f.scope.clone()).await.unwrap(); + assert_eq!( + recovered.documents.documents().documents, + full.documents.documents().documents + ); +} + +#[tokio::test] +async fn limited_public_factory_uses_core_local_storage_without_full_service_or_remote_requests() { + let source = Fixture::new(); + let temp = Temp::new(); + let credentials = TestCredentials { + bound_gate: Arc::new(Mutex::new(None)), + ..source.credentials.clone() + }; + let store = LimitedSyncStore::build( + crate::cloud_sync_e2ee::EncryptedSyncConfig { + service_origin: "https://sync.example.test".into(), + app_version: "test".into(), + }, + &temp.0, + BackendRepositories::open(&temp.0).unwrap(), + Arc::new(credentials), + Arc::new(crate::TokioTaskSpawner), + AllowedSyncScope::ConfirmedVocabularyModeStyleReferences, + Arc::new(TestAccount("42")), + ) + .unwrap(); + assert!(temp.0.join("encrypted-sync-limited-v1/device-id").is_file()); + assert!(!temp.0.join("encrypted-sync/device-id").exists()); + let scope = SyncScope { + device_id: store.device().id.clone(), + ..source.scope.clone() + }; + let confirmed = + ConfirmedVocabularySelection::new(scope.clone(), store.generation().unwrap(), vec![]) + .unwrap(); + store.export(scope, confirmed).await.unwrap(); + assert!(!store.is_recovering()); + // Production export above needed no crypto key yet. Restore still requires the real + // platform effects + secure vault hooks; this fixture cannot silently substitute them. +} diff --git a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs index bb82d7a9f..64fa38556 100644 --- a/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs +++ b/openless-all/app/crates/openless-core/src/cloud_sync_e2ee_store/tests.rs @@ -1,4 +1,6 @@ #![cfg(test)] +#[path = "limited_tests.rs"] +mod limited_tests; // Seam scanner looks for #[cfg(test)] (inner attr alone is not enough). #[cfg(test)] mod __runtime_seam_test_marker {}