From b6b61a7c746552a935546c747c88db7031a26995 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 16:07:31 +0800 Subject: [PATCH 1/3] Bind snapshot attempts, unlock phases and verified asset publication --- app/application.ml | 138 +++-- .../2026-10-23-ocaml-evidence-boundaries.md | 142 +++++ .../lib/effect_runner/effect_runner.ml | 12 +- logseq_db_worker/lib/pure_reducer/core.ml | 3 +- .../lui/logseq_db_worker_lui_service.ml | 9 +- .../lui/logseq_db_worker_lui_service.mli | 30 +- logseq_db_worker/test/test_lui_service.ml | 42 +- .../test/test_managed_sync_e2e.ml | 4 +- logseq_db_worker/test/test_pure_reducer.ml | 9 +- .../lib/effect_runner/effect_runner.ml | 59 +- .../lib/effect_runner/protocol/asset_codec.ml | 107 ++-- .../lib/effect_runner/storage/bootstrap.ml | 4 +- .../lib/effect_runner/storage/bootstrap.mli | 3 +- logseq_sync/lib/pure_reducer/core.ml | 527 ++++++++++-------- .../spec/effect_runner/asset_codec.mli | 24 +- logseq_sync/spec/pure_reducer/core.mli | 77 ++- logseq_sync/test/asset_native_interop.ml | 14 +- logseq_sync/test/core_contract.ml | 209 +++++-- logseq_sync/test/runner_contract.ml | 3 +- logseq_sync/test/test_asset_cache.ml | 13 + logseq_sync/test/test_asset_codec.ml | 47 +- logseq_sync/test/transport_contract.ml | 3 +- test/application_view_test.ml | 108 +++- test/startup_test.ml | 1 + 24 files changed, 1118 insertions(+), 470 deletions(-) create mode 100644 docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md diff --git a/app/application.ml b/app/application.ml index 37472d22..c194124f 100644 --- a/app/application.ml +++ b/app/application.ml @@ -46,6 +46,66 @@ module Effect = struct let run (t : unit t) = t () end +module Unlock = Graph_service.Unlock + +type unlock_ui = + | Unlock_idle + | Unlock_waiting of Unlock.unlock_challenge + | Unlock_queued of Unlock.unlock_challenge + | Unlock_active of Unlock.unlock_submission + | Unlock_downloading of Unlock.unlock_submission + | Unlock_retry of Unlock.unlock_challenge * string + | Unlock_preparation_error of string + | Unlock_complete of Unlock.unlock_attached + +let unlock_pending = function Unlock_queued _ | Unlock_active _ -> true | _ -> false +let unlock_ui_challenge = function + | Unlock_waiting challenge | Unlock_queued challenge | Unlock_retry (challenge, _) -> Some challenge + | Unlock_active submission -> Some (Unlock.unlock_submission_challenge submission) + | Unlock_idle | Unlock_downloading _ | Unlock_preparation_error _ | Unlock_complete _ -> None +;; +let unlock_ui_error = function + | Unlock_retry (_, message) | Unlock_preparation_error message -> Some message + | _ -> None +;; + +let unlock_rejected = function + | Unlock_queued challenge -> Unlock_retry (challenge, "Unable to unlock the graph. Try again.") + | Unlock_active submission -> Unlock_retry + (Unlock.unlock_submission_challenge submission, "Unable to unlock the graph. Try again.") + | phase -> phase +;; + +let reconcile_unlock previous (snapshot : Graph_service.snapshot) = + let current_scope (scope : Unlock.graph_scope) = + scope.account.account_generation = snapshot.startup.account_generation + && scope.graph_generation = snapshot.startup.graph_generation + && snapshot.selected_graph = Some scope.graph_id in + let same_challenge challenge = Option.fold ~none:false + ~some:(Unlock.equal_unlock_challenge challenge) (unlock_ui_challenge previous) in + match snapshot.unlock with + | Unlock.Unlock_challenge challenge when current_scope (Unlock.unlock_challenge_scope challenge) -> + if unlock_pending previous && same_challenge challenge then previous + else Unlock_waiting challenge + | Unlock_running submission when current_scope + (Unlock.unlock_challenge_scope (Unlock.unlock_submission_challenge submission)) -> + (match previous with + | Unlock_complete receipt when Unlock.equal_unlock_submission submission + (Unlock.unlock_attached_submission receipt) -> previous + | _ -> Unlock_active submission) + | Unlock_bootstrapping submission when current_scope + (Unlock.unlock_challenge_scope (Unlock.unlock_submission_challenge submission)) -> + Unlock_downloading submission + | Unlock_presentable receipt when current_scope (Unlock.unlock_attached_scope receipt) -> + Unlock_complete receipt + | Unlock_failed (Retry_password (challenge, message)) + when current_scope (Unlock.unlock_challenge_scope challenge) -> Unlock_retry (challenge, message) + | Unlock_failed (Retry_preparation message) -> Unlock_preparation_error message + | Unlock_inactive | Unlock_cancelled _ | Unlock_challenge _ | Unlock_running _ + | Unlock_bootstrapping _ | Unlock_presentable _ | Unlock_failed (Retry_password _) -> Unlock_idle +;; + + module Admission_refresh = struct type observation = | Unavailable @@ -338,8 +398,7 @@ type state = ; worker_errors : worker_error_occurrence list ; next_sync_error_sequence : int64 ; e2ee_password : Journal_capture.t - ; e2ee_submission_pending : bool - ; e2ee_submission_error : string option + ; unlock : unlock_ui ; modal : modal ; confirmation_sequence : int64 ; environment : Journal_environment.snapshot @@ -443,8 +502,7 @@ let initial_state = ; worker_errors = [] ; next_sync_error_sequence = 1L ; e2ee_password = Journal_capture.create ~session_number:9_000_000L ~source:"" - ; e2ee_submission_pending = false - ; e2ee_submission_error = None + ; unlock = Unlock_idle ; modal = No_modal ; confirmation_sequence = 0L ; environment = Journal_environment.fallback @@ -455,7 +513,7 @@ let initial_state = let sync_startup_presentation state = let startup_presentation = Journal_startup.Presentation.step state.startup_presentation (Observe {manager=state.manager;graph=state.graph_state;graph_ready=state.graph_ready; - feed_loaded=state.feed_loaded;password_pending=state.e2ee_submission_pending; + feed_loaded=state.feed_loaded;password_pending=(unlock_pending state.unlock); graph_error=Option.is_some state.graph_error}) in if startup_presentation == state.startup_presentation then state else {state with startup_presentation} @@ -723,7 +781,7 @@ let local_deletion_available state = ;; let apply_manager_state state (manager_state : Graph_service.state) = - let e2ee_submission_was_pending = state.e2ee_submission_pending in + let e2ee_submission_was_pending = (unlock_pending state.unlock) in let snapshot = manager_state.snapshot in let graph_catalog = match state.manager with | Some previous when previous.catalog == snapshot.catalog -> state.graph_catalog @@ -779,15 +837,6 @@ let apply_manager_state state (manager_state : Graph_service.state) = | None | Some _ -> false in let is_awaiting_password = snapshot.startup.awaiting_e2ee_password in - (* Attachment precedes Timeline's first appearance. Waiting for Ready here - would keep the password screen mounted and prevent that acknowledgement. *) - let current_graph_attached = - not (owner_changed || graph_context_changed) - && Option.is_some snapshot.selected_graph - && snapshot.startup.local_access = Graph_service.Local_available - && not snapshot.startup.restoring_local - && snapshot.timeline_presentation_pending - in let e2ee_password, next_local_sequence = if is_awaiting_password = was_awaiting_password && not (owner_changed || graph_context_changed) @@ -817,35 +866,19 @@ let apply_manager_state state (manager_state : Graph_service.state) = ; graph_picker_open = state.graph_picker_open && not owner_changed ; diagnostics = Some manager_state.diagnostics ; e2ee_password - ; e2ee_submission_pending = - state.e2ee_submission_pending - && not - (owner_changed - || graph_context_changed - || is_awaiting_password - || Option.is_some snapshot.startup.failure - || snapshot.startup.bootstrapping - || current_graph_attached - || (not snapshot.startup.authenticated) - || (Journal_startup.derive ~snapshot ~graph:state.graph_state).phase = Ready - ) - ; e2ee_submission_error = - (if owner_changed || graph_context_changed - || was_awaiting_password <> is_awaiting_password - || Option.is_some snapshot.startup.failure - then None else state.e2ee_submission_error) + ; unlock = reconcile_unlock (if owner_changed || graph_context_changed then Unlock_idle else state.unlock) snapshot ; next_local_sequence ; modal ; graph_ready = state.graph_ready && not graph_context_changed ; graph_error = state.graph_error } in - if state.e2ee_submission_pending <> e2ee_submission_was_pending + if (unlock_pending state.unlock) <> e2ee_submission_was_pending then unlock_log "phase=state pending=%b challenge_advertised=%b failure=%s account_generation=%d \ graph_generation=%d" - state.e2ee_submission_pending is_awaiting_password (unlock_failure snapshot.startup.failure) + (unlock_pending state.unlock) is_awaiting_password (unlock_failure snapshot.startup.failure) snapshot.startup.account_generation snapshot.startup.graph_generation; match snapshot.last_error with | Some message when previous_sync_error <> Some message -> @@ -3546,9 +3579,11 @@ let manager_page ?picker_signal state dispatch = content in let unlock error = - let error = match state.e2ee_submission_error with + let error = match (unlock_ui_error state.unlock) with | Some _ as error -> error | None -> error in - let pending = state.e2ee_submission_pending in + let pending = (unlock_pending state.unlock) in + let challenge_ready = Option.is_some + (Option.bind state.manager (fun manager -> Unlock.unlock_challenge manager.unlock)) in let password = state.e2ee_password in let ignored = Ui.Event.Handler.create (fun _ -> ()) in let submit = bind_action dispatch Submit_e2ee_password in @@ -3594,7 +3629,7 @@ let manager_page ?picker_signal state dispatch = ~text_alignment:`center ~grow:1. ~min_height:44 - ~disabled:(pending || not (Journal_capture.can_save password)) + ~disabled:(pending || not challenge_ready || not (Journal_capture.can_save password)) ~on_press:(fun _ -> dispatch.send Submit_e2ee_password) ~text:(if pending then "Unlocking…" else "Unlock graph") []) @@ -3655,7 +3690,7 @@ let manager_page ?picker_signal state dispatch = | _ -> Journal_startup.derive ~snapshot ~graph:state.graph_state in (match startup.phase with | _ when state.graph_picker_open -> graph_picker snapshot - | _ when state.e2ee_submission_pending -> unlock None + | _ when (unlock_pending state.unlock) -> unlock None | Awaiting_selection -> graph_picker snapshot | Awaiting_e2ee_password -> unlock None | Failed @@ -4345,8 +4380,7 @@ let start if unlock_owner state <> owner then state else { state with - e2ee_submission_pending = false - ; e2ee_submission_error = Some "Unable to unlock the graph. Try again." + unlock = unlock_rejected state.unlock }) () in let pending_startup_commands = ref [] in let send_manager command = @@ -5116,7 +5150,7 @@ let start when Option.fold ~none:false ~some:(fun (owned, _) -> owned = request_id) !unlock_request -> let _, owner = Option.get !unlock_request in unlock_request := None; - if owner = unlock_owner !state_ref && !state_ref.e2ee_submission_pending + if owner = unlock_owner !state_ref && (unlock_pending !state_ref.unlock) then Effect.of_thunk (reject_unlock owner) else Effect.ignore | Worker.Response { outcome = Completed (Asset_file _); _ } -> Effect.ignore @@ -5206,9 +5240,8 @@ let start let worker_error = service_error ~operation:"terminal" error in record_worker_error state ~operation:"terminal" worker_error |> fun state -> - let state = if state.e2ee_submission_pending then - { state with e2ee_submission_pending = false - ; e2ee_submission_error = Some "Unable to unlock the graph. Try again." } + let state = if (unlock_pending state.unlock) then + { state with unlock = unlock_rejected state.unlock } else state in terminal_graph_state state (Worker_graph_error (latest_worker_error state))) in @@ -5956,7 +5989,7 @@ let start else { state with routes = Journal_routes.update_detail state.routes next }) | _, Some { startup = { awaiting_e2ee_password = true; _ }; _ } | _, Some { startup = { failure = Some During_e2ee; _ }; _ } - when not state.e2ee_submission_pending -> + when not (unlock_pending state.unlock) -> let e2ee_password = Journal_capture.apply_text_edit state.e2ee_password edit in (* A mount-time echo produces an identical capture; rebuilding the record would republish the model and remount the field, which @@ -6390,14 +6423,10 @@ let start ] | Submit_e2ee_password -> let password = Journal_capture.source snapshot.e2ee_password in - let awaiting = - match snapshot.manager with - | Some { startup = { awaiting_e2ee_password = true; _ }; _ } -> true - | None | Some _ -> false - in + let challenge = Option.bind snapshot.manager (fun manager -> Unlock.unlock_challenge manager.unlock) in if - snapshot.e2ee_submission_pending - || (not awaiting) + (unlock_pending snapshot.unlock) + || Option.is_none challenge || String.equal (String.trim password) "" then Effect.ignore else ( @@ -6407,15 +6436,14 @@ let start Effect.bind (update (fun state -> { state with - e2ee_submission_pending = true - ; e2ee_submission_error = None + unlock = Unlock_queued (Option.get challenge) ; e2ee_password = Journal_capture.create ~session_number:state.next_local_sequence ~source:"" ; next_local_sequence = Int64.succ state.next_local_sequence })) - ~f:(fun () -> send_manager (Graph_service.Submit_e2ee_password password))) + ~f:(fun () -> send_manager (Graph_service.Submit_e2ee_password (Option.get challenge, password)))) | Request_local_cache_reset -> update (fun state -> { state with graph_picker_open = true; modal = No_modal }) | Timeline_retry value -> diff --git a/docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md b/docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md new file mode 100644 index 00000000..49df1156 --- /dev/null +++ b/docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md @@ -0,0 +1,142 @@ +# Ocaml Evidence Boundaries + +## Problem + +At main 5abaab43b2943c89df5ce236e51c25718fa4df56, snapshot metadata, +download scope, cursor and activation are independently constructible; unlock +submission state is spread across Core and Application options/booleans; asset +decode returns ordinary strings and runner error classification compares English +messages. PRs #107, #108 and #102 already fix the known runtime defects. The +requested change strengthens the interfaces while retaining those protections. + +## Proposal + +Keep all three changes local to their existing owners and modules. No dependency, +Dune, persistent format, transport format or Swift packet-layout change is planned. + +### Snapshot attempt + +In logseq_sync/spec/pure_reducer/core.mli, make snapshot_download and +snapshot_activation_request abstract. A metadata-waiting attempt captures its +unique identity and graph_scope; accepting metadata preserves its original +Server_cursor.t and URL in the downloading phase. The download interpreter reads +request accessors and returns an artifact receipt tied to that request, replacing +the arbitrary id/scope artifact factory. Core must compare the returned receipt +against the active attempt, not just the graph scope, before producing activation. +Activation carries the bound artifact/cursor/scope/key as one value. Successful +and failed activation receipts also carry the original abstract request, and Core +matches it against the current Activating phase before consuming either. Worker reads +accessors and retains its current key checks, staged database validation and atomic +publication. No public activation constructor accepts independent fields. + +Use an internal bootstrap phase ADT instead of snapshot_scope, +snapshot_server_t, active_snapshot_download and pending_snapshot_activation as +independent authority. Preserve ticket admission, generation/current scope checks +and cancellation. Same static phase does not establish same dynamic identity. +Keep metadata t as the catchup lower bound even when the live stream includes +later writes. Preserve the legacy baseline request behavior where still exposed. + +### Unlock phases + +Add abstract challenge/submission/attachment evidence within Core's existing +interface, with a narrow unlock phase projection. Core creates a challenge only +when the actual key packages are available. Submission consumes that challenge +and records an operation identity; the real successful Graph_attached handling +creates attachment evidence after current scope and pending attachment checks. +The presentable phase is reached before Timeline appearance acknowledgement. +After key success, a missing mirror uses a separate bootstrapping phase: it is +not yet presentable, but password_pending is false so download progress can show. +Failure/cancellation/reset retires the matching operation; a retry-password +projection retains a valid challenge. Retain runtime owner/operation checks for +late success and failure, and keep password/key material out of UI projections. + +Application consumes the typed phase/receipt, replacing its independent +e2ee_submission_pending and e2ee_submission_error authorities. Password text can +remain a separate editing model. Graph_service forwards the typed projection; +existing Swift/FFI UI booleans are derived from it. Preserve manager-first and +graph-first notification behavior from PR #108 and scoped Timeline_presented. +Do not make Ready a prerequisite for leaving the password screen. + +### Asset decoding and publication + +In logseq_sync/spec/effect_runner/asset_codec.mli, keep raw/envelope/plaintext roles internal and expose abstract verified plus +finite decode errors. Classification and decoding preserve +PR #102's conservative candidate policy, with no authentication/parse failure raw +fallback. Verified binds bytes to the expected asset version, produced only by +successful bounded decoding/validation. Provide read access for private cache IO, +with no arbitrary verified_of_string constructor. + +Change private Bootstrap.Asset_cache.publish to consume verified, deriving the +version from it. Retain independent current, maximum-file-size, checksum, disk, +lease and atomic publication checks. Both network download and the existing +local staged-import publication call must cross the verification boundary; avoid +leaving a plaintext publication overload as a bypass. Runner maps finite errors +to existing domain failures without comparing error wording. Keep crypto callback +byte adapters at the platform boundary rather than extending the whole crypto +system. The public asset_cache.mli currently exposes only abstract types; change +it only if an implementation type equality actually requires it. + +## Decision + +The user explicitly authorized the required protected spec .mli changes on +2026-10-23, as relayed by the parent with the direct question and answer. Proceed +within these three boundaries. Build/test/Simulator work still waits for an +explicit resource handoff; no Dune or pin changes are authorized. + +## Alternatives considered + +### Implementation-only wrappers + +Insufficient: Core's public activation record and arbitrary staged_artifact +factory would still admit independent data, and public codec decode would still +return ordinary bytes/string errors. The requested protection needs changes to +the protected virtual-library interfaces. + +### One global application state machine + +Rejected: local access, online sync, UI editing and presentation have independent +lifecycles. The change concerns only bootstrap attempts, unlock operations and +asset decode/publication boundaries. + +## Acceptance criteria + +- Separate compiler rejection evidence from dynamic behavior evidence: callers + cannot construct activation records/artifacts independently, submit without a + challenge, mint attached from a bool, or publish unverified strings. +- Run existing Core, recovery, codec, asset-cache, runner, worker and Application + regressions applicable to the changed interfaces. Adapt fixtures to the public + producer paths; do not expose construction backdoors just for fixtures. +- Temporary external fixtures cover two same-type attempts with distinct identity, + late metadata, graph replacement, stale download success/failure, original + metadata cursor catchup, full bootstrap, current/key scope and publication + invalidation. +- Unlock fixtures cover correct/wrong password, retry, cancellation, queue failure, + stale responses, owner replacement, both notification orders, password-screen + dismissal before Timeline, and first appearance acknowledgement. +- Asset fixtures cover clear and encrypted bytes, conservative JSON candidates, + authentication failure with a checksum matching the wire bytes, malformed/size + errors and stale cache publication. No candidate failure becomes raw success. +- After the parent releases shared resources, run a real Simulator workflow with + isolated synthetic data covering bootstrap, sign out/login/unlock and assets. + Do not touch real graph credentials or reset device + 1446A493-492B-4F2D-974D-3AED9DA5C97B. Reuse A135 after release, with at most + three devices and no new device for this work. +- Independent review, final applicable tests and actual Simulator evidence precede + a non-Draft PR. No merge or TestFlight. Host-restamp evidence remains explicitly + experimental-host-restamp with cross_abi_validated=false; unsafe simulations + are recorded as untested. + +## Risks + +- Abstract types do not revoke old values or establish identity, response freshness, + authentication, disk success or liveness. Existing dynamic checks remain required. +- Type migration must update real producers and consumers together; parallel old + factories or UI authorities would undermine the intended protection. +- Existing regression behavior is already correct, so behavioral tests may remain + green before the refactor; do not invent a regression or claim a RED run that + was not observed. No build/test process was started during resource hold. + +## Questions + +None. The user answered the protected-interface authorization question with +“允许”. Resource release is an execution gate, not an open design question. diff --git a/logseq_db_worker/lib/effect_runner/effect_runner.ml b/logseq_db_worker/lib/effect_runner/effect_runner.ml index c4313883..b2406627 100644 --- a/logseq_db_worker/lib/effect_runner/effect_runner.ml +++ b/logseq_db_worker/lib/effect_runner/effect_runner.ml @@ -1709,19 +1709,19 @@ let handle_sync_worker_effect t = function | Available _ -> Ok (sync_result ~event:(Sync.Mirror_inspected (Mirror_available request)) ())))) | Activate_snapshot request -> - let key = Graph.Uuid.to_string request.scope.graph_id in + let key = Graph.Uuid.to_string (Sync.snapshot_activation_scope request).graph_id in (match Hashtbl.find_opt t.inspections key with | None -> Error (effect_error "The snapshot mirror inspection is stale.") | Some inspection -> - let cursor = request.applied_server_t in + let cursor = (Sync.snapshot_activation_cursor request) in (match Database.prepare_snapshot_activation t.dependencies.overlay inspection - ~path:(Sync.staged_artifact_path request.artifact) + ~path:(Sync.staged_artifact_path (Sync.snapshot_activation_artifact request)) ~applied_server_cursor:cursor ~expected_checksum:None - ~expected_rows:(Sync.staged_artifact_expected_rows request.artifact) + ~expected_rows:(Sync.staged_artifact_expected_rows (Sync.snapshot_activation_artifact request)) with | Error (Overlay.Snapshot_parse_error message) -> Error (effect_error message) | Error _ -> Error (effect_error "The snapshot activation could not start.") @@ -1736,7 +1736,7 @@ let handle_sync_worker_effect t = function | Ok (Some crypto_request) -> let ciphertexts = Database.unprotection_ciphertexts crypto_request in let plaintexts = - match request.key with + match (Sync.snapshot_activation_key request) with | None -> Ok ciphertexts | Some graph_key -> let rec decrypt reversed = function @@ -1769,7 +1769,7 @@ let handle_sync_worker_effect t = function Hashtbl.replace t.inspections key inspection; Ok (sync_result - ~event:(Sync.Snapshot_activated { scope = request.scope }) + ~event:(Sync.Snapshot_activated (Sync.snapshot_activation_completed request)) ()))))) | Delete_mirror request -> let ( let* ) = Result.bind in diff --git a/logseq_db_worker/lib/pure_reducer/core.ml b/logseq_db_worker/lib/pure_reducer/core.ml index 67a8e24b..a66a3303 100644 --- a/logseq_db_worker/lib/pure_reducer/core.ml +++ b/logseq_db_worker/lib/pure_reducer/core.ml @@ -477,8 +477,7 @@ let step state event = translate_sync (Sync.step state.sync_core - (Sync.Snapshot_activation_failed - { scope = Sync.effect_scope_of_graph request.scope; message })) + (Sync.Snapshot_activation_failed (Sync.snapshot_activation_failed request ~message))) state | Some (Sync.Apply_authoritative_batch request) -> let transitioned = diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.ml b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml index fad5cc4b..6b4ede75 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.ml +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml @@ -2,6 +2,7 @@ module Db = Logseq_db_worker module Pure = Logseq_db_worker_pure_reducer.Core module Worker_runner = Logseq_db_worker_effect_runner.Effect_runner module Sync = Logseq_sync_pure_reducer.Core +module Unlock = Sync module Sync_runner = Logseq_sync_effect_runner.Effect_runner module Protocol = Db.Protocol module Overlay = Logseq_overlay_db.Database @@ -91,6 +92,7 @@ type snapshot = ; selected_graph : graph_id option ; applied_server_t : Logseq_db_types.Server_cursor.t option ; timeline_presentation_pending : bool + ; unlock : Unlock.unlock_phase ; startup : startup_facts ; last_error : string option ; local_deletion : local_deletion option @@ -161,6 +163,7 @@ let snapshot (value : Sync.snapshot) = ; selected_graph = value.selected_graph ; applied_server_t = value.applied_server_t ; timeline_presentation_pending = value.timeline_presentation_pending + ; unlock = value.unlock ; startup = startup_facts value.startup ; last_error = value.last_error ; local_deletion = value.local_deletion @@ -204,7 +207,7 @@ type client_command = | Return_to_graph_picker | Refresh_catalog of startup_scope | Begin_online_recovery of timeline_scope - | Submit_e2ee_password of string + | Submit_e2ee_password of Unlock.unlock_challenge * string | Delete_local_cache of Sync.graph_id | Set_foreground of bool @@ -626,8 +629,8 @@ let client_event = function | Return_to_graph_picker -> Pure.Sync_event Sync.Graph_picker_requested | Refresh_catalog scope -> Pure.Sync_event (Sync.Catalog_refresh_requested scope) | Begin_online_recovery scope -> Pure.Sync_event (Sync.Online_recovery_requested scope) - | Submit_e2ee_password password -> - Pure.Sync_event (Sync.E2ee_password_submitted password) + | Submit_e2ee_password (challenge, password) -> + Pure.Sync_event (Sync.E2ee_password_submitted (challenge, password)) | Delete_local_cache graph_id -> Pure.Sync_event (Sync.Local_cache_deletion_requested graph_id) | Set_foreground foreground -> Pure.Set_foreground foreground diff --git a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli index 8951e01e..ecd85094 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli @@ -1,6 +1,33 @@ type graph_id = Logseq_db_types.Graph_types.Uuid.t type graph = Logseq_db_types.Managed_graph.t +(** Narrow projection of the unlock operation; construction evidence stays with + the sync owner. UI clients need not depend on the sync library directly. *) +module Unlock : sig + type graph_scope = Logseq_sync_pure_reducer.Core.graph_scope + type unlock_challenge = Logseq_sync_pure_reducer.Core.unlock_challenge + type unlock_submission = Logseq_sync_pure_reducer.Core.unlock_submission + type unlock_attached = Logseq_sync_pure_reducer.Core.unlock_attached + type unlock_recovery = Logseq_sync_pure_reducer.Core.unlock_recovery = + | Retry_password of unlock_challenge * string + | Retry_preparation of string + type unlock_phase = Logseq_sync_pure_reducer.Core.unlock_phase = + | Unlock_inactive + | Unlock_challenge of unlock_challenge + | Unlock_running of unlock_submission + | Unlock_bootstrapping of unlock_submission + | Unlock_presentable of unlock_attached + | Unlock_failed of unlock_recovery + | Unlock_cancelled of unlock_submission + val unlock_challenge : unlock_phase -> unlock_challenge option + val equal_unlock_challenge : unlock_challenge -> unlock_challenge -> bool + val unlock_challenge_scope : unlock_challenge -> graph_scope + val unlock_submission_challenge : unlock_submission -> unlock_challenge + val equal_unlock_submission : unlock_submission -> unlock_submission -> bool + val unlock_attached_submission : unlock_attached -> unlock_submission + val unlock_attached_scope : unlock_attached -> graph_scope +end + type sync_phase = | Offline | Connecting @@ -67,6 +94,7 @@ type snapshot = ; selected_graph : graph_id option ; applied_server_t : Logseq_db_types.Server_cursor.t option ; timeline_presentation_pending : bool + ; unlock : Unlock.unlock_phase ; startup : startup_facts ; last_error : string option ; local_deletion : local_deletion option @@ -111,7 +139,7 @@ type client_command = | Return_to_graph_picker | Refresh_catalog of startup_scope | Begin_online_recovery of timeline_scope - | Submit_e2ee_password of string + | Submit_e2ee_password of Unlock.unlock_challenge * string | Delete_local_cache of graph_id | Set_foreground of bool diff --git a/logseq_db_worker/test/test_lui_service.ml b/logseq_db_worker/test/test_lui_service.ml index b81f15f2..6031b1a3 100644 --- a/logseq_db_worker/test/test_lui_service.ml +++ b/logseq_db_worker/test/test_lui_service.ml @@ -123,6 +123,41 @@ let test_managed_client_command_is_accepted () = | _ -> T.fail "managed client command returned the wrong response")) ;; +(* Build opaque unlock evidence through the production reducer, including its + actual key-package and attachment owners. No boolean-to-receipt fixture. *) +let synthetic_unlock_prompt graph_id = + let module C = Logseq_sync_pure_reducer.Core in + let limits = C.limits ~maximum_response_bytes:Logseq_db_types.Limits.maximum_response_bytes + ~maximum_artifact_bytes:1048576 ~submission_batch_size:32 |> Result.get_ok in + let initial = C.config ~managed_sync_origin:(Uri.of_string "https://example.invalid") ~limits + |> Result.get_ok |> C.initial |> Result.get_ok in + let permitted = C.step initial (C.Sign_in_requested + { account_generation = 0; presentation_generation = 0 }) in + let auth = C.step permitted.next (C.Account_authenticated { user_id = Some "synthetic-user" }) in + let graph : C.graph = { graph_id; name = "Private Journal"; encrypted = true + ; schema = { major = 65; minor = 33; exact = true } } in + let catalog = List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_catalog _)) -> Some (C.step auth.next + (C.Runner_completed (C.Completion (ticket, Ok [graph])))) + | _ -> None) auth.effects |> Option.get in + let selected = C.step catalog.next (C.Graph_selected graph_id) in + let mirror = List.find_map (function C.Delegate (C.Inspect_mirror request) -> Some request + | _ -> None) selected.effects |> Option.get in + let local = C.step selected.next (C.Mirror_inspected (C.Mirror_available mirror)) in + let prepare = List.find_map (function + | C.Run (C.Request (ticket, C.Load_and_unlock_graph_key _)) -> Some (C.step local.next + (C.Runner_completed (C.Completion (ticket, Error (C.Effect_failed "synthetic missing key"))))) + | _ -> None) local.effects |> Option.get in + let wrapped = List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_e2ee_graph_key _)) -> Some (C.step prepare.next + (C.Runner_completed (C.Completion (ticket, Ok "synthetic-wrapped")))) + | _ -> None) prepare.effects |> Option.get in + List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_e2ee_user_keys _)) -> Some (C.step wrapped.next + (C.Runner_completed (C.Completion (ticket, Ok "synthetic-package")))) + | _ -> None) wrapped.effects |> Option.get +;; + let test_unlock_ignored_logging () = let path = Filename.temp_file "journal-unlock-worker-log-" "" in let saved = Unix.dup Unix.stderr in @@ -144,7 +179,12 @@ let test_unlock_ignored_logging () = T.with_managed (fun fixture -> with_client fixture.config (fun client -> let id = Worker.send client (Service.Client_command - (Submit_e2ee_password "LOG_SECRET_PASSWORD")) |> accepted in + (Submit_e2ee_password + (Logseq_sync_pure_reducer.Core.unlock_challenge + (Logseq_sync_pure_reducer.Core.state + (synthetic_unlock_prompt (Logseq_db_types.Graph_types.Uuid.of_string + "00000000-0000-0000-0000-000000000901" |> Result.get_ok)).next).snapshot.unlock + |> Option.get, "LOG_SECRET_PASSWORD"))) |> accepted in (match await_response client id with | Service.Client_command_completed -> () | _ -> T.fail "password queue acknowledgement changed"); diff --git a/logseq_db_worker/test/test_managed_sync_e2e.ml b/logseq_db_worker/test/test_managed_sync_e2e.ml index 351ad68a..7bfe5ead 100644 --- a/logseq_db_worker/test/test_managed_sync_e2e.ml +++ b/logseq_db_worker/test/test_managed_sync_e2e.ml @@ -276,7 +276,9 @@ let handle_public_state context state = if snapshot.startup.awaiting_e2ee_password && not context.password_submitted then ( context.password_submitted <- true; - send_command context (Submit_e2ee_password context.credentials.e2ee_password)) + send_command context (Submit_e2ee_password + (Logseq_sync_pure_reducer.Core.unlock_challenge snapshot.unlock |> Option.get, + context.credentials.e2ee_password))) ;; let handle_graph_state context state = diff --git a/logseq_db_worker/test/test_pure_reducer.ml b/logseq_db_worker/test/test_pure_reducer.ml index 81be3adc..cd7194c4 100644 --- a/logseq_db_worker/test/test_pure_reducer.ml +++ b/logseq_db_worker/test/test_pure_reducer.ml @@ -1008,7 +1008,7 @@ let snapshot_activation () = (Sync.Runner_completed (Sync.Completion (ticket, Ok [graph]))))) | _ -> None) auth.effects |> Option.get in let selected = Core.step catalog.next (Sync_event (Sync.Graph_selected graph.graph_id)) in - let missing, scope = List.find_map (function + let missing, _scope = List.find_map (function | Core.Run_worker (Core.Request (ticket, Core.Handle_sync_worker_effect (Sync.Inspect_mirror request))) -> Some (Core.step selected.next (Runner_completed @@ -1022,9 +1022,8 @@ let snapshot_activation () = (Sync.Completion (ticket, Ok {|{"ok":true,"t":42,"url":"https://example.invalid/snapshot"}|}))))) | _ -> None) missing.effects |> Option.get in let activation = List.find_map (function - | Core.Run_sync (Sync.Request (ticket, Sync.Download_snapshot _)) -> - let artifact = Sync.staged_artifact ~id:"synthetic" ~scope - ~path:"/tmp/synthetic-schema.snapshot" ~expected_rows:1 in + | Core.Run_sync (Sync.Request (ticket, Sync.Download_snapshot download_request)) -> + let artifact = Sync.stage_snapshot_download download_request ~path:"/tmp/synthetic-schema.snapshot" ~expected_rows:1 |> Result.get_ok in Some (Core.step download.next (Sync_event (Sync.Runner_completed (Sync.Completion (ticket, Ok artifact))))) | _ -> None) download.effects |> Option.get in @@ -1036,7 +1035,7 @@ let snapshot_activation () = Core.Handle_sync_worker_effect (Sync.Activate_snapshot request))) -> Some (Core.Runner_completed (Sync_worker_effect_completed (ticket, Error error)), Core.Runner_completed (Sync_worker_effect_completed (ticket, Ok - { event = Some (Sync.Snapshot_activated {scope = request.scope}) + { event = Some (Sync.Snapshot_activated (Sync.snapshot_activation_completed request)) ; lifecycle = Lifecycle_unchanged }))) | _ -> None) activation.effects |> Option.get in activation, failure, success, Logseq_db_worker.Error.message error diff --git a/logseq_sync/lib/effect_runner/effect_runner.ml b/logseq_sync/lib/effect_runner/effect_runner.ml index eb487a62..af32ae8a 100644 --- a/logseq_sync/lib/effect_runner/effect_runner.ml +++ b/logseq_sync/lib/effect_runner/effect_runner.ml @@ -743,22 +743,22 @@ let execute_request ; Filename.concat staging ("snapshot-" ^ id ^ ".gzip-2") ] in - let base_url = download.scope.account.managed_sync_origin in - let bearer_authorized = Http.same_origin base_url download.uri in + let base_url = (Core.snapshot_download_scope download).account.managed_sync_origin in + let bearer_authorized = Http.same_origin base_url (Core.snapshot_download_uri download) in let download_once token = - let request = Http.artifact ~base_url ~uri:download.uri ~token in + let request = Http.artifact ~base_url ~uri:(Core.snapshot_download_uri download) ~token in let response = t.dependencies.transport.download ~sw:t.sw ~request ~destination:raw - ~maximum_bytes:download.maximum_bytes + ~maximum_bytes:(Core.snapshot_download_maximum_bytes download) ~on_progress:(fun progress -> if not t.closed then t.post (Core.Snapshot_download_progress - { graph_id = download.scope.graph_id + { graph_id = (Core.snapshot_download_scope download).graph_id ; received_bytes = Int64.of_int progress.received_bytes ; total_bytes = Option.map Int64.of_int progress.total_bytes })) @@ -784,27 +784,23 @@ let execute_request then authenticated_operation t.dependencies.id_token_provider - ~account:download.scope.account + ~account:(Core.snapshot_download_scope download).account ~perform:(fun token -> perform (Some token)) else Result.map_error authenticated_failure (perform None)) |> Result.map_error (fun message -> Core.Effect_failed message) in Result.bind response (fun response -> Result.bind (Bootstrap.artifact_row_count response.headers) (fun expected_rows -> - Result.map - (fun () -> - Bootstrap.cleanup (raw :: temporary_paths); - Core.staged_artifact - ~id - ~scope:download.scope - ~path:destination - ~expected_rows) + Result.bind (Bootstrap.peel_gzip_layers ~decompress_gzip:Artifact_decoder.decompress_gzip - ~maximum_bytes:download.maximum_bytes + ~maximum_bytes:(Core.snapshot_download_maximum_bytes download) ~source:raw ~destination - ~temporary_paths)) + ~temporary_paths) + (fun () -> + Bootstrap.cleanup (raw :: temporary_paths); + Core.stage_snapshot_download download ~path:destination ~expected_rows)) |> Result.map_error (fun message -> Core.Effect_failed message))) | Load_and_unlock_graph_key scope -> run_secret_action t (fun () -> @@ -1330,6 +1326,13 @@ let asset_cache_failure = function | Invalid message | Io message -> Core.Asset_invalid_content message ;; +let asset_decode_failure = function + | Asset_codec.Checksum_mismatch -> Core.Asset_checksum_mismatch + | (Invalid_limit | Wire_too_large | Plaintext_too_large | Malformed_envelope + | Malformed_transit _ | Decryption_failed _) as error -> + Core.Asset_invalid_content (Asset_codec.decode_error_message error) +;; + let asset_graph_key t scope = function | Plaintext -> Ok None | Encrypted None -> Error Core.Asset_locked @@ -1451,24 +1454,20 @@ let fetch_asset_admitted ; decrypt = t.dependencies.crypto.decrypt_aes_gcm } in - let* plaintext = + let* verified = Asset_codec.decode ~maximum_plaintext_bytes ~crypto ~key:graph_key - ~expected_checksum:version.checksum + ~expected:version response.body - |> Result.map_error (fun message -> - if message = "Asset checksum mismatch" - then Core.Asset_checksum_mismatch - else Core.Asset_invalid_content message) + |> Result.map_error asset_decode_failure in Asset_cache.publish cache ~asset - ~version ~current:(fun () -> (not t.closed) && current ()) - ~plaintext + ~verified |> Result.map_error asset_cache_failure)) ;; @@ -1881,7 +1880,17 @@ let execute_asset ?except_id t (request : Core.asset_io_request) ~current = let* plaintext = read_upload_source ~maximum_plaintext_bytes:t.dependencies.local_store.asset_maximum_file_bytes source_file in - let* handle = Asset_cache.publish cache ~asset ~version ~current ~plaintext + let crypto : Asset_codec.crypto = + { encrypt = t.dependencies.crypto.encrypt_aes_gcm + ; decrypt = t.dependencies.crypto.decrypt_aes_gcm } + in + (* This source is the retained local plaintext staging file, not + HTTP wire data. It still crosses bounded checksum verification. *) + let* verified = Asset_codec.decode + ~maximum_plaintext_bytes:t.dependencies.local_store.asset_maximum_file_bytes + ~crypto ~key:None ~expected:version plaintext + |> Result.map_error asset_decode_failure in + let* handle = Asset_cache.publish cache ~asset ~current ~verified |> Result.map_error asset_cache_failure in Asset_cache.release cache handle; Ok Core.Asset_unit)) diff --git a/logseq_sync/lib/effect_runner/protocol/asset_codec.ml b/logseq_sync/lib/effect_runner/protocol/asset_codec.ml index 4dc52ffe..72c6eafa 100644 --- a/logseq_sync/lib/effect_runner/protocol/asset_codec.ml +++ b/logseq_sync/lib/effect_runner/protocol/asset_codec.ml @@ -110,9 +110,68 @@ let transit_candidate wire = | _ -> false ;; -let decode ~maximum_plaintext_bytes ~crypto ~key ~expected_checksum wire = +type decode_error = + | Invalid_limit + | Wire_too_large + | Plaintext_too_large + | Malformed_envelope + | Malformed_transit of string + | Decryption_failed of string + | Checksum_mismatch + +let decode_error_message = function + | Invalid_limit -> "Invalid plaintext size limit" + | Wire_too_large -> "Asset exceeds wire size limit" + | Plaintext_too_large -> "Asset exceeds plaintext size limit" + | Malformed_envelope -> "Invalid asset AES-GCM envelope" + | Malformed_transit message -> "Invalid asset Transit: " ^ message + | Decryption_failed message -> message + | Checksum_mismatch -> "Asset checksum mismatch" +;; + +(* These roles stay inside the decoder: a failed candidate has no raw constructor + path, and ciphertext cannot be fed directly to verification. *) +type raw = Raw of string +type envelope = Envelope of { iv : string; ciphertext : string } +type plaintext = Plaintext of string +type classified = Clear of raw | Protected of envelope + +type verified = + { bytes : string + ; version : Logseq_db_types.Asset_descriptor.version + } + +let verified_bytes value = value.bytes +let verified_version value = value.version + +let classify ~maximum_plaintext_bytes ~key wire = + match key with + | None -> Ok (Clear (Raw wire)) + | Some _ when not (transit_candidate wire) -> + (try Printf.eprintf "journal asset decode path=raw-fallback\n%!" with + | _ -> ()); + Ok (Clear (Raw wire)) + | Some _ -> + let* () = if envelope_shape wire then Ok () else Error Malformed_envelope in + let* value = + try Ok (Codec.of_string wire) with + | Transit.Decode_error message + | Yojson.Json_error message + | Failure message + | Invalid_argument message -> Error (Malformed_transit message) + in + (match value with + | Transit.Array [ Binary iv; Binary ciphertext ] + when String.length iv = 12 + && String.length ciphertext >= 16 + && String.length ciphertext - 16 <= maximum_plaintext_bytes -> + Ok (Protected (Envelope { iv; ciphertext })) + | _ -> Error Malformed_envelope) +;; + +let decode ~maximum_plaintext_bytes ~crypto ~key ~expected wire = if not (limit maximum_plaintext_bytes) - then Error "Invalid plaintext size limit" + then Error Invalid_limit else ( let maximum_wire = match key with @@ -120,37 +179,21 @@ let decode ~maximum_plaintext_bytes ~crypto ~key ~expected_checksum wire = | Some _ -> (4 * ((maximum_plaintext_bytes + 16 + 2) / 3)) + 128 in if String.length wire > maximum_wire - then Error "Asset exceeds wire size limit" + then Error Wire_too_large else - let* plaintext = - match key with - | None -> Ok wire - | Some _ when not (transit_candidate wire) -> - (try Printf.eprintf "journal asset decode path=raw-fallback\n%!" with - | _ -> ()); - Ok wire - | Some key -> - let* () = - if envelope_shape wire then Ok () else Error "Invalid asset AES-GCM envelope" - in - let* value = - try Ok (Codec.of_string wire) with - | Transit.Decode_error message - | Yojson.Json_error message - | Failure message - | Invalid_argument message -> Error ("Invalid asset Transit: " ^ message) - in - (match value with - | Transit.Array [ Binary iv; Binary ciphertext ] - when String.length iv = 12 - && String.length ciphertext >= 16 - && String.length ciphertext - 16 <= maximum_plaintext_bytes -> - crypto.decrypt ~key ~iv ~ciphertext - | _ -> Error "Invalid asset AES-GCM envelope") + let* classified = classify ~maximum_plaintext_bytes ~key wire in + let* Plaintext plaintext = + match classified, key with + | Clear (Raw bytes), _ -> Ok (Plaintext bytes) + | Protected (Envelope { iv; ciphertext }), Some key -> + crypto.decrypt ~key ~iv ~ciphertext + |> Result.map (fun bytes -> Plaintext bytes) + |> Result.map_error (fun message -> Decryption_failed message) + | Protected _, None -> Error Malformed_envelope in if String.length plaintext > maximum_plaintext_bytes - then Error "Asset exceeds plaintext size limit" - else if checksum plaintext <> expected_checksum - then Error "Asset checksum mismatch" - else Ok plaintext) + then Error Plaintext_too_large + else if checksum plaintext <> expected.Logseq_db_types.Asset_descriptor.checksum + then Error Checksum_mismatch + else Ok { bytes = plaintext; version = expected }) ;; diff --git a/logseq_sync/lib/effect_runner/storage/bootstrap.ml b/logseq_sync/lib/effect_runner/storage/bootstrap.ml index dd01dce1..a3d6f483 100644 --- a/logseq_sync/lib/effect_runner/storage/bootstrap.ml +++ b/logseq_sync/lib/effect_runner/storage/bootstrap.ml @@ -375,7 +375,9 @@ module Asset_cache = struct if needed > t.budget then Error Full else evict usage candidates ;; - let publish t ~asset ~(version : Asset.version) ~current ~plaintext = + let publish t ~asset ~current ~verified = + let version = Asset_codec.verified_version verified in + let plaintext = Asset_codec.verified_bytes verified in protect (fun () -> if t.closed || not (current ()) then Error Stale diff --git a/logseq_sync/lib/effect_runner/storage/bootstrap.mli b/logseq_sync/lib/effect_runner/storage/bootstrap.mli index 241a27b3..564e1391 100644 --- a/logseq_sync/lib/effect_runner/storage/bootstrap.mli +++ b/logseq_sync/lib/effect_runner/storage/bootstrap.mli @@ -47,9 +47,8 @@ module Asset_cache : sig val publish : t -> asset:Logseq_db_types.Graph_types.Uuid.t - -> version:Logseq_db_types.Asset_descriptor.version -> current:(unit -> bool) - -> plaintext:string + -> verified:Asset_codec.verified -> (handle, error) result val path : t -> handle -> string option diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index 9e80bb1d..726566d4 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -24,6 +24,25 @@ type connection_generation = int type presentation_generation = int type lifecycle_generation = int64 +type account_scope = + { managed_sync_origin : Uri.t + ; user_id : string + ; account_generation : account_generation + ; presentation_generation : presentation_generation + ; lifecycle_generation : lifecycle_generation + } + +type graph_scope = + { account : account_scope + ; graph_id : graph_id + ; graph_generation : graph_generation + } + +type connection_scope = + { graph : graph_scope + ; connection_generation : connection_generation + } + type sync_phase = | Offline | Connecting @@ -81,12 +100,58 @@ type local_deletion = | Deletion_in_progress of local_deletion_stage | Deletion_failed of local_deletion_stage +type unlock_challenge = + { challenge_scope : graph_scope + ; challenge_id : int + ; encrypted_graph_key : string + ; private_key_package : string + } + +type unlock_stage = Private_key of int | Graph_key of int | Attachment + +type unlock_submission = + { challenge : unlock_challenge + ; operation_id : int + ; stage : unlock_stage + } + +type unlock_attached = { submission : unlock_submission; attached_scope : graph_scope } + +type unlock_recovery = + | Retry_password of unlock_challenge * string + | Retry_preparation of string + +type unlock_phase = + | Unlock_inactive + | Unlock_challenge of unlock_challenge + | Unlock_running of unlock_submission + | Unlock_bootstrapping of unlock_submission + | Unlock_presentable of unlock_attached + | Unlock_failed of unlock_recovery + | Unlock_cancelled of unlock_submission + +let unlock_challenge = function + | Unlock_challenge challenge | Unlock_failed (Retry_password (challenge, _)) -> Some challenge + | _ -> None +;; + +let equal_unlock_challenge a b = + a.challenge_id = b.challenge_id && a.challenge_scope = b.challenge_scope +let unlock_challenge_scope challenge = challenge.challenge_scope +let unlock_submission_challenge submission = submission.challenge +let equal_unlock_submission a b = + a.operation_id = b.operation_id && equal_unlock_challenge a.challenge b.challenge +;; +let unlock_attached_submission receipt = receipt.submission +let unlock_attached_scope receipt = receipt.attached_scope + type snapshot = { sync_phase : sync_phase ; catalog : graph list ; selected_graph : graph_id option ; applied_server_t : Server_cursor.t option ; timeline_presentation_pending : bool + ; unlock : unlock_phase ; startup : startup_facts ; last_error : string option ; local_deletion : local_deletion option @@ -149,25 +214,6 @@ type invalidation = ; changed_uuids_truncated : bool } -type account_scope = - { managed_sync_origin : Uri.t - ; user_id : string - ; account_generation : account_generation - ; presentation_generation : presentation_generation - ; lifecycle_generation : lifecycle_generation - } - -type graph_scope = - { account : account_scope - ; graph_id : graph_id - ; graph_generation : graph_generation - } - -type connection_scope = - { graph : graph_scope - ; connection_generation : connection_generation - } - type effect_scope = { account_generation : account_generation option ; graph_generation : graph_generation option @@ -191,13 +237,21 @@ type graph_key_handle = ; handle_scope : graph_scope } +type snapshot_download = + { scope : graph_scope + ; uri : Uri.t + ; expected_bytes : int64 option + ; maximum_bytes : int + ; metadata_cursor : Server_cursor.t + ; attempt_id : effect_id + ; download_id : effect_id + } + type staged_artifact = - { artifact_id : string - ; artifact_scope : graph_scope + { download : snapshot_download ; artifact_path : string ; artifact_expected_rows : int } -[@@warning "-69"] type catalog_cache = { cache_user_id : string @@ -210,14 +264,16 @@ let graph_key_handle ~id ~scope = { handle_id = id; handle_scope = scope } let graph_key_handle_id handle = handle.handle_id let graph_key_handle_scope handle = handle.handle_scope -let staged_artifact ~id ~scope ~path ~expected_rows = - { artifact_id = id - ; artifact_scope = scope - ; artifact_path = path - ; artifact_expected_rows = expected_rows - } +let stage_snapshot_download download ~path ~expected_rows = + if path = "" || expected_rows < 0 then Error "Invalid staged snapshot artifact" + else Ok { download; artifact_path = path; artifact_expected_rows = expected_rows } ;; +let snapshot_download_scope request = request.scope +let snapshot_download_uri request = request.uri +let snapshot_download_expected_bytes request = request.expected_bytes +let snapshot_download_maximum_bytes request = request.maximum_bytes + let staged_artifact_path artifact = artifact.artifact_path let staged_artifact_expected_rows artifact = artifact.artifact_expected_rows @@ -302,13 +358,6 @@ type decrypted_values = string list type snapshot_baseline = string type snapshot_metadata = string -type snapshot_download = - { scope : graph_scope - ; uri : Uri.t - ; expected_bytes : int64 option - ; maximum_bytes : int - } - type graph_key_request = { scope : graph_scope ; encrypted_graph_key : string @@ -591,6 +640,30 @@ type snapshot_activation_request = ; key : graph_key_handle option } +let snapshot_activation_scope request = request.scope +let snapshot_activation_cursor request = request.applied_server_t +let snapshot_activation_artifact request = request.artifact +let snapshot_activation_key request = request.key + +(* Each phase owns all evidence needed by the next phase. Effect ids identify + attempts at runtime; sharing an OCaml type never establishes identity. *) +type bootstrap = + | No_bootstrap + | Preparing_snapshot of graph_scope + | Awaiting_snapshot_metadata of graph_scope * effect_id + | Downloading_snapshot of snapshot_download + | Activating_snapshot of snapshot_activation_request + +let bootstrap_scope = function + | No_bootstrap -> None + | Preparing_snapshot scope | Awaiting_snapshot_metadata (scope, _) -> Some scope + | Downloading_snapshot request -> Some request.scope + | Activating_snapshot request -> Some request.scope +;; + +let bootstrap_downloading = function Downloading_snapshot _ -> true | _ -> false +let bootstrap_activating = function Activating_snapshot _ -> true | _ -> false + type mirror_deletion = { account : account_scope ; graph_id : graph_id @@ -678,7 +751,10 @@ type outbox_transition_result = ; sync : Overlay.sync_view } -type snapshot_activation = { scope : graph_scope } +type snapshot_activation = snapshot_activation_request +type snapshot_activation_error = { activation : snapshot_activation_request; message : string } +let snapshot_activation_completed (request : snapshot_activation_request) : snapshot_activation = request +let snapshot_activation_failed activation ~message = { activation; message } type startup_scope = { account_generation : account_generation @@ -706,7 +782,7 @@ type event = | Online_recovery_requested of timeline_scope | Online_cancelled of startup_scope | Local_account_unavailable of startup_scope * local_unavailability - | E2ee_password_submitted of string + | E2ee_password_submitted of unlock_challenge * string | Local_cache_deletion_requested of graph_id | Foreground_changed of { foreground : bool @@ -724,7 +800,7 @@ type event = | Authoritative_batch_deferred of authoritative_deferred_result | Authoritative_batch_failed of scoped_error | Snapshot_activated of snapshot_activation - | Snapshot_activation_failed of scoped_error + | Snapshot_activation_failed of snapshot_activation_error | Runner_completed of runner_completion | Snapshot_download_progress of bootstrap_progress | Websocket_opened of connection_scope @@ -779,6 +855,11 @@ let asset_retirement_key (scope : graph_scope) retirement = (retirement_id_to_string retirement) ;; +type unlock_preparation = + | No_unlock_preparation + | Fetching_graph_key of graph_scope * effect_id + | Fetching_user_keys of graph_scope * string * effect_id + type t = { asset_retirement_observed : unit Asset_retirement_set.t ; asset_known_scopes : graph_scope list @@ -802,12 +883,8 @@ type t = ; pending_mirror_inspection : mirror_request option ; pending_selected_download : graph_scope option ; pending_attachment : mirror_request option - ; pending_encrypted_graph_key : string option - ; pending_private_key_package : string option - ; snapshot_scope : graph_scope option - ; snapshot_server_t : Server_cursor.t option - ; active_snapshot_download : graph_scope option - ; pending_snapshot_activation : graph_scope option + ; unlock_preparation : unlock_preparation + ; bootstrap : bootstrap ; sync_view : Overlay.sync_view option ; pending_sync_inspection : graph_scope option ; pending_outbox_transition : outbox_transition_request option @@ -855,6 +932,7 @@ let initial config = ; selected_graph = None ; applied_server_t = None ; timeline_presentation_pending = false + ; unlock = Unlock_inactive ; startup = initial_startup ; last_error = None } @@ -882,12 +960,8 @@ let initial config = ; pending_graph_open = None ; pending_mirror_inspection = None ; pending_attachment = None - ; pending_encrypted_graph_key = None - ; pending_private_key_package = None - ; snapshot_scope = None - ; snapshot_server_t = None - ; active_snapshot_download = None - ; pending_snapshot_activation = None + ; unlock_preparation = No_unlock_preparation + ; bootstrap = No_bootstrap ; sync_view = None ; pending_sync_inspection = None ; pending_outbox_transition = None @@ -930,6 +1004,14 @@ let publish core = Publish (State_changed core.public_state) let set_snapshot core (snapshot : snapshot) = let previous = core.public_state.snapshot.startup in + let unlock = + if previous.account_generation <> snapshot.startup.account_generation + || previous.graph_generation <> snapshot.startup.graph_generation + then Unlock_inactive else snapshot.unlock + in + let snapshot = { snapshot with unlock; startup = { snapshot.startup with + awaiting_e2ee_password = Option.is_some (unlock_challenge unlock) } } in + let previous = core.public_state.snapshot.startup in let diagnostics = if previous.account_generation <> snapshot.startup.account_generation || previous.graph_generation <> snapshot.startup.graph_generation @@ -961,7 +1043,8 @@ let local_unavailable core reason message = let startup={snapshot.startup with local_access=Local_unavailable reason; restoring_local=false;bootstrapping=false;catalog_loading=false;awaiting_e2ee_password=false; failure=Some (if reason=Local_catalog_unavailable then During_catalog else During_local_restore)} in - let next=set_snapshot core {snapshot with startup;last_error=Some message;sync_phase=Offline} in + let next=set_snapshot core {snapshot with startup; unlock = Unlock_failed (Retry_preparation message); + last_error=Some message;sync_phase=Offline} in {next;effects=[publish next]} ;; @@ -982,6 +1065,7 @@ let fail core stage message = { core.public_state.snapshot with sync_phase = Failed ; startup + ; unlock = Unlock_failed (Retry_preparation message) ; last_error = Some message } in @@ -994,12 +1078,8 @@ let fail core stage message = ; pending_mirror_inspection = None ; pending_selected_download = None ; pending_attachment = None - ; pending_encrypted_graph_key = None - ; pending_private_key_package = None - ; snapshot_scope = None - ; snapshot_server_t = None - ; active_snapshot_download = None - ; pending_snapshot_activation = None + ; unlock_preparation = No_unlock_preparation + ; bootstrap = No_bootstrap ; pending_sync_inspection = None ; pending_outbox_transition = None ; active_authoritative_batch = None @@ -1056,7 +1136,7 @@ let issue_request : type a. t -> a runner_request -> t * runner_effect = let ticket = { id = core.next_effect_id ; scope = - (match request, core.snapshot_scope with + (match request, (bootstrap_scope core.bootstrap) with | (Fetch_e2ee_user_keys _ | Unlock_private_key _), Some graph -> (* Shared account material is requested for this graph's challenge. *) effect_scope_of_graph graph @@ -1073,21 +1153,25 @@ let issue_request : type a. t -> a runner_request -> t * runner_effect = let begin_snapshot_bootstrap core scope = if not (network_allowed core) then local_unavailable core Local_mirror_unavailable "This graph is not downloaded. Connect to download it." else - let core = { core with snapshot_scope = Some scope; snapshot_server_t = None } in + let unlock = match core.public_state.snapshot.unlock with + | Unlock_running submission when submission.stage = Attachment -> Unlock_bootstrapping submission + | phase -> phase in + let core = set_snapshot + { core with bootstrap = Awaiting_snapshot_metadata (scope, core.next_effect_id) } + { core.public_state.snapshot with unlock } in let next, request = issue_request core (Fetch_snapshot_metadata scope) in - { next; effects = [ Run request ] } + let effects = match unlock with + | Unlock_bootstrapping _ -> [ publish next; Run request ] + | _ -> [ Run request ] in + { next; effects } ;; let begin_e2ee_key_access core scope = if not (network_allowed core) then local_unavailable core Local_key_unavailable "The local encryption key is unavailable. Connect to recover access." else - let core = - { core with - snapshot_scope = Some scope - ; snapshot_server_t = None - ; pending_encrypted_graph_key = None - ; pending_private_key_package = None - } - in + let core = set_snapshot + { core with bootstrap = Preparing_snapshot scope + ; unlock_preparation = Fetching_graph_key (scope, core.next_effect_id) } + { core.public_state.snapshot with unlock = Unlock_inactive } in let next, request = issue_request core (Fetch_e2ee_graph_key scope) in { next; effects = [ Run request ] } ;; @@ -1182,12 +1266,8 @@ let reset_graph_operations core = graph_key = None ; pending_graph_open = None ; pending_attachment = None - ; pending_encrypted_graph_key = None - ; pending_private_key_package = None - ; snapshot_scope = None - ; snapshot_server_t = None - ; active_snapshot_download = None - ; pending_snapshot_activation = None + ; unlock_preparation = No_unlock_preparation + ; bootstrap = No_bootstrap ; sync_view = None ; pending_sync_inspection = None ; pending_outbox_transition = None @@ -1478,7 +1558,7 @@ let mirror_inspected core = function let snapshot = core.public_state.snapshot in let startup = { snapshot.startup with authenticated = false; restoring_local = false; bootstrapping = true } in - let next = set_snapshot { core with snapshot_scope = Some scope } + let next = set_snapshot { core with bootstrap = Preparing_snapshot scope } { snapshot with startup } in { next; effects = [publish next] } else @@ -1511,6 +1591,12 @@ let graph_attached core (attachment : graph_attachment) = sync_phase = (if network_allowed core then Current else Offline) ; applied_server_t = Some (Overlay.sync_view_checkpoint attachment.sync) ; timeline_presentation_pending = true + ; unlock = (match core.public_state.snapshot.unlock with + | (Unlock_running submission | Unlock_bootstrapping submission) + when submission.stage = Attachment + && submission.challenge.challenge_scope = attachment.scope -> + Unlock_presentable { submission; attached_scope = attachment.scope } + | phase -> phase) ; startup ; last_error = None } @@ -2325,13 +2411,35 @@ let consume_ticket : type a. t -> a effect_ticket -> t option = let snapshot_ticket_is_current : type a. t -> a effect_ticket -> bool = fun core ticket -> + let current scope = graph_scope_is_current core scope + && ticket.scope = effect_scope_of_graph scope in match ticket.kind with - | Fetch_snapshot_baseline_kind | Fetch_snapshot_metadata_kind | Download_snapshot_kind -> - (match core.snapshot_scope with - | Some scope -> - graph_scope_is_current core scope - && ticket.scope = effect_scope_of_graph scope - | None -> false) + | Fetch_snapshot_metadata_kind -> + (match core.bootstrap with + | Awaiting_snapshot_metadata (scope, id) -> current scope && ticket.id = id + | _ -> false) + | Download_snapshot_kind -> + (match core.bootstrap with + | Downloading_snapshot request -> current request.scope && ticket.id = request.download_id + | _ -> false) + | Fetch_snapshot_baseline_kind -> + Option.fold ~none:false ~some:current (bootstrap_scope core.bootstrap) + | Fetch_e2ee_graph_key_kind -> + (match core.unlock_preparation with + | Fetching_graph_key (scope, id) -> current scope && id = ticket.id + | _ -> false) + | Fetch_e2ee_user_keys_kind -> + (match core.unlock_preparation with + | Fetching_user_keys (scope, _, id) -> current scope && id = ticket.id + | _ -> false) + | Unlock_private_key_kind | Fetch_and_unlock_graph_key_kind -> + (match core.public_state.snapshot.unlock with + | Unlock_running submission | Unlock_cancelled submission -> + current submission.challenge.challenge_scope + && (match submission.stage with + | Private_key id | Graph_key id -> id = ticket.id + | Attachment -> false) + | _ -> false) | _ -> true ;; @@ -2376,6 +2484,7 @@ let clear_selected_graph_for_catalog core catalog = ; selected_graph = None ; applied_server_t = None ; timeline_presentation_pending = false + ; unlock = Unlock_inactive ; startup ; last_error = None } @@ -2534,102 +2643,84 @@ let complete_runner : type a. t -> a effect_ticket -> a -> transition = | Save_catalog_kind -> deletion_selection_saved core | Fetch_catalog_kind -> apply_fetched_catalog core value | Fetch_snapshot_baseline_kind -> - (match - decode_snapshot_baseline value, core.current_graph_scope, core.snapshot_scope - with - | Ok server_t, Some graph, Some scope when scope = graph -> - let next, request_effect = issue_request core (Fetch_snapshot_metadata scope) in - { next = { next with snapshot_server_t = Some server_t } - ; effects = [ Run request_effect ] - } - | Error message, _, _ -> fail core During_bootstrap message - | Ok _, _, _ -> unchanged core) + (match decode_snapshot_baseline value, bootstrap_scope core.bootstrap with + | Ok _, Some scope when graph_scope_is_current core scope -> + begin_snapshot_bootstrap core scope + | Error message, _ -> fail core During_bootstrap message + | _ -> unchanged core) | Fetch_snapshot_metadata_kind -> - (match decode_snapshot_uri value, core.snapshot_scope with - | Ok (uri, server_t), Some scope -> + (match decode_snapshot_uri value, core.bootstrap with + | Ok (uri, metadata_cursor), Awaiting_snapshot_metadata (scope, attempt_id) -> let request = - { scope - ; uri + { scope; uri; metadata_cursor; attempt_id; download_id = core.next_effect_id ; expected_bytes = None - ; maximum_bytes = core.config.limits.maximum_artifact_bytes - } + ; maximum_bytes = core.config.limits.maximum_artifact_bytes } in let next, request_effect = issue_request core (Download_snapshot request) in - (* Metadata precedes a live stream that may contain later writes. Keep - this cursor so catchup replays all intervening journal entries. *) - { next = - { next with active_snapshot_download = Some scope - ; snapshot_server_t = Some server_t - } - ; effects = [ Run request_effect ] - } + (* Metadata is a lower bound for the live stream, never its maximum t. *) + { next = { next with bootstrap = Downloading_snapshot request } + ; effects = [ Run request_effect ] } | Error message, _ -> fail core During_bootstrap message - | Ok _, None -> unchanged core) + | _ -> unchanged core) | Download_snapshot_kind -> - let core = { core with active_snapshot_download = None } in - (match - core.current_graph_scope, core.snapshot_server_t, core.selected_graph_value - with - | Some scope, Some applied_server_t, Some graph when graph.encrypted -> - (match core.graph_key with - | Some key when graph_key_handle_scope key = scope -> - { next = { core with pending_snapshot_activation = Some scope } - ; effects = - [ Delegate - (Activate_snapshot - { artifact = value; scope; applied_server_t; key = Some key }) - ] - } - | None -> + (match core.bootstrap, core.selected_graph_value with + | Downloading_snapshot request, Some graph + when graph_scope_is_current core request.scope + && value.download.attempt_id = request.attempt_id + && value.download = request -> + let key = if graph.encrypted then core.graph_key else None in + (match key with + | None when graph.encrypted -> fail core During_bootstrap "encrypted snapshot requires a graph key handle" - | Some _ -> - fail - core - During_bootstrap - "graph key handle is outside the selected graph scope") - | Some scope, Some applied_server_t, Some _ -> - { next = { core with pending_snapshot_activation = Some scope } - ; effects = - [ Delegate - (Activate_snapshot - { artifact = value; scope; applied_server_t; key = None }) - ] - } + | Some key when graph_key_handle_scope key <> request.scope -> + fail core During_bootstrap "graph key handle is outside the selected graph scope" + | _ -> + let activation = + { artifact = value; scope = request.scope + ; applied_server_t = request.metadata_cursor; key } + in + { next = { core with bootstrap = Activating_snapshot activation } + ; effects = [ Delegate (Activate_snapshot activation) ] }) + | Downloading_snapshot _, _ -> + fail core During_bootstrap "snapshot artifact is outside the active attempt" | _ -> unchanged core) | Load_and_unlock_graph_key_kind -> graph_key_loaded core value | Fetch_e2ee_graph_key_kind -> - (match core.snapshot_scope with - | Some scope -> - let core = { core with pending_encrypted_graph_key = Some value } in - let account = scope.account in - let next, request = issue_request core (Fetch_e2ee_user_keys account) in + (match core.unlock_preparation with + | Fetching_graph_key (scope, _) -> + let core = { core with unlock_preparation = + Fetching_user_keys (scope, value, core.next_effect_id) } in + let next, request = issue_request core (Fetch_e2ee_user_keys scope.account) in { next; effects = [ Run request ] } - | None -> unchanged core) + | _ -> unchanged core) | Fetch_e2ee_user_keys_kind -> - let startup = - { core.public_state.snapshot.startup with - awaiting_e2ee_password = true - ; failure = None - } - in - let next = - set_snapshot - { core with pending_private_key_package = Some value } - { core.public_state.snapshot with startup; last_error = None } - in - { next; effects = [ publish next ] } + (match core.unlock_preparation with + | Fetching_user_keys (scope, encrypted_graph_key, _) -> + let challenge = { challenge_scope = scope; challenge_id = ticket.id + ; encrypted_graph_key; private_key_package = value } in + let next = set_snapshot { core with unlock_preparation = No_unlock_preparation } + { core.public_state.snapshot with unlock = Unlock_challenge challenge; last_error = None + ; startup = { core.public_state.snapshot.startup with failure = None } } in + { next; effects = [ publish next ] } + | _ -> unchanged core) | Unlock_private_key_kind -> - (match core.snapshot_scope, core.pending_encrypted_graph_key with - | Some scope, Some encrypted_graph_key -> - let next, request = - issue_request core (Fetch_and_unlock_graph_key { scope; encrypted_graph_key }) - in + (match core.public_state.snapshot.unlock with + | (Unlock_running submission | Unlock_cancelled submission) as phase -> + let scope = submission.challenge.challenge_scope in + let submission = { submission with stage = Graph_key core.next_effect_id } in + let unlock = match phase with Unlock_cancelled _ -> Unlock_cancelled submission + | _ -> Unlock_running submission in + let core = set_snapshot core { core.public_state.snapshot with unlock } in + let next, request = issue_request core (Fetch_and_unlock_graph_key + { scope; encrypted_graph_key = submission.challenge.encrypted_graph_key }) in { next; effects = [ Run request ] } | _ -> unchanged core) | Fetch_and_unlock_graph_key_kind -> - let core = - { core with pending_encrypted_graph_key = None; pending_private_key_package = None } - in + let unlock = match core.public_state.snapshot.unlock with + | Unlock_running submission -> Unlock_running { submission with stage = Attachment } + | Unlock_cancelled submission -> Unlock_cancelled { submission with stage = Attachment } + | phase -> phase in + let core = set_snapshot core { core.public_state.snapshot with unlock } in graph_key_loaded core value | Delete_account_secrets_kind -> unchanged core | Encrypt_protected_values_kind -> unchanged core @@ -2646,11 +2737,6 @@ let consume_completion core = function (match result with | Ok value -> complete_runner core ticket value | Error (Effect_failed message | Crypto_failed (_, message)) -> - let core = - match ticket.kind with - | Download_snapshot_kind -> { core with active_snapshot_download = None } - | _ -> core - in (match ticket.kind with | Save_catalog_kind -> (match core.public_state.snapshot.local_deletion with @@ -2695,24 +2781,14 @@ let consume_completion core = function | Unlock_private_key_kind when core.public_state.snapshot.startup.network_phase = Local_only -> fail core During_e2ee message | Unlock_private_key_kind -> - (* A rejected password leaves the encrypted challenge available for - another attempt. No password or unlocked key is retained. *) - let failed = fail core During_e2ee message in - let startup = - { failed.next.public_state.snapshot.startup with - awaiting_e2ee_password = true - } - in - let next = - set_snapshot - { failed.next with - snapshot_scope = core.snapshot_scope - ; pending_encrypted_graph_key = core.pending_encrypted_graph_key - ; pending_private_key_package = core.pending_private_key_package - } - { failed.next.public_state.snapshot with startup } - in - { next; effects = [ publish next ] } + (match core.public_state.snapshot.unlock with + | Unlock_running submission -> + let failed = fail core During_e2ee message in + let next = set_snapshot { failed.next with bootstrap = core.bootstrap } + { failed.next.public_state.snapshot with + unlock = Unlock_failed (Retry_password (submission.challenge, message)) } in + { next; effects = [ publish next ] } + | _ -> fail core During_e2ee message) | Fetch_e2ee_graph_key_kind | Fetch_e2ee_user_keys_kind | Fetch_and_unlock_graph_key_kind -> fail core During_e2ee message @@ -2825,7 +2901,7 @@ let begin_local_deletion core graph_id = && not snapshot.startup.bootstrapping && not snapshot.startup.awaiting_e2ee_password && core.pending_attachment = None - && core.active_snapshot_download = None -> + && not (bootstrap_downloading core.bootstrap) -> let account = account_scope core user_id in (* A private flight identity leaves the live graph's generations untouched. *) let account = { account with lifecycle_generation = Int64.of_int core.next_effect_id } in @@ -2915,12 +2991,12 @@ let step core event = || List.exists (fun (Pending_effect ticket) -> match ticket.kind with | Load_and_unlock_graph_key_kind | Fetch_and_unlock_graph_key_kind | Unlock_private_key_kind -> true | _ -> false) local_pending - || Option.is_some next.pending_snapshot_activation + || bootstrap_activating next.bootstrap in let local_access = match snapshot.startup.local_access with | Local_pending when not local_restore_pending -> Local_unavailable (if Option.is_some next.pending_graph_open - then Local_key_unavailable else if Option.is_some next.snapshot_scope + then Local_key_unavailable else if Option.is_some (bootstrap_scope next.bootstrap) then Local_mirror_unavailable else Local_restore_failed) | access -> access in @@ -2935,13 +3011,15 @@ let step core event = let next = set_snapshot { next with pending_selected_download = None; pending_effects = local_pending; websocket_live = false; deferred_catalog_reconciliation = false; - pending_encrypted_graph_key = (if List.exists (fun (Pending_effect ticket) -> - match ticket.kind with Unlock_private_key_kind -> true | _ -> false) local_pending - then next.pending_encrypted_graph_key else None); pending_private_key_package = None; - active_snapshot_download = None; pull_wanted = None; + unlock_preparation = No_unlock_preparation; + bootstrap = (if bootstrap_downloading next.bootstrap then No_bootstrap else next.bootstrap); pull_wanted = None; pull_owner = (match next.pull_owner with | Some {pull_stage=Applying_pull; _} as owner -> owner | _ -> None) - } { snapshot with timeline_presentation_pending = false; startup; sync_phase = Offline } in + } { snapshot with timeline_presentation_pending = false; startup; sync_phase = Offline + ; unlock = (match snapshot.unlock with + | Unlock_running submission | Unlock_bootstrapping submission -> Unlock_cancelled submission + | Unlock_cancelled _ as phase -> phase + | _ -> Unlock_inactive) } in let scope = { account_generation = Some snapshot.startup.account_generation; graph_generation = None; connection_generation = None; presentation_generation = None; lifecycle_generation = None } in @@ -3001,20 +3079,21 @@ let step core event = | Authoritative_batch_applied result -> authoritative_applied core result | Authoritative_batch_deferred result -> authoritative_deferred core result | Snapshot_activated activation -> - (match core.selected_graph_value with - | Some graph when graph_scope_is_current core activation.scope -> + (match core.bootstrap, core.selected_graph_value with + | Activating_snapshot pending, Some graph + when pending = activation && graph_scope_is_current core activation.scope -> let request = { graph; scope = activation.scope } in - { next = { core with pending_mirror_inspection = Some request; pending_snapshot_activation = None } + { next = { core with pending_mirror_inspection = Some request; bootstrap = Preparing_snapshot activation.scope } ; effects = [ Delegate (Inspect_mirror request) ] } | _ -> unchanged core) | Snapshot_download_progress progress -> - (match core.active_snapshot_download with - | Some scope + (match core.bootstrap with + | Downloading_snapshot { scope; _ } when graph_scope_is_current core scope && Logseq_db_types.Graph_types.Uuid.equal scope.graph_id progress.graph_id -> { next = core; effects = [ Publish (Bootstrap_progressed progress) ] } - | None | Some _ -> unchanged core) + | _ -> unchanged core) | Graph_picker_requested -> return_to_graph_picker core | Catalog_refresh_requested scope when not (startup_scope_is_current core scope) -> unchanged core | Catalog_refresh_requested _ -> @@ -3106,42 +3185,31 @@ let step core event = let core=network_phase core Online_after_timeline in let next=set_snapshot core {core.public_state.snapshot with timeline_presentation_pending=false} in {next;effects=[publish next]} + | Snapshot_activation_failed error -> + (match core.bootstrap with + | Activating_snapshot pending + when pending = error.activation && graph_scope_is_current core pending.scope -> + fail core During_bootstrap error.message + | _ -> unchanged core) | Graph_attachment_failed error | Authoritative_batch_failed error - | Snapshot_activation_failed error when scoped_error_is_current core error -> fail core (if core.public_state.snapshot.startup.restoring_local then During_local_restore else During_bootstrap) error.message | Graph_attachment_failed _ - | Authoritative_batch_failed _ - | Snapshot_activation_failed _ -> unchanged core - | E2ee_password_submitted password -> - (match - ( core.snapshot_scope - , core.pending_private_key_package - , core.public_state.snapshot.startup.awaiting_e2ee_password ) - with - | Some graph, Some private_key_package, true -> - let startup = - { core.public_state.snapshot.startup with - awaiting_e2ee_password = false - ; failure = None - } - in - let core = - set_snapshot - core - { core.public_state.snapshot with - startup - ; sync_phase = Offline - ; last_error = None - } - in - let scope = graph.account in - let next, request = - issue_request - core - (Unlock_private_key { scope; password; private_key_package }) - in + | Authoritative_batch_failed _ -> unchanged core + | E2ee_password_submitted (challenge, password) -> + (match unlock_challenge core.public_state.snapshot.unlock with + | Some current when equal_unlock_challenge current challenge + && graph_scope_is_current core challenge.challenge_scope -> + let submission = { challenge; operation_id = core.next_effect_id + ; stage = Private_key core.next_effect_id } in + let core = set_snapshot core + { core.public_state.snapshot with unlock = Unlock_running submission + ; sync_phase = Offline; last_error = None + ; startup = { core.public_state.snapshot.startup with failure = None } } in + let next, request = issue_request core (Unlock_private_key + { scope = challenge.challenge_scope.account; password + ; private_key_package = challenge.private_key_package }) in { next; effects = [ publish next; Run request ] } | _ -> unchanged core) | Outbox_transition_failed { request; message } -> @@ -3212,8 +3280,7 @@ let step core event = ; pending_graph_open = None ; pending_mirror_inspection = None ; pending_attachment = None - ; active_snapshot_download = None - ; pending_snapshot_activation = None + ; bootstrap = No_bootstrap ; pending_sync_inspection = None ; pending_outbox_transition = None ; submission_owner = None diff --git a/logseq_sync/spec/effect_runner/asset_codec.mli b/logseq_sync/spec/effect_runner/asset_codec.mli index fa63107e..d5e1b9e0 100644 --- a/logseq_sync/spec/effect_runner/asset_codec.mli +++ b/logseq_sync/spec/effect_runner/asset_codec.mli @@ -13,11 +13,29 @@ val encode -> string -> (string, string) result -(** Validate the bounded two-string envelope shape before allocating a Transit tree. *) +type decode_error = + | Invalid_limit + | Wire_too_large + | Plaintext_too_large + | Malformed_envelope + | Malformed_transit of string + | Decryption_failed of string + | Checksum_mismatch + +val decode_error_message : decode_error -> string + +(** Only a successful bounded decode creates this value. It binds plaintext to + the expected version; there is no unchecked bytes-to-verified constructor. *) +type verified +val verified_bytes : verified -> string +val verified_version : verified -> Logseq_db_types.Asset_descriptor.version + +(** Preserve conservative raw/envelope classification. A candidate's parsing or + decryption failure never becomes raw data, even if its wire checksum matches. *) val decode : maximum_plaintext_bytes:int -> crypto:crypto -> key:string option - -> expected_checksum:string + -> expected:Logseq_db_types.Asset_descriptor.version -> string - -> (string, string) result + -> (verified, decode_error) result diff --git a/logseq_sync/spec/pure_reducer/core.mli b/logseq_sync/spec/pure_reducer/core.mli index 8d0ee5dc..4dd81486 100644 --- a/logseq_sync/spec/pure_reducer/core.mli +++ b/logseq_sync/spec/pure_reducer/core.mli @@ -69,12 +69,38 @@ type local_deletion = | Deletion_in_progress of local_deletion_stage | Deletion_failed of local_deletion_stage +(** Evidence is created by Core's package/crypto/attachment owners, never from + UI booleans. Phases are projections; Core rechecks identity and current scope. *) +type unlock_challenge +type unlock_submission +type unlock_attached + +type unlock_recovery = + | Retry_password of unlock_challenge * string + | Retry_preparation of string + +type unlock_phase = + | Unlock_inactive + | Unlock_challenge of unlock_challenge + | Unlock_running of unlock_submission + | Unlock_bootstrapping of unlock_submission + | Unlock_presentable of unlock_attached + | Unlock_failed of unlock_recovery + | Unlock_cancelled of unlock_submission + +val unlock_challenge : unlock_phase -> unlock_challenge option +val equal_unlock_challenge : unlock_challenge -> unlock_challenge -> bool +val unlock_submission_challenge : unlock_submission -> unlock_challenge +val equal_unlock_submission : unlock_submission -> unlock_submission -> bool +val unlock_attached_submission : unlock_attached -> unlock_submission + type snapshot = { sync_phase : sync_phase ; catalog : graph list ; selected_graph : graph_id option ; applied_server_t : Logseq_db_types.Server_cursor.t option ; timeline_presentation_pending : bool + ; unlock : unlock_phase ; startup : startup_facts ; last_error : string option ; local_deletion : local_deletion option @@ -154,6 +180,7 @@ type effect_error = | Crypto_failed of crypto_failure_kind * string type graph_key_handle +type snapshot_download type staged_artifact type catalog_cache @@ -161,15 +188,22 @@ val graph_key_handle : id:string -> scope:graph_scope -> graph_key_handle val graph_key_handle_id : graph_key_handle -> string val graph_key_handle_scope : graph_key_handle -> graph_scope -val staged_artifact - : id:string - -> scope:graph_scope - -> path:string - -> expected_rows:int - -> staged_artifact +(** The download producer must carry the exact admitted request. This receipt + binds its artifact to the metadata attempt; Core also checks identity/current + scope on completion. Disk/stream validation remains the producer's job. *) +val stage_snapshot_download + : snapshot_download -> path:string -> expected_rows:int -> (staged_artifact, string) result + +val snapshot_download_scope : snapshot_download -> graph_scope +val snapshot_download_uri : snapshot_download -> Uri.t +val snapshot_download_expected_bytes : snapshot_download -> int64 option +val snapshot_download_maximum_bytes : snapshot_download -> int val staged_artifact_path : staged_artifact -> string val staged_artifact_expected_rows : staged_artifact -> int +val unlock_challenge_scope : unlock_challenge -> graph_scope +val unlock_attached_scope : unlock_attached -> graph_scope + val effect_scope_of_account : account_scope -> effect_scope val effect_scope_of_graph : graph_scope -> effect_scope @@ -203,13 +237,6 @@ type decrypted_values = string list type snapshot_baseline = string type snapshot_metadata = string -type snapshot_download = - { scope : graph_scope - ; uri : Uri.t - ; expected_bytes : int64 option - ; maximum_bytes : int - } - type graph_key_request = { scope : graph_scope ; encrypted_graph_key : string @@ -436,12 +463,13 @@ type mirror_request = ; scope : graph_scope } -type snapshot_activation_request = - { artifact : staged_artifact - ; scope : graph_scope - ; applied_server_t : Logseq_db_types.Server_cursor.t - ; key : graph_key_handle option - } +(** Constructed only by Core after matching the active attempt and its artifact. + Consumers cannot replace its graph scope, metadata cursor or key independently. *) +type snapshot_activation_request +val snapshot_activation_scope : snapshot_activation_request -> graph_scope +val snapshot_activation_cursor : snapshot_activation_request -> Logseq_db_types.Server_cursor.t +val snapshot_activation_artifact : snapshot_activation_request -> staged_artifact +val snapshot_activation_key : snapshot_activation_request -> graph_key_handle option type mirror_deletion = { account : account_scope @@ -532,7 +560,12 @@ type outbox_transition_result = ; sync : Logseq_overlay_db.Types.sync_view } -type snapshot_activation = { scope : graph_scope } +(** Completion/failure producers must return the exact delegated activation. + Same-scope retries still have different attempt identities. *) +type snapshot_activation +type snapshot_activation_error +val snapshot_activation_completed : snapshot_activation_request -> snapshot_activation +val snapshot_activation_failed : snapshot_activation_request -> message:string -> snapshot_activation_error type startup_scope = { account_generation : account_generation @@ -560,7 +593,7 @@ type event = | Online_recovery_requested of timeline_scope | Online_cancelled of startup_scope | Local_account_unavailable of startup_scope * local_unavailability - | E2ee_password_submitted of string + | E2ee_password_submitted of unlock_challenge * string | Local_cache_deletion_requested of graph_id | Foreground_changed of { foreground : bool @@ -581,7 +614,7 @@ type event = | Authoritative_batch_deferred of authoritative_deferred_result | Authoritative_batch_failed of scoped_error | Snapshot_activated of snapshot_activation - | Snapshot_activation_failed of scoped_error + | Snapshot_activation_failed of snapshot_activation_error | Runner_completed of runner_completion | Snapshot_download_progress of bootstrap_progress | Websocket_opened of connection_scope diff --git a/logseq_sync/test/asset_native_interop.ml b/logseq_sync/test/asset_native_interop.ml index ab738cb0..a9bcf621 100644 --- a/logseq_sync/test/asset_native_interop.ml +++ b/logseq_sync/test/asset_native_interop.ml @@ -29,6 +29,16 @@ let get = function | Error e -> failwith e ;; +let version checksum = + get (Logseq_db_types.Asset_descriptor.version ~checksum ~file_type:"bin") +;; + +let decoded result = + result + |> Result.map C.verified_bytes + |> Result.map_error C.decode_error_message +;; + let reject label result = if Result.is_ok result then failwith ("Accepted " ^ label) let () = @@ -56,8 +66,8 @@ let () = ~maximum_plaintext_bytes:size ~crypto ~key:(Some key) - ~expected_checksum:checksum - wire + ~expected:(version checksum) + wire |> decoded in if get (decode upstream) <> plaintext then failwith "Upstream bytes changed"; reject "wrong key" (decode ~key:(String.make 32 '\255') upstream); diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml index 301a4f76..4834adfc 100644 --- a/logseq_sync/test/core_contract.ml +++ b/logseq_sync/test/core_contract.ml @@ -119,6 +119,35 @@ let selected_graph selected_graph = selected, Core.admitted_graph_scope selected.next |> Option.get ;; +(* Invalid/stale submission scenarios still carry a genuine challenge, obtained + through production events rather than a test constructor. *) +let fixture_challenge = lazy ( + let selected, scope = selected_graph encrypted_graph in + let absent = Core.step selected.next (Core.Mirror_inspected (Core.Mirror_absent scope)) in + let prepare = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Load_and_unlock_graph_key _)) -> + Some (Core.step absent.next (Core.Runner_completed + (Core.Completion (ticket, Error (Core.Effect_failed "synthetic missing key"))))) + | _ -> None) absent.effects |> Option.get in + let graph_key = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Fetch_e2ee_graph_key _)) -> + Some (Core.step prepare.next (Core.Runner_completed + (Core.Completion (ticket, Ok "encrypted-graph-key")))) + | _ -> None) prepare.effects |> Option.get in + let prompt = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Fetch_e2ee_user_keys _)) -> + Some (Core.step graph_key.next (Core.Runner_completed + (Core.Completion (ticket, Ok "private-key-package")))) + | _ -> None) graph_key.effects |> Option.get in + Core.unlock_challenge (Core.state prompt.next).snapshot.unlock |> Option.get) +;; + +let password_event core password = + let challenge = Core.unlock_challenge (Core.state core).snapshot.unlock + |> Option.value ~default:(Lazy.force fixture_challenge) in + Core.E2ee_password_submitted (challenge, password) +;; + let other_graph_id = Logseq_db_types.Graph_types.Uuid.of_string "99999999-9999-4999-8999-999999999999" |> Result.get_ok @@ -656,7 +685,7 @@ let warm_encrypted_graph_online_recovery_attaches_existing_mirror () = |> Option.get in let private_key_unlock = - Core.step password_prompt.next (Core.E2ee_password_submitted "password") + Core.step password_prompt.next (password_event password_prompt.next "password") in let graph_key_unlock = List.find_map @@ -742,7 +771,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = user_key_fetch.effects |> Option.get in - let first = Core.step prompt.next (Core.E2ee_password_submitted "wrong-synthetic") in + let first = Core.step prompt.next (password_event prompt.next "wrong-synthetic") in let rejected, repeat_rejected = List.find_map (function @@ -763,7 +792,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = true ((Core.state rejected.next).snapshot.startup.failure = Some During_e2ee); let corrected = - Core.step rejected.next (Core.E2ee_password_submitted " corrected-synthetic ") + Core.step rejected.next (password_event rejected.next " corrected-synthetic ") in let requests = List.filter_map @@ -802,7 +831,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = true ((Core.state corrected.next).snapshot.startup.failure = None); let duplicate = - Core.step corrected.next (Core.E2ee_password_submitted "duplicate-synthetic") + Core.step corrected.next (password_event corrected.next "duplicate-synthetic") in Alcotest.(check int) "pending duplicate is inert" 0 (List.length duplicate.effects); let stale = Core.step duplicate.next repeat_rejected in @@ -814,7 +843,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = "repeated failure remains retryable" true (Core.state again.next).snapshot.startup.awaiting_e2ee_password; - let final = Core.step again.next (Core.E2ee_password_submitted "final-synthetic") in + let final = Core.step again.next (password_event again.next "final-synthetic") in let unlocked = List.find_map (function @@ -854,7 +883,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = resumed.effects); let picker = Core.step rejected.next Core.Graph_picker_requested in let abandoned = - Core.step picker.next (Core.E2ee_password_submitted "abandoned-synthetic") + Core.step picker.next (password_event picker.next "abandoned-synthetic") in Alcotest.(check int) "picker discards password challenge" @@ -864,7 +893,7 @@ let e2ee_password_retry_recovers_after_failed_unlock () = Core.step rejected.next (Core.Account_authenticated { user_id = Some "other-user" }) in let abandoned = - Core.step account.next (Core.E2ee_password_submitted "other-account-synthetic") + Core.step account.next (password_event account.next "other-account-synthetic") in Alcotest.(check int) "account switch discards password challenge" @@ -936,7 +965,7 @@ let encrypted_graph_recovery_fetches_and_unlocks_key_before_bootstrap () = true (Core.state password_prompt.next).snapshot.startup.awaiting_e2ee_password; let private_key_unlock = - Core.step password_prompt.next (Core.E2ee_password_submitted "password") + Core.step password_prompt.next (password_event password_prompt.next "password") in let graph_key_unlock = List.find_map @@ -972,6 +1001,15 @@ let encrypted_graph_recovery_fetches_and_unlocks_key_before_bootstrap () = graph_key_unlock.effects |> Option.get in + Alcotest.(check bool) + "bootstrap phase is published before waiting for metadata" + true + (List.exists + (function + | Core.Publish (Core.State_changed state) -> + (match state.snapshot.unlock with Core.Unlock_bootstrapping _ -> true | _ -> false) + | _ -> false) + bootstrapping.effects); Alcotest.(check bool) "unlocked graph key resumes snapshot bootstrap" true @@ -1013,8 +1051,32 @@ let begin_snapshot_bootstrap ?(graphs = [ graph ]) () = missing, scope ;; +let snapshot_activation_request effects = + List.find_map (function Core.Delegate (Core.Activate_snapshot request) -> Some request + | _ -> None) effects |> Option.get +;; + +let snapshot_activation_fixture () = + let requested, _ = begin_snapshot_bootstrap () in + let downloading = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Fetch_snapshot_metadata _)) -> + Some (Core.step requested.next (Core.Runner_completed (Core.Completion + (ticket, Ok {|{"ok":true,"t":42,"url":"https://example.com/synthetic"}|})))) + | _ -> None) requested.effects |> Option.get in + let activating = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Download_snapshot request)) -> + let artifact = Core.stage_snapshot_download request ~path:"/tmp/synthetic-activation" + ~expected_rows:1 |> Result.get_ok in + Some (Core.step downloading.next (Core.Runner_completed (Core.Completion (ticket, Ok artifact)))) + | _ -> None) downloading.effects |> Option.get in + activating +;; + +let fixture_snapshot_activation = lazy (snapshot_activation_request (snapshot_activation_fixture ()).effects) +;; + let snapshot_catchup_fixture () = - let requested, scope = begin_snapshot_bootstrap () in + let requested, _scope = begin_snapshot_bootstrap () in Alcotest.(check bool) "bootstrap starts with exactly one metadata request and no preliminary pull" true @@ -1043,13 +1105,9 @@ let snapshot_catchup_fixture () = Alcotest.(check int) "download retains configured bound" (1024 * 1024) - request.maximum_bytes; + (Core.snapshot_download_maximum_bytes request); let artifact = - Core.staged_artifact - ~id:"snapshot" - ~scope - ~path:"/tmp/snapshot.sqlite" - ~expected_rows:10 + Core.stage_snapshot_download request ~path:"/tmp/snapshot.sqlite" ~expected_rows:10 |> Result.get_ok in Some (Core.step @@ -1070,15 +1128,15 @@ let snapshot_catchup_fixture () = Alcotest.(check cursor_testable) "activation uses metadata cursor" (server_cursor_of_int 42) - activation.applied_server_t; + (Core.snapshot_activation_cursor activation); Alcotest.(check bool) "plain snapshot has no graph key" true - (Option.is_none activation.key); + (Option.is_none (Core.snapshot_activation_key activation)); Alcotest.(check (option cursor_testable)) "download completion cannot publish an uncommitted cursor" None (Core.state activated.next).snapshot.applied_server_t; - let activated_snapshot = Core.step activated.next (Core.Snapshot_activated { scope }) in + let activated_snapshot = Core.step activated.next (Core.Snapshot_activated (Core.snapshot_activation_completed activation)) in let mirror = inspect_mirror_request activated_snapshot.effects in let attaching = Core.step @@ -2335,13 +2393,9 @@ let local_cache_deletion_rejects_unopened_graphs () = let activating = List.find_map (function - | Core.Run (Core.Request (ticket, Core.Download_snapshot _)) -> + | Core.Run (Core.Request (ticket, Core.Download_snapshot download_request)) -> let artifact = - Core.staged_artifact - ~id:"deletion-admission" - ~scope - ~path:"/tmp/snapshot.sqlite" - ~expected_rows:10 + Core.stage_snapshot_download download_request ~path:"/tmp/snapshot.sqlite" ~expected_rows:10 |> Result.get_ok in Some (Core.step @@ -2351,7 +2405,8 @@ let local_cache_deletion_rejects_unopened_graphs () = downloading.effects |> Option.get in - let activated = Core.step activating.next (Core.Snapshot_activated { scope }) in + let activated = Core.step activating.next (Core.Snapshot_activated + (Core.snapshot_activation_completed (snapshot_activation_request activating.effects))) in let attaching = Core.step activated.next @@ -2478,12 +2533,12 @@ let deletion_blocked_events (scope : Core.graph_scope) sync = ; Core.Timeline_presented {startup={account_generation=scope.account.account_generation;presentation_generation=scope.account.presentation_generation};graph_id=scope.graph_id;graph_generation=scope.graph_generation} ; Core.Catalog_refresh_requested {account_generation=scope.account.account_generation;presentation_generation=scope.account.presentation_generation} ; Core.Account_authenticated { user_id = Some "user" } - ; Core.E2ee_password_submitted "ignored" + ; Core.E2ee_password_submitted (Lazy.force fixture_challenge, "ignored") ; Core.Foreground_changed { foreground = true; lifecycle_generation = 98L } ; Core.Foreground_changed { foreground = false; lifecycle_generation = 99L } ; Core.Graph_attached { scope; sync } ; Core.Sync_inspected { scope; sync } - ; Core.Snapshot_activated { scope } + ; Core.Snapshot_activated (Core.snapshot_activation_completed (Lazy.force fixture_snapshot_activation)) ; Core.Mirror_inspected (Core.Mirror_available { graph; scope }) ; Core.Websocket_opened { graph = scope; connection_generation = 1 } ; Core.Websocket_closed @@ -5076,7 +5131,7 @@ let e2ee_preparation_failure_retries fetch_user () = let failed = if fetch_user then e2ee_finish_user_fetch (e2ee_finish_graph_fetch preparation (Ok "wrapped")) error else e2ee_finish_graph_fetch preparation error in - let rejected = Core.step failed.next (Core.E2ee_password_submitted "unused-synthetic") in + let rejected = Core.step failed.next (password_event failed.next "unused-synthetic") in Alcotest.(check bool) "no challenge is advertised" false (Core.state rejected.next).snapshot.startup.awaiting_e2ee_password; Alcotest.(check int) "password without challenge starts no work" 0 @@ -5108,7 +5163,7 @@ let e2ee_invalidated_challenge_is_not_advertised () = | _ -> None) refresh.effects |> Option.get in Alcotest.(check bool) "cleared key package cannot advertise a challenge" false (Core.state failed.next).snapshot.startup.awaiting_e2ee_password; - let submit = Core.step failed.next (Core.E2ee_password_submitted "unused-synthetic") in + let submit = Core.step failed.next (password_event failed.next "unused-synthetic") in Alcotest.(check int) "invalidated challenge starts no crypto" 0 (List.length submit.effects) ;; @@ -5226,7 +5281,7 @@ let e2ee_auto_preparation_failure_is_bounded fetch_user message () = snapshot.startup.awaiting_e2ee_password; Alcotest.(check bool) "failure does not automatically retry" false (List.exists (function Core.Run _ -> true | _ -> false) failed.effects); - let blocked = Core.step failed.next (Core.E2ee_password_submitted "unused") in + let blocked = Core.step failed.next (password_event failed.next "unused") in Alcotest.(check int) "no unusable password submission" 0 (List.length blocked.effects); let retry = Core.step failed.next (Core.Online_recovery_requested (timeline_scope failed.next)) in Alcotest.(check bool) "explicit Retry can reacquire materials" true @@ -5247,7 +5302,7 @@ let e2ee_late_preparation_cannot_reopen_challenge stage cancel result () = if stage = 1 then user_fetch, (fun origin -> e2ee_finish_user_fetch { user_fetch with next = origin } result) else let prompt = e2ee_finish_user_fetch user_fetch (Ok "old-package") in - let unlocking = Core.step prompt.next (Core.E2ee_password_submitted "synthetic") in + let unlocking = Core.step prompt.next (password_event prompt.next "synthetic") in let completion = List.find_map (function | Core.Run (Core.Request (ticket, Core.Unlock_private_key _)) -> Some (Core.Runner_completed (Core.Completion (ticket, @@ -5262,7 +5317,7 @@ let e2ee_late_preparation_cannot_reopen_challenge stage cancel result () = Alcotest.(check bool) "late completion preserves current public state" true (before = Core.state late.next); Alcotest.(check int) "late completion starts no next phase" 0 (List.length late.effects); - let submit = Core.step late.next (Core.E2ee_password_submitted "abandoned") in + let submit = Core.step late.next (password_event late.next "abandoned") in Alcotest.(check int) "obsolete challenge cannot submit" 0 (List.length submit.effects) ;; @@ -5401,7 +5456,7 @@ let appearance_is_scoped_and_authentication_is_required () = let cancelled_local_key_crypto_still_completes () = let preparation, scope = e2ee_preparation_fixture () in let prompt = e2ee_finish_user_fetch (e2ee_finish_graph_fetch preparation (Ok "wrapped")) (Ok "private-package") in - let unlocking = Core.step prompt.next (Core.E2ee_password_submitted "synthetic") in + let unlocking = Core.step prompt.next (password_event prompt.next "synthetic") in let finish_private (result : (unit, Core.effect_error) result) core = List.find_map (function | Core.Run (Core.Request (ticket, Core.Unlock_private_key _)) -> Some (Core.step core (Core.Runner_completed (Core.Completion (ticket, result)))) @@ -5691,7 +5746,7 @@ let snapshot_live_stream_catchup_keeps_metadata_cursor () = let snapshot_failure_and_retry_keep_cursor_uncommitted () = List.iter (fun activation_failure -> - let requested, scope = begin_snapshot_bootstrap () in + let requested, _scope = begin_snapshot_bootstrap () in let download = List.find_map (function @@ -5712,13 +5767,9 @@ let snapshot_failure_and_retry_keep_cursor_uncommitted () = let failed, late_completion = List.find_map (function - | Core.Run (Core.Request (ticket, Core.Download_snapshot _)) -> + | Core.Run (Core.Request (ticket, Core.Download_snapshot download_request)) -> let artifact = - Core.staged_artifact - ~id:"synthetic-failure" - ~scope - ~path:"/tmp/synthetic-snapshot" - ~expected_rows:1 + Core.stage_snapshot_download download_request ~path:"/tmp/synthetic-snapshot" ~expected_rows:1 |> Result.get_ok in let completion = Core.Runner_completed (Core.Completion (ticket, Ok artifact)) @@ -5729,10 +5780,9 @@ let snapshot_failure_and_retry_keep_cursor_uncommitted () = let activating = Core.step download.next completion in Core.step activating.next - (Core.Snapshot_activation_failed - { scope = Core.effect_scope_of_graph scope - ; message = "synthetic activation failure" - })) + (Core.Snapshot_activation_failed (Core.snapshot_activation_failed + (snapshot_activation_request activating.effects) + ~message:"synthetic activation failure"))) else Core.step download.next @@ -5802,7 +5852,7 @@ let replaced_snapshot_completions_are_inert () = Logseq_db_types.Graph_types.Uuid.of_string "22222222-2222-4222-8222-222222222222" |> Result.get_ok } in List.iter (fun download_pending -> - let requested, old_scope = begin_snapshot_bootstrap ~graphs:[graph; other] () in + let requested, _old_scope = begin_snapshot_bootstrap ~graphs:[graph; other] () in let metadata (result : (Core.snapshot_metadata, Core.effect_error) result) = List.find_map (function | Core.Run (Core.Request (ticket, Core.Fetch_snapshot_metadata _)) -> Some (Core.Runner_completed (Core.Completion (ticket, result))) @@ -5812,9 +5862,8 @@ let replaced_snapshot_completions_are_inert () = let downloading = Core.step requested.next (metadata (Ok {|{"ok":true,"t":42,"url":"https://example.com/old"}|})) in List.find_map (function - | Core.Run (Core.Request (ticket, Core.Download_snapshot _)) -> - let artifact = Core.staged_artifact ~id:"old" ~scope:old_scope - ~path:"/tmp/synthetic-old" ~expected_rows:1 in + | Core.Run (Core.Request (ticket, Core.Download_snapshot download_request)) -> + let artifact = Core.stage_snapshot_download download_request ~path:"/tmp/synthetic-old" ~expected_rows:1 |> Result.get_ok in Some (downloading.next, [Core.Runner_completed (Core.Completion (ticket, Ok artifact))], Core.Runner_completed (Core.Completion @@ -5841,16 +5890,15 @@ let replaced_snapshot_completions_are_inert () = List.find_map (function | Core.Run (Core.Request (ticket, Core.Download_snapshot request)) -> Alcotest.(check bool) "fresh download retains graph and URL" true - (request.scope = inspection.scope && request.uri = Uri.of_string "https://example.com/current"); - let artifact = Core.staged_artifact ~id:"current" ~scope:inspection.scope - ~path:"/tmp/synthetic-current" ~expected_rows:1 in + (Core.snapshot_download_scope request = inspection.scope && Core.snapshot_download_uri request = Uri.of_string "https://example.com/current"); + let artifact = Core.stage_snapshot_download request ~path:"/tmp/synthetic-current" ~expected_rows:1 |> Result.get_ok in Some (Core.step fresh.next (Core.Runner_completed (Core.Completion (ticket, Ok artifact)))) | _ -> None) fresh.effects |> Option.get |> fun activation -> Alcotest.(check bool) "fresh activation keeps its own graph and cursor" true (List.exists (function | Core.Delegate (Core.Activate_snapshot request) -> - request.scope = inspection.scope && request.applied_server_t = server_cursor_of_int 84 + Core.snapshot_activation_scope request = inspection.scope && Core.snapshot_activation_cursor request = server_cursor_of_int 84 | _ -> false) activation.effects)) (successes @ [failure])) [false; true] ;; @@ -5858,3 +5906,60 @@ let scenarios = scenarios @ [ Alcotest.test_case "replaced snapshot metadata and download completions are inert" `Quick replaced_snapshot_completions_are_inert ] ;; + +let snapshot_artifact_from_retried_attempt_is_rejected () = + let requested, _ = begin_snapshot_bootstrap () in + let metadata (origin : Core.transition) cursor = + List.find_map (function + | Core.Run (Core.Request (ticket, Core.Fetch_snapshot_metadata _)) -> + Some (Core.step origin.next (Core.Runner_completed (Core.Completion + (ticket, Ok (Printf.sprintf "{\"ok\":true,\"t\":%d,\"url\":\"https://example.com/snapshot\"}" cursor))))) + | _ -> None) origin.effects |> Option.get in + let old = metadata requested 42 in + let failed, old_artifact, old_scope = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Download_snapshot request)) -> + let artifact = Core.stage_snapshot_download request ~path:"/tmp/synthetic-attempt-a" + ~expected_rows:1 |> Result.get_ok in + Some (Core.step old.next (Core.Runner_completed (Core.Completion + (ticket, Error (Core.Effect_failed "synthetic interrupted stream")))), artifact, + Core.snapshot_download_scope request) + | _ -> None) old.effects |> Option.get in + let retry = Core.step failed.next (Core.Online_recovery_requested (timeline_scope failed.next)) in + let mirror = inspect_mirror_request retry.effects in + let missing = Core.step retry.next (Core.Mirror_inspected (Core.Mirror_absent mirror.scope)) in + let current = metadata missing 84 in + let mismatch = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Download_snapshot request)) -> + Alcotest.(check bool) "retry changes the generation of this production graph scope" false + (Core.snapshot_download_scope request = old_scope); + Some (Core.step current.next (Core.Runner_completed (Core.Completion (ticket, Ok old_artifact)))) + | _ -> None) current.effects |> Option.get in + Alcotest.(check bool) "foreign attempt cannot activate" false + (List.exists (function Core.Delegate (Core.Activate_snapshot _) -> true | _ -> false) mismatch.effects); + Alcotest.(check bool) "mismatch terminates bootstrap with visible failure" true + ((Core.state mismatch.next).snapshot.startup.failure = Some Core.During_bootstrap) +;; + +let scenarios = scenarios @ [ Alcotest.test_case + "old snapshot artifact cannot complete a fresh request" `Quick + snapshot_artifact_from_retried_attempt_is_rejected ] +;; + +let failed_snapshot_activation_receipts_are_inert () = + let activating = snapshot_activation_fixture () in + let request = snapshot_activation_request activating.effects in + let failure = Core.Snapshot_activation_failed + (Core.snapshot_activation_failed request ~message:"synthetic activation failure") in + let failed = Core.step activating.next failure in + let success = Core.Snapshot_activated (Core.snapshot_activation_completed request) in + List.iter (fun event -> + let late = Core.step failed.next event in + check_observed "retired activation" "old receipt preserves failed owner" + (observe failed.next) (observe late.next); + check_instructions "retired activation receipt" [] late.effects) [success; failure] +;; + +let scenarios = scenarios @ [ Alcotest.test_case + "late activation success and failure cannot reopen failed bootstrap" `Quick + failed_snapshot_activation_receipts_are_inert ] +;; diff --git a/logseq_sync/test/runner_contract.ml b/logseq_sync/test/runner_contract.ml index d7990ffc..66252bf2 100644 --- a/logseq_sync/test/runner_contract.ml +++ b/logseq_sync/test/runner_contract.ml @@ -804,7 +804,8 @@ let private_key_unlock_and_sign_out () = user_key_fetch.effects |> Option.get in - let unlocking = Core.step password_prompt.next (E2ee_password_submitted "password") in + let unlocking = Core.step password_prompt.next (Core.E2ee_password_submitted + (Core.unlock_challenge (Core.state password_prompt.next).snapshot.unlock |> Option.get, "password")) in let unlock = List.find_map (function diff --git a/logseq_sync/test/test_asset_cache.ml b/logseq_sync/test/test_asset_cache.ml index 9661eee1..d97473fc 100644 --- a/logseq_sync/test/test_asset_cache.ml +++ b/logseq_sync/test/test_asset_cache.ml @@ -98,6 +98,19 @@ let sealed_public_interface () = positive root "module C = Logseq_sync_pure_reducer__Core\nlet carry (x : C.runner_effect) = x\n"; + negative root (prefix ^ "let bypass = C.staged_artifact\n") "Unbound value"; + negative root + (prefix ^ "let forge (artifact : C.staged_artifact) (scope : C.graph_scope) cursor : C.snapshot_activation_request = { artifact; scope; applied_server_t = cursor; key = None }\n") + "Unbound record field"; + negative root + (prefix ^ "let forge (value : bool) : C.unlock_attached = value\n") + "but an expression was expected of type"; + negative root + (prefix ^ "let forge (value : string) : C.unlock_challenge = value\n") + "but an expression was expected of type"; + negative root + "let forge (value : string) : Logseq_sync_effect_runner.Asset_codec.verified = value\n" + "but an expression was expected of type"; negative root (prefix ^ "let forge scope = C.Cancel_effects scope\n") "private type"; negative root diff --git a/logseq_sync/test/test_asset_codec.ml b/logseq_sync/test/test_asset_codec.ml index 06f88a3a..c1e56325 100644 --- a/logseq_sync/test/test_asset_codec.ml +++ b/logseq_sync/test/test_asset_codec.ml @@ -6,6 +6,16 @@ let get = function | Error e -> failwith e ;; +let version checksum = + get (Logseq_db_types.Asset_descriptor.version ~checksum ~file_type:"bin") +;; + +let decoded result = + result + |> Result.map C.verified_bytes + |> Result.map_error C.decode_error_message +;; + let bytes = "\000PNG\255\128binary\000" let iv = String.make 12 '\001' let ciphertext = String.make 16 '\002' ^ bytes @@ -25,12 +35,31 @@ let crypto : C.crypto = let envelope = T.to_string (Transit_core.Json.Array [ Binary iv; Binary ciphertext ]) let decode ?(key = Some "key") ?(limit = 32) ?(checksum = C.checksum bytes) wire = - C.decode ~maximum_plaintext_bytes:limit ~crypto ~key ~expected_checksum:checksum wire + C.decode ~maximum_plaintext_bytes:limit ~crypto ~key ~expected:(version checksum) wire |> decoded ;; let rejects result = Alcotest.(check bool) "rejected" true (Result.is_error result) +let finite_decode_errors () = + let run ~key ~expected wire = + C.decode ~maximum_plaintext_bytes:32 ~crypto ~key ~expected:(version expected) wire + in + (match run ~key:(Some "wrong") ~expected:(C.checksum envelope) envelope with + | Error (C.Decryption_failed _) -> () + | _ -> Alcotest.fail "authentication failure must remain a typed failure, never raw"); + (match run ~key:None ~expected:(String.make 64 'a') bytes with + | Error C.Checksum_mismatch -> () + | _ -> Alcotest.fail "checksum has a stable domain classification"); + let expected = version (C.checksum bytes) in + match C.decode ~maximum_plaintext_bytes:32 ~crypto ~key:None ~expected bytes with + | Error error -> Alcotest.fail (C.decode_error_message error) + | Ok verified -> + Alcotest.(check bool) "verified retains the expected version" true + (Logseq_db_types.Asset_descriptor.equal_version expected (C.verified_version verified)) +;; + let raw_bytes () = + finite_decode_errors (); Alcotest.(check string) "raw bytes after decrypt" bytes (get (decode envelope)); Alcotest.(check string) "upstream envelope" @@ -72,8 +101,8 @@ let bounded_envelope_shape () = ~maximum_plaintext_bytes:1048576 ~crypto:guarded ~key:(Some "key") - ~expected_checksum:(C.checksum bytes) - wire + ~expected:(version (C.checksum bytes)) + wire |> decoded with | Stack_overflow -> Alcotest.fail "untrusted envelope exhausted the stack" in @@ -149,8 +178,8 @@ let non_transit_raw_assets () = ~maximum_plaintext_bytes:256 ~crypto:guarded ~key:(Some "PRIVATE_KEY") - ~expected_checksum:(C.checksum wire) - wire) + ~expected:(version (C.checksum wire)) + wire |> decoded) in Alcotest.(check string) "raw bytes preserved" wire (get result); Alcotest.(check string) @@ -180,8 +209,8 @@ let raw_assets_keep_validation () = ~maximum_plaintext_bytes:maximum ~crypto ~key:(Some "key") - ~expected_checksum:expected - wire) + ~expected:(version expected) + wire |> decoded) in Alcotest.(check (result string string)) "common validation is retained" @@ -223,8 +252,8 @@ let transit_failures_never_become_raw () = ~maximum_plaintext_bytes:1048576 ~crypto:crypt ~key:(Some "wrong") - ~expected_checksum:(C.checksum wire) - wire) + ~expected:(version (C.checksum wire)) + wire |> decoded) in rejects result; Alcotest.(check string) "no raw fallback even when raw checksum matches" "" log; diff --git a/logseq_sync/test/transport_contract.ml b/logseq_sync/test/transport_contract.ml index 0d25aa5a..5aea69ce 100644 --- a/logseq_sync/test/transport_contract.ml +++ b/logseq_sync/test/transport_contract.ml @@ -4005,7 +4005,8 @@ let test_unlock_crypto_classification () = | Core.Run (Core.Request (ticket, Core.Fetch_e2ee_user_keys _)) -> Some (Core.step graph_key.next (Core.Runner_completed (Core.Completion (ticket, Ok "LOG_SECRET_PACKAGE")))) | _ -> None) graph_key.effects |> Option.get in - let unlock = Core.step private_key.next (Core.E2ee_password_submitted "LOG_SECRET_PASSWORD") in + let unlock = Core.step private_key.next (Core.E2ee_password_submitted + (Core.unlock_challenge (Core.state private_key.next).snapshot.unlock |> Option.get, "LOG_SECRET_PASSWORD")) in let request = List.find_map (function | Core.Run (Core.Request (_, Core.Unlock_private_key _) as request) -> Some request | _ -> None) unlock.effects |> Option.get in diff --git a/test/application_view_test.ml b/test/application_view_test.ml index 14c63314..ee0fe7f8 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -123,7 +123,8 @@ let test_diagnostic_phase_rows_retain_current_error () = let message = "Graph schema 65.29 is not supported. Journal requires schema 65.33 or later." in let snapshot : Service.snapshot = { sync_phase = Failed; catalog = []; selected_graph = None; applied_server_t = None - ; timeline_presentation_pending = false; local_deletion = None; last_error = Some message + ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive + ; timeline_presentation_pending = false; local_deletion = None; last_error = Some message ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user; catalog_loading = false; awaiting_selection = false ; restoring_local = false; bootstrapping = true; awaiting_e2ee_password = false ; failure = Some During_bootstrap; account_generation = 1; graph_generation = 1 @@ -145,7 +146,8 @@ let test_diagnostic_identity_comes_from_worker () = let actual = uuid "60000000-0000-4000-8000-000000000002" in let snapshot : Service.snapshot = { sync_phase = Offline; catalog = []; selected_graph = Some selected - ; applied_server_t = None; timeline_presentation_pending = false + ; applied_server_t = None; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive + ; timeline_presentation_pending = false ; local_deletion = None; last_error = None ; startup = { authenticated = true; catalog_loading = false ; awaiting_selection = false; restoring_local = false; bootstrapping = false @@ -693,6 +695,7 @@ let run_favorites_native_visibility ; catalog = [] ; selected_graph = Some (uuid 900) ; applied_server_t = Some (server_cursor_of_int 0) + ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user @@ -4012,6 +4015,7 @@ let test_reference_outer_terminals cancel = ; catalog = [] ; selected_graph = Some (uuid 900) ; applied_server_t = Some (server_cursor_of_int 0) + ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user @@ -4617,7 +4621,8 @@ let test_favorite_page_uses_shared_reference_lookup () = let manager : Service.state = { snapshot = { sync_phase = Current; catalog = []; selected_graph = Some (uuid 9) - ; applied_server_t = Some (server_cursor_of_int 0); timeline_presentation_pending = false + ; applied_server_t = Some (server_cursor_of_int 0); unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive + ; timeline_presentation_pending = false ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user; catalog_loading = false; awaiting_selection = false ; restoring_local = false; bootstrapping = false; awaiting_e2ee_password = false ; failure = None; account_generation = 1; graph_generation = 1; presentation_generation = 1 } @@ -5090,6 +5095,41 @@ let test_fixture_exception_cleanup exception_ = check_fixture_worker_idle () ;; +(* Build opaque unlock evidence through the production reducer, including its + actual key-package and attachment owners. No boolean-to-receipt fixture. *) +let synthetic_unlock_prompt graph_id = + let module C = Logseq_sync_pure_reducer.Core in + let limits = C.limits ~maximum_response_bytes:Logseq_db_types.Limits.maximum_response_bytes + ~maximum_artifact_bytes:1048576 ~submission_batch_size:32 |> Result.get_ok in + let initial = C.config ~managed_sync_origin:(Uri.of_string "https://example.invalid") ~limits + |> Result.get_ok |> C.initial |> Result.get_ok in + let permitted = C.step initial (C.Sign_in_requested + { account_generation = 0; presentation_generation = 0 }) in + let auth = C.step permitted.next (C.Account_authenticated { user_id = Some "synthetic-user" }) in + let graph : C.graph = { graph_id; name = "Private Journal"; encrypted = true + ; schema = { major = 65; minor = 33; exact = true } } in + let catalog = List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_catalog _)) -> Some (C.step auth.next + (C.Runner_completed (C.Completion (ticket, Ok [graph])))) + | _ -> None) auth.effects |> Option.get in + let selected = C.step catalog.next (C.Graph_selected graph_id) in + let mirror = List.find_map (function C.Delegate (C.Inspect_mirror request) -> Some request + | _ -> None) selected.effects |> Option.get in + let local = C.step selected.next (C.Mirror_inspected (C.Mirror_available mirror)) in + let prepare = List.find_map (function + | C.Run (C.Request (ticket, C.Load_and_unlock_graph_key _)) -> Some (C.step local.next + (C.Runner_completed (C.Completion (ticket, Error (C.Effect_failed "synthetic missing key"))))) + | _ -> None) local.effects |> Option.get in + let wrapped = List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_e2ee_graph_key _)) -> Some (C.step prepare.next + (C.Runner_completed (C.Completion (ticket, Ok "synthetic-wrapped")))) + | _ -> None) prepare.effects |> Option.get in + List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_e2ee_user_keys _)) -> Some (C.step wrapped.next + (C.Runner_completed (C.Completion (ticket, Ok "synthetic-package")))) + | _ -> None) wrapped.effects |> Option.get +;; + (* Application owns password admission and UI. No public pure root event exists for E2EE; this fixture exercises mounted Application through its public hooks. *) let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = false) ?(cold_start = false) @@ -5102,6 +5142,9 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = Logseq_db_types.Graph_types.Uuid.of_string "00000000-0000-0000-0000-000000000901" |> Result.get_ok in + let module C = Logseq_sync_pure_reducer.Core in + let prompt = synthetic_unlock_prompt graph_id in + let core = ref prompt.next in let manager : Service.state = { snapshot = { sync_phase = Offline @@ -5114,6 +5157,7 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = ] ; selected_graph = Some graph_id ; applied_server_t = None + ; unlock = (C.state !core).snapshot.unlock ; timeline_presentation_pending = false ; startup = { authenticated = true; local_access=Service.Local_available; network_phase=Service.Online_by_user @@ -5125,7 +5169,7 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = ; failure = None ; account_generation = 1 ; graph_generation = 1 - ; presentation_generation = 1 + ; presentation_generation = (C.state !core).snapshot.startup.presentation_generation } ; last_error = None ; local_deletion = None @@ -5136,7 +5180,8 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = let manager = if cold_start then { manager with snapshot = { manager.snapshot with startup = { manager.snapshot.startup with authenticated = false; local_access = Service.Local_pending - ; network_phase = Service.Local_only; awaiting_e2ee_password = false } } } + ; network_phase = Service.Local_only; awaiting_e2ee_password = false } + ; unlock = C.Unlock_inactive } } else manager in let graph_reads = Atomic.make 0 in let current = Atomic.make manager in @@ -5187,14 +5232,17 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = }) | Client_command (Submit_e2ee_password _) when worker_failure -> Error "Synthetic unlock worker failure" - | Client_command (Submit_e2ee_password password) -> + | Client_command (Submit_e2ee_password (challenge, password)) -> + let unlocking = C.step !core (C.E2ee_password_submitted (challenge, password)) in + core := unlocking.next; Atomic.set submissions (password :: Atomic.get submissions); let old = Atomic.get current in publish { old with snapshot = { old.snapshot with - startup = + unlock = (C.state !core).snapshot.unlock + ; startup = { old.snapshot.startup with awaiting_e2ee_password = false ; failure = None @@ -5210,9 +5258,27 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = Eio.Time.Mono.sleep (W.Request_context.clock context) 0.01 done; if attach_mirror then ( + let private_key = List.find_map (function + | C.Run (C.Request (ticket, C.Unlock_private_key _)) -> Some (C.step !core + (C.Runner_completed (C.Completion (ticket, Ok ())))) + | _ -> None) unlocking.effects |> Option.get in + let graph_key = List.find_map (function + | C.Run (C.Request (ticket, C.Fetch_and_unlock_graph_key request)) -> + Some (C.step private_key.next (C.Runner_completed (C.Completion + (ticket, Ok (C.graph_key_handle ~id:"synthetic-key" ~scope:request.scope))))) + | _ -> None) private_key.effects |> Option.get in + let attach = List.find_map (function C.Delegate (C.Attach_graph request) -> Some request + | _ -> None) graph_key.effects |> Option.get in + let token = Logseq_overlay_db.Types.sync_token_of_string "sync-token:v1:synthetic-unlock" + |> Result.get_ok in + let sync = Logseq_overlay_db.Types.sync_view ~token + ~checkpoint:(server_cursor_of_int 51) ~submissions:[] in + let completed = C.step graph_key.next (C.Graph_attached { scope = attach.scope; sync }) in + core := completed.next; let attached = { old with snapshot = { old.snapshot with - applied_server_t = Some (server_cursor_of_int 51) + unlock = (C.state !core).snapshot.unlock + ; applied_server_t = Some (server_cursor_of_int 51) ; timeline_presentation_pending = true ; startup = { old.snapshot.startup with awaiting_e2ee_password = false; restoring_local = false @@ -5233,17 +5299,25 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = done; (if graph_first then manager_changed else graph_changed) (); Atomic.set attachment_stage 2) - else publish + else ( + let failed = List.find_map (function + | C.Run (C.Request (ticket, C.Unlock_private_key _)) -> Some (C.step !core + (C.Runner_completed (C.Completion (ticket, + Error (C.Effect_failed "Could not unlock the graph. Check your encryption password and try again."))))) + | _ -> None) unlocking.effects |> Option.get in + core := failed.next; + publish { manager with snapshot = { manager.snapshot with - startup = { manager.snapshot.startup with failure = Some During_e2ee } + unlock = (C.state !core).snapshot.unlock + ; startup = { manager.snapshot.startup with failure = Some During_e2ee } ; last_error = Some "Could not unlock the graph. Check your encryption password and \ try again." } - }; + }); Ok Service.Client_command_completed | Client_command (Acknowledge_timeline_presented scope) -> on_timeline_presented scope; @@ -5256,6 +5330,7 @@ let unlock_fixture ?(worker_failure = false) ?(queue_full = false) ?(fail_init = snapshot = { manager.snapshot with selected_graph = None + ; unlock = C.Unlock_inactive ; startup = { manager.snapshot.startup with awaiting_selection = true @@ -5367,16 +5442,16 @@ let test_repeated_native_sign_in_intent_receives_permission () = ignore (hooks.init 2 2 startup); wait "initial Online_by_user permission" (fun () -> permit_count () >= 1); requests := []; - send_intent 1 1; + send_intent 1 2; wait "same-phase sign-in receives permission" (fun () -> permit_count () >= 1); - send_intent 1 1; + send_intent 1 2; wait "reopened sign-in receives permission" (fun () -> permit_count () >= 2); let before = permit_count () in send_intent 0 0; for _ = 1 to 100 do ignore (hooks.pump ()); Unix.sleepf 0.001 done; Alcotest.(check int) "stale startup intent cannot grant permission" before (permit_count ()); - send_intent ~tag:35 1 1; - send_intent 1 1; + send_intent ~tag:35 1 2; + send_intent 1 2; for _ = 1 to 100 do ignore (hooks.pump ()); Unix.sleepf 0.001 done; Alcotest.(check int) "queued cancellation fences old-scope retry" before (permit_count ())) ;; @@ -5442,7 +5517,7 @@ let test_pending_auth_never_replays_older_identity ~newer_accepted () = in let payload user = let json = Yojson.Safe.to_string (`Assoc ["userId",`String user; - "startupScope",`Assoc ["accountGeneration",`Int 1;"presentationGeneration",`Int 1]]) |> Bytes.of_string in + "startupScope",`Assoc ["accountGeneration",`Int 1;"presentationGeneration",`Int 2]]) |> Bytes.of_string in let bytes = Bytes.make (32 + Bytes.length json) '\000' in Bytes.blit_string "LJP2" 0 bytes 0 4; Bytes.set_uint16_le bytes 4 2; Bytes.set_uint16_le bytes 6 7; @@ -5954,6 +6029,7 @@ let test_graph_picker_controls scenario () = { snapshot = { sync_phase = Current; catalog = [graph first "Current synthetic graph"; graph second long_name] ; selected_graph = Some first; applied_server_t = Some (server_cursor_of_int 0) + ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending = false ; startup = { authenticated = scenario <> `Offline; local_access=Service.Local_available; network_phase=(if scenario = `Offline then Service.Local_only else Service.Online_by_user); catalog_loading = false; awaiting_selection = false ; restoring_local = false; bootstrapping = false; awaiting_e2ee_password = false diff --git a/test/startup_test.ml b/test/startup_test.ml index 9d159651..09b3a1d6 100644 --- a/test/startup_test.ml +++ b/test/startup_test.ml @@ -96,6 +96,7 @@ let startup_snapshot ; catalog = [] ; selected_graph = Some graph_id ; applied_server_t = Some (server_cursor_of_int 11) + ; unlock = Logseq_sync_pure_reducer.Core.Unlock_inactive ; timeline_presentation_pending ; startup = { authenticated From 3d836147cd83b69f51679cff640c8b461f71b47c Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 16:21:56 +0800 Subject: [PATCH 2/3] Record final Simulator acceptance for evidence boundaries --- .../2026-10-23-ocaml-evidence-boundaries.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) rename docs/agent-guide/{proposed => implemented}/architecture/2026-10-23-ocaml-evidence-boundaries.md (76%) diff --git a/docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md b/docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md similarity index 76% rename from docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md rename to docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md index 49df1156..741e574c 100644 --- a/docs/agent-guide/proposed/architecture/2026-10-23-ocaml-evidence-boundaries.md +++ b/docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md @@ -136,6 +136,43 @@ asset decode/publication boundaries. green before the refactor; do not invent a regression or claim a RED run that was not observed. No build/test process was started during resource hold. +## Consequences + +The three boundaries are implemented within their existing owners. Public compiler +negative tests reject arbitrary activation/challenge/attached/verified construction +and the old artifact factory. Core retains ticket/generation/current/request identity +checks; cache publication retains current, size, checksum, disk and lease checks. +The slow-bootstrap regression was reproduced through public Core events before +fixing publication of Unlock_bootstrapping ahead of metadata work. + +Final production-source build @all and repository runtest pass. Applicable reruns +include Sync 305, Application 144, runtime locality 152, worker integration 15, +worker reducer 20 and LUI service 49; existing overlay storage 85 also passes. +Independent read-only review of source commit b6b61a7 found no remaining proven +issue. Temporary logs, hosts and screenshots remain outside the repository. + +The same source modules were tested on the existing A135 iOS 26.1 Simulator with +an isolated synthetic account/graph, a memory secrets adapter and a loopback server. +The temporary test entry is not committed; production native_embed is unchanged. +The real UI covers wrong-password retry, cancel/reselect, sign out/login/unlock, +retained-mirror attach, and a fresh 412-row snapshot with delayed delivery. Password +pending clears in bootstrapping, before attachment and Timeline appearance. The +persisted metadata cursor is 42, SQLite integrity_check is ok, and diagnostics show +Sync Current, Startup Ready and Graph Open. Clear and Apple AES-GCM attachments +publish the expected plaintext; a corrupted-auth envelope whose wire checksum +matches is rejected and never published. Sign out clears decoded cache files and +memory secrets; relogin downloads and verifies attachments again. + +The first temporary server used the wrong pull reply spelling; its affected run +was not accepted. The corrected server and final source were rerun successfully. +No real account/keychain or graph was accessed. No device was created or reset; +the protected 1446 device was untouched. Host-restamp remains experimental with +cross_abi_validated=false; these checks do not establish complete cross-ABI support +or production authentication-service acceptance. Disk-full/hardware faults and +pending-operation UI cancellation were not simulated on-device; applicable existing +runtime regressions remain separate evidence. A135 and this task's processes were +released after verification. No merge, TestFlight or deployment is authorized. + ## Questions None. The user answered the protected-interface authorization question with From 5bc9a0195ddb5cfe8a028201b8da29bfa8c8a957 Mon Sep 17 00:00:00 2001 From: rcmerci Date: Fri, 23 Oct 2026 16:23:32 +0800 Subject: [PATCH 3/3] Clarify completed validation resource handoff --- .../architecture/2026-10-23-ocaml-evidence-boundaries.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md b/docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md index 741e574c..a3ca68ea 100644 --- a/docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md +++ b/docs/agent-guide/implemented/architecture/2026-10-23-ocaml-evidence-boundaries.md @@ -80,7 +80,7 @@ it only if an implementation type equality actually requires it. The user explicitly authorized the required protected spec .mli changes on 2026-10-23, as relayed by the parent with the direct question and answer. Proceed -within these three boundaries. Build/test/Simulator work still waits for an +within these three boundaries. Build/test/Simulator work began after the explicit resource handoff; no Dune or pin changes are authorized. ## Alternatives considered