diff --git a/.agents/skills/journal-build/scripts/build_authentication_probe.py b/.agents/skills/journal-build/scripts/build_authentication_probe.py index f1e37679..9fdb9d9d 100644 --- a/.agents/skills/journal-build/scripts/build_authentication_probe.py +++ b/.agents/skills/journal-build/scripts/build_authentication_probe.py @@ -26,6 +26,7 @@ })) sources = [root/'swift/JournalAuthentication.swift', root/'swift/JournalAuthenticationView.swift', root/'swift/JournalPlatformWire.swift', root/'swift/JournalPlatformServices.swift', + root/'swift/JournalCognitoOAuth.swift', root/'swift/JournalLocalAccountBindingStore.swift', root/'apple-tests/authentication/JournalAuthenticationProbe.swift'] sdk = subprocess.check_output(['xcrun', '--sdk', 'iphonesimulator', '--show-sdk-path'], text=True).strip() diff --git a/.agents/skills/journal-build/scripts/journal_build.py b/.agents/skills/journal-build/scripts/journal_build.py index 2f9fd20c..bc478bdf 100644 --- a/.agents/skills/journal-build/scripts/journal_build.py +++ b/.agents/skills/journal-build/scripts/journal_build.py @@ -1243,6 +1243,7 @@ def interrupted(signum, frame): int32_t journal_ocaml_extension_event(int64_t n, const char *name, const char *p) { (void)n; (void)name; (void)p; return 1; } int32_t journal_ocaml_pump(void) { return 1; } +int32_t journal_ocaml_failure_reason(int32_t operation) { (void)operation; return 0; } void journal_ocaml_platform_event(const char *d, int32_t l) { (void)d; (void)l; } void journal_ocaml_platform_response(const char *d, int32_t l) { (void)d; (void)l; } void journal_ocaml_platform_failure(const char *d, int32_t l) { (void)d; (void)l; } diff --git a/.agents/skills/journal-build/scripts/test_journal_build.py b/.agents/skills/journal-build/scripts/test_journal_build.py index 7c45b1cc..85519709 100644 --- a/.agents/skills/journal-build/scripts/test_journal_build.py +++ b/.agents/skills/journal-build/scripts/test_journal_build.py @@ -132,6 +132,16 @@ def setUpClass(cls): cls.build = importlib.util.module_from_spec(spec) spec.loader.exec_module(cls.build) + def test_link_stub_exports_bounded_failure_getter(self): + with tempfile.TemporaryDirectory() as directory: + source = Path(directory)/'stub.c' + source.write_text(self.build.STUB) + caller = Path(directory)/'caller.c' + caller.write_text('#include \nextern int32_t journal_ocaml_failure_reason(int32_t);\nint main(void){return journal_ocaml_failure_reason(1);}\n') + executable = Path(directory)/'stub-test' + subprocess.run(['xcrun','clang',str(source),str(caller),'-o',str(executable)],check=True,capture_output=True) + subprocess.run([str(executable)],check=True) + @contextmanager def pinned_switch(self): """Real offline switch; original source metadata deliberately has no url.""" diff --git a/app/application.ml b/app/application.ml index 2d9593a9..04ed980c 100644 --- a/app/application.ml +++ b/app/application.ml @@ -1023,9 +1023,11 @@ let failure_source_message = function let record_failure_source state = function | Journal_graph_runtime.Worker_failure worker_failure -> record_runtime_worker_failure state worker_failure - | Projection_failure _ -> + | Projection_failure source -> + let diagnostic = Logseq_db_types.Failure_diagnostic.of_source + ~operation:"projectGraph" ~reason:"invalidContent" source in let error = Logseq_db_worker.Error.create ~code:Unsupported_semantics - ~message:"The graph view could not be prepared. Retry the operation." ~details:[] + ~message:(Logseq_db_types.Failure_diagnostic.message diagnostic) ~details:[] |> Result.get_ok in record_worker_error state ~operation:"projectGraph" ~phase:"presentation" error ;; @@ -1056,13 +1058,32 @@ let service_error ~operation message = let report_ui_error ?(code = Logseq_db_worker.Error.Invalid_request) ?(message = "The operation could not finish. Retry the operation or reopen Journal.") ?recovery state ~operation ~phase = - if List.exists (fun (e:worker_error_occurrence) -> e.active && e.operation=operation - && e.phase=Some phase && e.graph_generation=state.graph_state.generation) state.worker_errors + let latest = List.find_opt (fun (e:worker_error_occurrence) -> + e.active && e.operation=operation && e.phase=Some phase + && e.graph_generation=state.graph_state.generation) state.worker_errors in + if Option.fold ~none:false ~some:(fun e -> + Logseq_db_worker.Error.code e.error = code + && String.equal (Logseq_db_worker.Error.message e.error) message) latest then state else record_worker_error state ~operation ~phase ?recovery (Logseq_db_worker.Error.create ~code ~message ~details:[] |> Result.get_ok) ;; +let report_decode_error state ~operation source = + let diagnostic = Logseq_db_types.Failure_diagnostic.of_source + ~operation ~reason:"parseFailed" source in + report_ui_error state ~operation ~phase:"native" + ~message:(Logseq_db_types.Failure_diagnostic.message diagnostic) +;; + +let report_view_decode_error state source = + match Logseq_db_types.Failure_diagnostic.decode source with + | Some diagnostic -> report_decode_error state + ~operation:(Logseq_db_types.Failure_diagnostic.operation diagnostic) source + | None -> report_decode_error state ~operation:"decodeExtensionEvent" + "Native event shape is invalid" +;; + let graph_lifecycle_owns_publication error = let trace = Logseq_db_worker.Error.trace error in not (String.equal trace.origin.operation "terminalStorageFailure") @@ -3689,7 +3710,9 @@ let manager_page ?picker_signal state dispatch = unavailable ~title:"Unable to prepare your account" ~symbol:"person.crop.circle.badge.exclamationmark" ~message:"Retry to check your account and open your journal." ~actions:(capsule ~id:"journal-startup-retry" ~command:Startup_retry "Retry" "arrow.clockwise") - |> toolbar "Journals" [] + |> toolbar "Journals" + [ V.Toolbar.item ~key:(Ui.Key.string "startup-diagnostics") + ~placement:Secondary_action diagnostics ] | Interaction {phase=Signed_out;_},None -> unavailable ~title:"Sign in to open a graph" ~symbol:"person.crop.circle" ~message:"Connect your account to access your graphs." ~actions:(V.empty ()) @@ -3940,6 +3963,7 @@ type app_context = (Graph_service.request, Graph_service.response, Graph_service.push) Worker.client ; send_action : action -> unit ; apply_platform : bytes -> unit Effect.t + ; fail_platform : int -> string -> (int64 * int64) option -> unit ; running : bool ref ; release_staging : unit -> unit } @@ -6837,7 +6861,9 @@ let start k result | None -> ()); (match result with - | Error _ when current -> report_ui_error model ~operation:"platformRequest" ~phase:"native" + | Error "native.platform.cancelled" when current -> model + | Error reason when current -> report_ui_error model ~operation:"platformRequest" ~phase:"native" + ~message:(Journal_bridge.platform_failure_message reason) | Ok _ | Error _ -> model) | Environment_changed snapshot -> prepare_presentation (sync_startup_presentation { model with environment = snapshot }) @@ -7010,8 +7036,8 @@ let start let on_event payload = match Journal_asset_import.decode_event payload with | Ok event -> dispatch.send (Capture_asset event) - | Error _ -> Effect.run (set_state (fun state -> - report_ui_error state ~operation:"decodeAttachmentSelection" ~phase:"native")) + | Error source -> Effect.run (set_state (fun state -> + report_decode_error state ~operation:"decodeAttachmentSelection" source)) in let dispatch = match owner with @@ -7606,8 +7632,26 @@ let start Hashtbl.iter (fun _ batch -> discard_import_batches [ batch ]) capture_staged_items; Hashtbl.clear capture_staged_items in + let fail_platform tag reason supplied_scope = + let owner_scope = startup_scope !state_ref in + let matches_scope () = match supplied_scope with + | None -> true (* Validated legacy host envelope. *) + | Some(account,presentation) -> account=Int64.of_int owner_scope.account_generation + && presentation=Int64.of_int owner_scope.presentation_generation in + let response_tag=response_tag tag in + let owner=Hashtbl.find_opt pending_platform response_tag in + if matches_scope () then Journal_pump.enqueue pump (fun () -> + if !running && startup_scope !state_ref=owner_scope then + if tag=0 then send_action(Update(fun state -> + report_ui_error state ~operation:"decodePlatformRequest" ~phase:"native" + ~message:(Journal_bridge.platform_failure_message reason),Effect.ignore)) + else match owner,Hashtbl.find_opt pending_platform response_tag with + | Some expected,Some current when expected==current -> + send_action(Platform_response(response_tag,Error reason)) + | _ -> ()) + in let context = - { app; pump; client; send_action; apply_platform; running; release_staging } + { app; pump; client; send_action; apply_platform; fail_platform; running; release_staging } in current_app := Some context; ignore (dispatch_worker Graph_service.Get_graph_state : Worker.send_result); @@ -7694,8 +7738,8 @@ let create (match !current_app with | Some { app; send_action; _ } -> Ui.with_event_error_handler - (fun operation -> send_action (Update (fun state -> - report_ui_error state ~operation ~phase:"native", Effect.ignore))) + (fun diagnostic -> send_action (Update (fun state -> + report_view_decode_error state diagnostic, Effect.ignore))) (fun () -> ignore (Lui_app.dispatch_event app event); ignore (Lui_app.flush app)) @@ -7707,8 +7751,8 @@ let create (match !current_app with | Some { app; send_action; _ } -> Ui.with_event_error_handler - (fun operation -> send_action (Update (fun state -> - report_ui_error state ~operation ~phase:"native", Effect.ignore))) + (fun diagnostic -> send_action (Update (fun state -> + report_view_decode_error state diagnostic, Effect.ignore))) (fun () -> (match Lui_runtime.extension_identifier (Lui_app.runtime app) node with | Some identifier -> @@ -7717,9 +7761,11 @@ let create report_ui_error state ~operation ~phase:"native", Effect.ignore)) in (match name, Lui_protocol.String_map.find_opt "id" values, Lui_protocol.String_map.find_opt "payload" values with - | "event", Some (IntValue (-1)), Some (StringValue "diagnostic.nativePropsDecode") -> report "decodeNativeProps" - | "event", Some (IntValue (-1)), Some (StringValue "diagnostic.nativeEventEncode") -> report "encodeNativeEvent" - | "event", Some (IntValue (-1)), Some (StringValue "diagnostic.nativeEventDelivery") -> report "deliverNativeEvent" + | "event", Some (IntValue (-1)), Some (StringValue code) -> + (match Journal_bridge.native_diagnostic code with + | Some(operation,message) -> send_action (Update(fun state -> + report_ui_error state ~operation ~phase:"native" ~message,Effect.ignore)) + | None -> report "decodeNativeEvent") | "event", Some (IntValue _), Some (StringValue _) -> ignore (Lui_app.dispatch_event app (Lui_protocol.ExtensionEvent (node, identifier, name, values))) @@ -7752,8 +7798,8 @@ let create then ( match Journal_platform.decode_environment_event bytes with | Ok snapshot -> send_action (Environment_changed snapshot) - | Error _ -> send_action (Update (fun state -> - report_ui_error state ~operation:"decodeEnvironment" ~phase:"native", Effect.ignore))) + | Error source -> send_action (Update (fun state -> + report_decode_error state ~operation:"decodeEnvironment" source, Effect.ignore))) else Effect.run (apply_platform bytes)) | None -> () in @@ -7773,17 +7819,12 @@ let create in let platform_failure payload = match !current_app with - | Some { pump; send_action; _ } -> - let bytes = Bytes.of_string payload in - if Bytes.length bytes >= 8 - then ( - let tag = response_tag (Bytes.get_uint16_le bytes 6) in - Journal_pump.enqueue pump (fun () -> - send_action - (Platform_response (tag, Error "application platform request failed")))) - else Journal_pump.enqueue pump (fun () -> - send_action (Update (fun state -> - report_ui_error state ~operation:"decodePlatformFailure" ~phase:"native", Effect.ignore))) + | Some { pump; send_action; fail_platform; running; _ } -> + (match Journal_bridge.decode_platform_failure payload with + | Some(tag,reason,scope) -> fail_platform tag reason scope + | None -> Journal_pump.enqueue pump (fun () -> + if !running then send_action(Update(fun state -> + report_ui_error state ~operation:"decodePlatformFailure" ~phase:"native",Effect.ignore)))) | None -> () in let dispose () = diff --git a/app/journal_asset_import.ml b/app/journal_asset_import.ml index 82a76ca9..f25f25fc 100644 --- a/app/journal_asset_import.ml +++ b/app/journal_asset_import.ml @@ -70,8 +70,14 @@ module Json = struct end let read_event payload = - try Logseq_db_types.Json_codec.decode Json.event_of_yojson (Yojson.Safe.from_string payload) - with Yojson.Json_error _ -> Error "Invalid attachment selection" + try + Logseq_db_types.Json_codec.decode Json.event_of_yojson (Yojson.Safe.from_string payload) + |> Result.map_error (function + | ("invalid UTF-8 field name" | "duplicate JSON field" | "invalid UTF-8 string" + | "non-finite JSON number" | "nonstandard JSON value" | "JSON exceeds byte budget" + | "invalid JSON value") as source -> source + | _ -> "Native event shape is invalid") + with Yojson.Json_error _ -> Error "Native event JSON syntax is invalid" ;; let staged_of_pick (pick : Json.pick) = diff --git a/app/journal_bridge.ml b/app/journal_bridge.ml index 6786cf49..4c3b0db8 100644 --- a/app/journal_bridge.ml +++ b/app/journal_bridge.ml @@ -81,8 +81,16 @@ let platform_failure payload = (hooks ()).platform_failure payload let dispose () = (hooks ()).dispose () let root_node () = (hooks ()).root_node () +let classify_exception = function + | Failure message when message = "application.decodeStartup.failed" -> 5 + | Failure message when message = "application.startWorker.failed" -> 6 + | Failure message when message = "application.mount.failed" -> 7 + | _ -> 2 +;; + let register hooks = current := Some hooks; + Callback.register "journal_ocaml_classify_exception" classify_exception; Callback.register "lui_ocaml_init" initialize; Callback.register "lui_ocaml_appear" appear; Callback.register "lui_ocaml_press" press; @@ -111,3 +119,75 @@ let register hooks = Callback.register "journal_ocaml_platform_response" platform_response; Callback.register "journal_ocaml_platform_failure" platform_failure ;; + +let platform_reasons = + [ 1, "cancelled", "The native request was cancelled." + ; 2, "requestDecode", "The native request format could not be read." + ; 3, "responseEncode", "The native response could not be encoded." + ; 4, "clipboardUnavailable", "The clipboard could not accept the copied text." + ; 5, "serviceUnavailable", "The native service is currently unavailable. Retry the operation." + ; 6, "serviceFailed", "The native service could not complete the request. Retry the operation." + ; 7, "authenticationRequired", "Sign in again to complete the request." + ; 8, "invalidBinding", "The saved account connection could not be read." + ; 9, "network", "The native account request could not connect. Check your connection and retry." + ; 10, "secureStorage", "Secure account storage is unavailable. Unlock your device and retry." + ; 11, "invalidPacket", "The native service returned an invalid response." + ] +;; + +let platform_failure_message code = + match List.find_opt (fun (_,reason,_)->code="native.platform."^reason) platform_reasons with + | Some (_,_,message) -> code ^ " · " ^ message + | None -> "native.platform.unknown · The native request could not finish. Retry the operation." +;; + +let decode_platform_failure payload = + let bytes=Bytes.of_string payload in + let size=Bytes.length bytes in + let known tag=List.mem tag [6;8;10;13;20;22;28;30;32] in + if size=24 && String.sub payload 0 4="LJF1" then ( + let tag=Bytes.get_uint16_le bytes 4 and reason=Bytes.get_uint16_le bytes 6 in + let account=Bytes.get_int64_le bytes 8 and presentation=Bytes.get_int64_le bytes 16 in + match List.find_opt (fun (value,_,_)->value=reason) platform_reasons with + | Some (_,name,_) when (known tag || (tag=0 && reason=2)) && account>=0L && presentation>=0L + && account<=Int64.of_int max_int && presentation<=Int64.of_int max_int -> + Some(tag,"native.platform."^name,Some(account,presentation)) + | _ -> None) + else if size>=32 && size<=32+262144 && String.sub payload 0 4="LJP2" + && Bytes.get_uint16_le bytes 4=2 + && String.sub payload 8 16=String.make 16 '\000' + && String.sub payload 28 4=String.make 4 '\000' + && Int32.equal (Bytes.get_int32_le bytes 24) (Int32.of_int(size-32)) then ( + let tag=Bytes.get_uint16_le bytes 6 in + if known tag then Some(tag,"native.platform.serviceFailed",None) else None) + else None +;; + +let native_diagnostic source = + let families= + ["diagnostic.nativePropsDecode","decodeNativeProps"; + "diagnostic.nativeScrollDecode","decodeNativeScroll"; + "diagnostic.nativeEventDelivery","deliverNativeEvent"; + "diagnostic.nativeEventEncode","encodeNativeEvent"] in + match List.find_opt (fun (prefix,_)->source=prefix || String.starts_with ~prefix:(prefix^".") source) families with + | None -> None + | Some(prefix,operation) -> + let suffix=if source=prefix then "unknown" else String.sub source (String.length prefix+1) (String.length source-String.length prefix-1) in + let reason,message = match suffix with + | "missingPayload" -> suffix,"The required native payload is missing." + | "typeMismatch" -> suffix,"A native field has an unexpected type." + | "keyNotFound" -> suffix,"A required native field is missing." + | "valueNotFound" -> suffix,"A required native value is missing." + | "dataCorrupted" -> suffix,"The native payload format is invalid." + | "invalidPayload" -> suffix,"The native payload is unsupported." + | "backendUnsupportedProperty" -> suffix,"The native backend rejected an unsupported property." + | "backendUnknownNode" -> suffix,"The native backend could not find the destination node." + | "backendUnknownExtension" -> suffix,"The native extension is not registered." + | "backendFingerprintMismatch" -> suffix,"The native extension schema does not match." + | "backendInvalidHierarchy" -> suffix,"The native node hierarchy is invalid." + | "backendInvalidEvent" -> suffix,"The native event schema is invalid." + | "backendRegistryInvalid" -> suffix,"The native extension registry is invalid." + | "backendInvalidBatch" -> suffix,"The native backend rejected the event batch." + | _ -> "unknown","The native operation could not finish. Retry the operation." + in Some(operation,"native."^operation^"."^reason^" · "^message) +;; diff --git a/app/journal_bridge.mli b/app/journal_bridge.mli index f4696141..64f8958b 100644 --- a/app/journal_bridge.mli +++ b/app/journal_bridge.mli @@ -26,10 +26,10 @@ type hooks = ; (* Host -> OCaml: an LJP2 response envelope completing an earlier platform request. Binary-safe string. *) platform_response : string -> unit - ; (* Host -> OCaml: the platform could not answer a request (decode or - service failure). Carries the original request envelope; the pending - continuation resolves with an error, matching the old bridge's - request-failure path. *) + ; (* Host -> OCaml: the platform could not answer a request. Carries a finite + LJF1 reason/scope envelope; a validated original LJP2 request remains + supported for legacy hosts. Current cancellation still completes the + pending continuation but does not create a failure ledger row. *) platform_failure : string -> unit ; (* Tears the app down; returns the final patch batch. *) dispose : unit -> string @@ -51,3 +51,13 @@ external wakeup : unit -> unit = "journal_ml_wakeup" (* Forwards one LJP2 request envelope to the host platform bridge (the host answers asynchronously through [platform_response]). *) external platform_request : string -> unit = "journal_ml_platform_request" + +(** Decode a versioned, finite native failure (LJF1) or a validated legacy LJP2 + request. The result contains a known request tag and fixed reason code only; + no request body or exception text is returned. LJF1 also includes bounded + account/presentation generations. Tag zero is decode failure. *) +val decode_platform_failure : string -> (int * string * (int64 * int64) option) option +val platform_failure_message : string -> string +(** Recognized native diagnostic family and finite subcause. Unrecognized + suffixes use a fixed unknown reason; arbitrary payloads are never echoed. *) +val native_diagnostic : string -> (string * string) option diff --git a/app/journal_environment.ml b/app/journal_environment.ml index c0583c9b..9bf1272c 100644 --- a/app/journal_environment.ml +++ b/app/journal_environment.ml @@ -34,7 +34,12 @@ type wire_snapshot = let decode_json (json : Yojson.Basic.t) = let ( let* ) = Result.bind in - let* wire = Logseq_db_types.Json_codec.decode wire_snapshot_of_yojson (json :> Yojson.Safe.t) in + let* wire = Logseq_db_types.Json_codec.decode wire_snapshot_of_yojson (json :> Yojson.Safe.t) + |> Result.map_error (function + | ("invalid UTF-8 field name" | "duplicate JSON field" | "invalid UTF-8 string" + | "non-finite JSON number" | "nonstandard JSON value" | "JSON exceeds byte budget" + | "invalid JSON value") as source -> source + | _ -> "Native event shape is invalid") in let* brightness = match wire.brightness with | "light" -> Ok Light diff --git a/app/journal_lui_bridge.c b/app/journal_lui_bridge.c index 3e345a15..f100d578 100644 --- a/app/journal_lui_bridge.c +++ b/app/journal_lui_bridge.c @@ -22,6 +22,28 @@ typedef void (*journal_wakeup_callback)(void); typedef void (*journal_platform_request_callback)(const char *data, int32_t length); +/* Main-thread entries may synchronously reenter through a patch callback. + Each frame commits its own result AFTER callback delivery. The getter is + immediate and operation-matched; success clears a previous failure. */ +static _Thread_local int32_t failure_operation; +static _Thread_local int32_t failure_reason; +static int operation_for(const char *name) { + if (strcmp(name,"lui_ocaml_init")==0) return 1; + if (strcmp(name,"lui_ocaml_dispose")==0) return 2; + if (strcmp(name,"journal_ocaml_pump")==0) return 3; + if (strcmp(name,"journal_ocaml_platform_event")==0) return 4; + if (strcmp(name,"journal_ocaml_platform_response")==0) return 5; + if (strcmp(name,"journal_ocaml_platform_failure")==0) return 6; + if (strcmp(name,"lui_ocaml_root_node")==0) return 8; + return 7; +} +static void completed(int operation,int reason) { + failure_operation=operation; failure_reason=reason; +} +LUI_EXPORT int32_t journal_ocaml_failure_reason(int32_t operation) { + return operation == failure_operation ? failure_reason : 0; +} + static int runtime_started = 0; static lui_patch_callback patch_callback = NULL; static journal_wakeup_callback wakeup_callback = NULL; @@ -34,18 +56,44 @@ static void report_ocaml_exception(const char *where, value result) { fprintf(stderr, "journal_lui_bridge: operation=%s code=ocamlCallbackFailed\n", where); } +/* Only the OCaml owner's exact allowlist is queried. Never format an exception + or copy its body into the diagnostic channel. Classification itself may fail. */ +static int classify_exception(value result) { + const value *classify = caml_named_value("journal_ocaml_classify_exception"); + if (classify == NULL) return 2; + CAMLparam0(); + CAMLlocal2(exception, classified); + exception = Extract_exception(result); + classified = caml_callback_exn(*classify,exception); + int reason = 2; + if (!Is_exception_result(classified) && Is_long(classified)) { + int candidate = Int_val(classified); + if (candidate >= 5 && candidate <= 7) reason = candidate; + } + CAMLreturnT(int,reason); +} + typedef struct { int accepted; char *json; lui_patch_callback callback; + int operation; + int reason; } patch_response; +static patch_response pending_response(const char *name, int invalid_input) { + patch_response response = {0,NULL,NULL,operation_for(name),invalid_input ? 4 : 1}; + return response; +} + /* Called with the runtime held. Never hand an OCaml heap pointer to the host: applying a patch can synchronously dispatch another event and trigger GC. */ static patch_response copy_patch(const char *where, value result) { - patch_response response = {0, NULL, patch_callback}; + patch_response response = pending_response(where,0); + response.callback = patch_callback; if (Is_exception_result(result)) { report_ocaml_exception(where, result); + response.reason = classify_exception(result); return response; } size_t length = caml_string_length(result); @@ -53,12 +101,14 @@ static patch_response copy_patch(const char *where, value result) { response.json = malloc(length + 1); if (response.json == NULL) { fprintf(stderr, "journal_lui_bridge: cannot copy patch in %s\n", where); + response.reason = 3; return response; } memcpy(response.json, String_val(result), length); response.json[length] = '\0'; } response.accepted = 1; + response.reason = 0; return response; } @@ -72,6 +122,7 @@ static int release_and_deliver(patch_response response) { response.callback(response.json); free(response.json); } + completed(response.operation,response.reason); return response.accepted; } @@ -100,6 +151,7 @@ LUI_EXPORT int32_t lui_ocaml_start( const value *initialize = caml_named_value("lui_ocaml_init"); if (initialize == NULL) { caml_enter_blocking_section(); + completed(1,1); return 0; } CAMLparam0(); @@ -116,7 +168,7 @@ LUI_EXPORT int32_t lui_ocaml_start( } static int dispatch_long(const char *name, int64_t node) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response(name,0); caml_leave_blocking_section(); const value *dispatch = caml_named_value(name); if (dispatch != NULL) { @@ -142,7 +194,7 @@ LUI_EXPORT int32_t lui_ocaml_long_press(int64_t node) { static int dispatch_pointer(const char *name, int64_t node, double x, double y, int32_t modifiers, int32_t button, const char *target_class) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response(name,target_class == NULL); caml_leave_blocking_section(); const value *dispatch = caml_named_value(name); if (dispatch != NULL && target_class != NULL) { @@ -183,7 +235,7 @@ LUI_EXPORT int32_t lui_ocaml_pointer_leave(int64_t node) { } static int dispatch_string(const char *name, int64_t node, const char *text) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response(name,text == NULL); caml_leave_blocking_section(); const value *dispatch = caml_named_value(name); if (dispatch != NULL && text != NULL) { @@ -214,7 +266,7 @@ LUI_EXPORT int32_t lui_ocaml_double_press(int64_t node) { } LUI_EXPORT int32_t lui_ocaml_toggle_changed(int64_t node, int32_t checked) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("lui_ocaml_toggle_changed",0); caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_toggle_changed"); if (dispatch != NULL) { @@ -229,7 +281,7 @@ LUI_EXPORT int32_t lui_ocaml_radio_changed(int64_t node) { } LUI_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("lui_ocaml_slider_changed",0); caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_slider_changed"); if (dispatch != NULL) { @@ -241,7 +293,7 @@ LUI_EXPORT int32_t lui_ocaml_slider_changed(int64_t node, double fraction) { LUI_EXPORT int32_t lui_ocaml_scroll_completed(int64_t node, int64_t token, const char *outcome) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("lui_ocaml_scroll_completed",outcome == NULL); caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_scroll_completed"); if (dispatch != NULL && outcome != NULL) { @@ -258,7 +310,7 @@ LUI_EXPORT int32_t lui_ocaml_scroll_completed(int64_t node, int64_t token, LUI_EXPORT int32_t lui_ocaml_visible_range(int64_t node, int64_t first, int64_t last) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("lui_ocaml_visible_range",0); caml_leave_blocking_section(); const value *dispatch = caml_named_value("lui_ocaml_visible_range"); if (dispatch != NULL) { @@ -273,7 +325,7 @@ LUI_EXPORT int32_t lui_ocaml_picked(int64_t node, const char *payload) { } LUI_EXPORT int32_t lui_ocaml_stop(void) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("lui_ocaml_dispose",0); caml_leave_blocking_section(); const value *dispose = caml_named_value("lui_ocaml_dispose"); if (dispose != NULL) { @@ -307,7 +359,7 @@ LUI_EXPORT int32_t journal_ocaml_extension_event( int64_t node, const char *name, const char *payload) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("journal_ocaml_extension_event",name == NULL || payload == NULL); caml_leave_blocking_section(); const value *dispatch = caml_named_value("journal_ocaml_extension_event"); if (dispatch != NULL && name != NULL && payload != NULL) { @@ -327,7 +379,7 @@ LUI_EXPORT int32_t journal_ocaml_extension_event( patches. The host schedules this on the UI thread when the wakeup callback fires. */ LUI_EXPORT int32_t journal_ocaml_pump(void) { - patch_response response = {0, NULL, NULL}; + patch_response response = pending_response("journal_ocaml_pump",0); caml_leave_blocking_section(); const value *pump = caml_named_value("journal_ocaml_pump"); if (pump != NULL) { @@ -337,11 +389,11 @@ LUI_EXPORT int32_t journal_ocaml_pump(void) { return release_and_deliver(response); } -/* Host -> OCaml LJP2 envelopes (binary safe). */ +/* Host -> OCaml LJP2/LJF1 envelopes (binary safe). */ static int32_t deliver_platform(const char *name, const char *data, int32_t length) { - if (length < 0 || (length > 0 && data == NULL)) return 0; - int32_t accepted = 0; + if (length < 0 || (length > 0 && data == NULL)) { completed(operation_for(name),4); return 0; } + int32_t accepted = 0, reason = 1; caml_leave_blocking_section(); const value *handler = caml_named_value(name); if (handler != NULL) { @@ -349,11 +401,13 @@ static int32_t deliver_platform(const char *name, const char *data, value result = caml_callback_exn(*handler, payload); if (Is_exception_result(result)) { report_ocaml_exception(name, result); + reason = classify_exception(result); } else { - accepted = 1; + accepted = 1; reason = 0; } } caml_enter_blocking_section(); + completed(operation_for(name),reason); return accepted; } @@ -367,9 +421,8 @@ LUI_EXPORT int32_t journal_ocaml_platform_response(const char *data, return deliver_platform("journal_ocaml_platform_response", data, length); } -/* The host reports a failed platform request by passing the original - request envelope; OCaml resolves the pending continuation with an - error instead of leaving it parked forever. */ +/* The host reports a failed platform request with a finite LJF1 envelope. + The legacy original-request form remains supported by the OCaml decoder. */ LUI_EXPORT int32_t journal_ocaml_platform_failure(const char *data, int32_t length) { return deliver_platform("journal_ocaml_platform_failure", data, length); diff --git a/app/journal_view.ml b/app/journal_view.ml index c423ffca..9ddd331f 100644 --- a/app/journal_view.ml +++ b/app/journal_view.ml @@ -19,7 +19,18 @@ let with_event_error_handler handler f = Fun.protect ~finally:(fun () -> event_error_handler := previous) f ;; -let report_event_error operation = !event_error_handler operation +let event_decode_source = function + | ("invalid UTF-8 field name" | "duplicate JSON field" | "invalid UTF-8 string" + | "non-finite JSON number" | "nonstandard JSON value" | "JSON exceeds byte budget" + | "invalid JSON value" | "Native event JSON syntax is invalid") as source -> source + | _ -> "Native event shape is invalid" +;; + +let report_event_error operation source = + let diagnostic = Logseq_db_types.Failure_diagnostic.create + ~operation ~reason:"parseFailed" ~source:(event_decode_source source) () in + !event_error_handler (Logseq_db_types.Failure_diagnostic.encode diagnostic) +;; type toolbar_control_props = { mode : string; title : string } [@@deriving yojson] @@ -1676,7 +1687,7 @@ module View = struct ;; let read_json decoder payload = - try Logseq_db_types.Json_codec.decode decoder (Yojson.Safe.from_string payload) with Yojson.Json_error _ -> Error "invalid native list event" + try Logseq_db_types.Json_codec.decode decoder (Yojson.Safe.from_string payload) with Yojson.Json_error _ -> Error "Native event JSON syntax is invalid" ;; let vertical @@ -1728,11 +1739,11 @@ module View = struct match read_json Json.row_event_of_yojson payload with | Ok wire -> Option.iter (fun handler -> invoke handler Event.Payload.Unit) (Hashtbl.find_opt row_actions (wire.row, wire.key)) - | Error _ -> report_event_error "decodeListRowEvent" + | Error source -> report_event_error "decodeListRowEvent" source in let on_event (event : Journal_lui_native.event) = match read_json Json.event_of_yojson event.payload with - | Error _ -> report_event_error "decodeListEvent" + | Error source -> report_event_error "decodeListEvent" source | Ok wire -> match wire.event_type with | "visible_range" -> @@ -2107,7 +2118,7 @@ module Native_widget = struct payload with | Ok event -> callback event - | Error _ -> report_event_error "decodeExtensionEvent") + | Error source -> report_event_error "decodeExtensionEvent" source) | _ -> ()) ;; @@ -2120,7 +2131,7 @@ module Native_widget = struct ~on_event:(fun event -> match decode extension event with | Ok decoded -> on_event decoded - | Error _ -> report_event_error "decodeExtensionEvent") + | Error source -> report_event_error "decodeExtensionEvent" source) extension.Extension.identifier context parent diff --git a/apple-tests/application-platform/JournalApplicationPlatformTests.swift b/apple-tests/application-platform/JournalApplicationPlatformTests.swift index d1230bc9..599bdaed 100644 --- a/apple-tests/application-platform/JournalApplicationPlatformTests.swift +++ b/apple-tests/application-platform/JournalApplicationPlatformTests.swift @@ -11,10 +11,30 @@ struct JournalEnvironmentSample: Equatable, Codable {} @MainActor final class PlatformTestAuth: JournalAuthCapability { func currentUserID() async throws -> String? { "fixture-user" } func freshIDToken() async throws -> String { "fixture-token" } - func signOut() async throws {} + var signOutFailure: Error? + func signOut() async throws { if let signOutFailure { throw signOutFailure } } } @main struct JournalApplicationPlatformTests { @MainActor static func main() async throws { + func packet(_ tag: UInt8) -> Data { + var bytes = Data("LJP2".utf8); bytes.append(contentsOf: [2,0,tag,0]); bytes.append(Data(repeating:0,count:24)); return bytes + } + let faultAuth = PlatformTestAuth() + faultAuth.signOutFailure = NSError(domain:"SECRET token path",code:1) + let faultServices = JournalPlatformServices(auth:faultAuth, account:JournalAccountStore( + load:{nil},save:{_ in JournalAccountSave(commit:{},rollback:{})},clear:{}),managedSyncOrigin:"https://api.logseq.io") + let fault = JournalApplicationPlatform(services:faultServices) + let faultRuntime = JournalRuntime(); fault.connect(to:faultRuntime) + let failure = await fault.request(packet(10)) + precondition(failure == (Data([76,74,70,49,10,0,6,0])+Data(repeating:0,count:16)), "current failure needs safe LJF1 reason and original request tag") + faultAuth.signOutFailure = CancellationError() + let cancellation = await fault.request(packet(10)) + precondition(cancellation == (Data([76,74,70,49,10,0,1,0])+Data(repeating:0,count:16)), "current cancellation must finish its pending continuation") + let malformed = await fault.request(Data([1,2])) + precondition(malformed == (Data([76,74,70,49,0,0,2,0])+Data(repeating:0,count:16)), "malformed request needs bounded decode reason without copying bytes") + fault.disconnect() + let retired = await fault.request(packet(10)) + precondition(retired == nil, "retired requests must remain silent") let services = JournalPlatformServices(auth: PlatformTestAuth(), account: JournalAccountStore( load: { nil }, save: { _ in JournalAccountSave(commit: {}, rollback: {}) }, clear: {}), managedSyncOrigin: "https://api.logseq.io") @@ -48,8 +68,13 @@ struct JournalEnvironmentSample: Equatable, Codable {} defer { nextFinished = true } try? await platform.beginOnline() } - for _ in 0..<50 { await Task.yield() } - precondition(runtime.events.last == JournalPlatformWire.onlineRequested(next)) + for _ in 0..<1000 { + if runtime.events.last.flatMap({ try? JournalPlatformWire.startupScope($0) }) == next { break } + await Task.yield() + } + precondition(runtime.events.last?[6] == 34 + && runtime.events.last.flatMap({ try? JournalPlatformWire.startupScope($0) }) == next, + "A fresh presentation must request its own online permission") var permission = JournalPlatformWire.onlineRequested(next) permission[6] = 30 let granted = await platform.request(permission) @@ -58,6 +83,55 @@ struct JournalEnvironmentSample: Equatable, Codable {} precondition(nextFinished && services.onlineAllowed) platform.disconnect() precondition(rejectedPromptly, "Same-scope retry hung on an event that was rejected") + // Native continuation/scope ownership is outside the OCaml pure reducer. + // Drive the existing public owner, using only the isolated runtime port. + func makePlatform() -> (JournalApplicationPlatform, JournalRuntime) { + let services = JournalPlatformServices(auth: PlatformTestAuth(), account: JournalAccountStore( + load: { nil }, save: { _ in JournalAccountSave(commit: {}, rollback: {}) }, clear: {}), + managedSyncOrigin: "https://api.logseq.io") + let runtime = JournalRuntime() + let platform = JournalApplicationPlatform(services: services) + platform.connect(to: runtime) + return (platform, runtime) + } + let (retryFailure, retryRuntime) = makePlatform() + let held = Task { @MainActor in try? await retryFailure.beginOnline() } + while retryRuntime.events.isEmpty { await Task.yield() } + retryFailure.cancelAccountPreparation() + await held.value + retryFailure.retryOnline() + for _ in 0..<100 { await Task.yield() } + precondition(retryFailure.accountIssue?.kind == .unavailable, + "Current non-cancellation retry failure must remain visible") + retryFailure.disconnect() + + let (cancelledRetry, cancelledRuntime) = makePlatform() + cancelledRetry.retryOnline() + while cancelledRuntime.events.isEmpty { await Task.yield() } + cancelledRetry.disconnect() + for _ in 0..<100 { await Task.yield() } + precondition(cancelledRetry.accountIssue == nil, + "Cancelled retry must not become an account failure") + + let (reconnectedRetry, reconnectedRuntime) = makePlatform() + reconnectedRetry.retryOnline() + reconnectedRetry.disconnect() + reconnectedRetry.connect(to: reconnectedRuntime) + for _ in 0..<100 { await Task.yield() } + precondition(reconnectedRuntime.events.isEmpty && reconnectedRetry.accountIssue == nil, + "Retry scheduled by a retired connection cannot admit work into its replacement") + reconnectedRetry.disconnect() + + let (replacedRetry, replacedRuntime) = makePlatform() + replacedRetry.retryOnline() + var newerScope = JournalPlatformWire.onlineCancelled(next) + newerScope[6] = 32 + let scopeAccepted = await replacedRetry.request(newerScope) + precondition(scopeAccepted != nil) + for _ in 0..<100 { await Task.yield() } + precondition(replacedRuntime.events.isEmpty && replacedRetry.accountIssue == nil, + "Retry scheduled by an older startup scope cannot admit work into its replacement") + replacedRetry.disconnect() print("PASS: revoked retry terminates promptly; new presentation retry succeeds") } } diff --git a/apple-tests/authentication/JournalAuthenticationTests.swift b/apple-tests/authentication/JournalAuthenticationTests.swift index faea5833..9d1d2904 100644 --- a/apple-tests/authentication/JournalAuthenticationTests.swift +++ b/apple-tests/authentication/JournalAuthenticationTests.swift @@ -70,6 +70,73 @@ import Foundation await failed.submit() precondition(failed.canSubmit && failed.message != nil && !failed.message!.contains("SECRET"), "Authentication errors must be sanitized and retryable") + // The native presentation owner owns this failure mapping. These public + // submit/cancel paths cannot be reproduced by the OCaml graph reducer. + let known: [JournalCognitoError] = [.configuration, .callback, .expired, + .invalidToken, .storage, .unavailable, .request, .invalidGrant] + for reason in known { + let knownAPI = API() + knownAPI.failure = reason + let knownModel = JournalAuthentication(api: knownAPI) + await knownModel.submit() + precondition(knownModel.message == reason.errorDescription && knownModel.canSubmit, + "Known browser failure must retain its fixed recovery reason") + knownAPI.failure = nil + knownAPI.preparationFailure = reason + await knownModel.submit() + precondition(knownModel.message == reason.errorDescription && knownModel.canSubmit, + "Known preparation failure must retain its fixed recovery reason") + } + let detailedAPI = API() + let detailed = JournalCognitoFailure(category: .request, + detail: .init(source: .token, reason: .httpStatus, httpStatus: 401, oauth: .invalid_client)) + detailedAPI.failure = detailed + let detailedModel = JournalAuthentication(api: detailedAPI) + await detailedModel.submit() + precondition(detailedModel.message == detailed.errorDescription + && detailedModel.message!.contains("token.httpStatus") + && detailedModel.message!.contains("http 401") + && detailedModel.canSubmit && !detailedModel.complete, + "Known safe upstream detail must survive direct sign-in presentation") + detailedAPI.failure = nil + detailedAPI.preparationFailure = detailed + await detailedModel.submit() + precondition(detailedModel.message == detailed.errorDescription && detailedModel.canSubmit, + "Known safe upstream detail must survive preparation presentation") + let kinds: [JournalAccountIssue.Kind] = [.unsupportedFormat, .corruptBinding, + .secureStorageUnavailable, .network, .signInRequired, .accountChangeBlocked, .unavailable] + for kind in kinds { + let issue = JournalAccountIssue(kind: kind, stage: .identityLookup) + let issueAPI = API() + issueAPI.failure = issue + let issueModel = JournalAuthentication(api: issueAPI) + await issueModel.submit() + precondition(issueModel.message == issue.errorDescription, + "Known account issue must retain its fixed recovery reason") + issueAPI.failure = nil + issueAPI.preparationFailure = issue + await issueModel.submit() + precondition(issueModel.message == issue.errorDescription, + "Known account preparation issue must retain its fixed recovery reason") + } + let cancelledAPI = API() + cancelledAPI.suspended = true + let cancelledModel = JournalAuthentication(api: cancelledAPI) + let cancelledSubmit = Task { await cancelledModel.submit() } + while cancelledAPI.hold == nil { await Task.yield() } + cancelledModel.cancel() + cancelledAPI.hold!.resume(throwing: JournalCognitoError.storage) + await cancelledSubmit.value + precondition(cancelledModel.message == nil && !cancelledModel.complete && !cancelledModel.busy, + "Retired known error cannot overwrite a cancelled presentation") + let unknownPreparation = API() + unknownPreparation.accountPreparationMessage = nil + unknownPreparation.preparationFailure = NSError(domain: "fixture", code: 1, + userInfo: [NSLocalizedDescriptionKey: "token=SECRET password=SECRET"]) + let unknownModel = JournalAuthentication(api: unknownPreparation) + await unknownModel.submit() + precondition(unknownModel.message == "Unable to complete your account connection. Please try again.", + "Unknown preparation error must keep the safe fallback") print("PASS credential-free presentation, error retry, cancel, duplicate and retired completion") } } diff --git a/apple-tests/authentication/JournalCognitoNativeReasonTests.swift b/apple-tests/authentication/JournalCognitoNativeReasonTests.swift new file mode 100644 index 00000000..2df340e3 --- /dev/null +++ b/apple-tests/authentication/JournalCognitoNativeReasonTests.swift @@ -0,0 +1,138 @@ +import Synchronization + +// Native owner fixture: the runner replaces only OS entry points/type names at +// compile time. No Keychain, window, browser, auth session or credentials exist. +private enum FixtureSecurity { + struct State: Sendable { + var status: OSStatus = errSecSuccess + var updateStatus: OSStatus = errSecSuccess + var data: Data? = nil + var calls: [String] = [] + } + static let state = Mutex(State()) + static func copy(_ query: CFDictionary, _ item: UnsafeMutablePointer?) -> OSStatus { + state.withLock { value in + value.calls.append("load") + if let data = value.data { item?.pointee = data as CFData } + else { item?.pointee = "private-token-sentinel" as CFString } + return value.status + } + } + static func update(_ query: CFDictionary, _ attributes: CFDictionary) -> OSStatus { + state.withLock { $0.calls.append("update"); return $0.updateStatus } + } + static func add(_ query: CFDictionary, _ item: UnsafeMutablePointer?) -> OSStatus { + state.withLock { $0.calls.append("add"); return $0.status } + } + static func delete(_ query: CFDictionary) -> OSStatus { + state.withLock { $0.calls.append("delete"); return $0.status } + } +} +@MainActor final class FixtureAnchor {} +@MainActor protocol FixturePresentationContext: AnyObject { + func presentationAnchor(for session: FixtureWebAuthenticationSession) -> FixtureAnchor +} +@MainActor final class FixtureWebAuthenticationSession { + var presentationContextProvider: (any FixturePresentationContext)? + var prefersEphemeralWebBrowserSession = false + let callback: (URL?, Error?) -> Void + init(url: URL, callbackURLScheme: String?, completionHandler: @escaping (URL?, Error?) -> Void) { + callback = completionHandler + } + func start() -> Bool { + if FixtureBrowserState.started, let error = FixtureBrowserState.error { callback(nil, error) } + return FixtureBrowserState.started + } + func cancel() {} +} +@MainActor private enum FixtureBrowserState { + static var anchor: FixtureAnchor? = FixtureAnchor() + static var started = false + static var error: Error? +} +@main private struct JournalCognitoNativeReasonTests { + @MainActor static func rejected(_ expected: [String], _ operation: () async throws -> Void) async throws { + do { try await operation(); preconditionFailure("expected native rejection") } + catch { + let issue = JournalAccountIssue.classify(error, stage: .identityLookup) + precondition(issue.kind == .unavailable && issue.offersSignIn) + for part in expected { precondition(issue.diagnostic.contains(part), "missing native cause \(part): \(issue.diagnostic)") } + precondition(!issue.diagnostic.contains("private-") && !issue.diagnostic.contains("https://")) + } + } + @MainActor static func main() async throws { + // The advisory binding store has its own error policy. Enrich it without + // turning a transient storage failure into a rejected/cleared binding. + FixtureSecurity.state.withLock { $0.status = -25308; $0.updateStatus = -25308 } + let bindingOperations: [(String, JournalAccountIssue.Kind, () throws -> Void)] = [ + ("bindingLoad.status", .secureStorageUnavailable, { _ = try JournalLocalAccountBindingStore.loadData() }), + ("bindingWrite.status", .secureStorageUnavailable, { try JournalLocalAccountBindingStore.writeData(Data()) }), + ("recoveryClear.status", .secureStorageUnavailable, { try JournalLocalAccountBindingStore.clearRecoveryData() }), + ("bindingRestore.status", .secureStorageUnavailable, { try JournalLocalAccountBindingStore.restoreData(nil) }), + ("bindingClear.status", .unavailable, { try JournalLocalAccountBindingStore.clear() })] + for (expected, kind, operation) in bindingOperations { + do { try operation(); preconditionFailure("Expected binding storage rejection") } + catch { + let issue = JournalAccountIssue.classify(error, stage: .bindingWrite) + precondition(issue.kind == kind && !issue.rejectsBinding) + precondition(issue.diagnostic.contains(expected) && issue.diagnostic.contains("osstatus -25308"), + "binding storage lost safe cause: \(issue.diagnostic)") + } + } + FixtureSecurity.state.withLock { $0.status = errSecSuccess; $0.updateStatus = errSecSuccess; $0.data = nil } + do { _ = try JournalLocalAccountBindingStore.loadData(); preconditionFailure("wrong native type accepted") } + catch { + let issue = JournalAccountIssue.classify(error, stage: .bindingRead) + precondition(issue.diagnostic.contains("bindingLoad.wrongType") && !issue.rejectsBinding) + } + FixtureSecurity.state.withLock { $0.status = errSecSuccess } + let storage = JournalCognitoKeychain() + let tokens = JournalCognitoTokens(idToken: "synthetic", accessToken: "synthetic", refreshToken: "synthetic", + userID: "synthetic", issuer: "synthetic", clientID: "synthetic", expiresAt: Date()) + FixtureSecurity.state.withLock { $0.status = -25308 } + try await rejected(["keychainLoad.status", "osstatus -25308"]) { _ = try await storage.load() } + FixtureSecurity.state.withLock { $0.status = errSecSuccess } + try await rejected(["keychainLoad.wrongType"]) { _ = try await storage.load() } + FixtureSecurity.state.withLock { $0.data = Data(repeating: 65, count: 131073) } + try await rejected(["keychainLoad.responseTooLarge"]) { _ = try await storage.load() } + FixtureSecurity.state.withLock { $0.data = Data("private-token-sentinel".utf8) } + try await rejected(["keychainLoad.invalidRecord"]) { _ = try await storage.load() } + FixtureSecurity.state.withLock { $0.updateStatus = -25308 } + try await rejected(["keychainSave.status", "osstatus -25308"]) { try await storage.save(tokens) } + FixtureSecurity.state.withLock { $0.updateStatus = errSecItemNotFound; $0.status = -25299 } + try await rejected(["keychainAdd.status", "osstatus -25299"]) { try await storage.save(tokens) } + FixtureSecurity.state.withLock { $0.status = -25308 } + try await rejected(["keychainClear.status", "osstatus -25308"]) { try await storage.clear() } + try await rejected(["legacyClear.status", "osstatus -25308"]) { try await storage.retireLegacy() } + let oversized = JournalCognitoTokens(idToken: String(repeating: "a", count: 131073), accessToken: "synthetic", + refreshToken: "synthetic", userID: "synthetic", issuer: "synthetic", clientID: "synthetic", expiresAt: Date()) + try await rejected(["keychainSave.responseTooLarge"]) { try await storage.save(oversized) } + FixtureSecurity.state.withLock { $0.status = errSecItemNotFound } + let missing = try await storage.load(); precondition(missing == nil) + try await storage.clear(); try await storage.retireLegacy() + FixtureSecurity.state.withLock { $0.status = errSecSuccess; $0.data = try! JSONEncoder().encode(tokens) } + let restored = try await storage.load(); precondition(restored?.idToken == tokens.idToken) + print("PASS native Keychain load/save/add/clear/legacy, status/type/size/record, missing/success without Security I/O") + + let browser = JournalCognitoWebSession(), url = URL(string: "https://example.invalid")! + FixtureBrowserState.anchor = nil + try await rejected(["browser.missingAnchor"]) { _ = try await browser.authenticate(url, scheme: "synthetic") } + FixtureBrowserState.anchor = FixtureAnchor() + try await rejected(["browser.startRejected"]) { _ = try await browser.authenticate(url, scheme: "synthetic") } + FixtureBrowserState.started = true + FixtureBrowserState.error = NSError(domain: "private-token-sentinel", code: 77, + userInfo: [NSLocalizedDescriptionKey: "private-token-sentinel"]) + try await rejected(["browser.browserFailed"]) { _ = try await browser.authenticate(url, scheme: "synthetic") } + FixtureBrowserState.error = ASWebAuthenticationSessionError(.canceledLogin) + do { _ = try await browser.authenticate(url, scheme: "synthetic"); preconditionFailure("cancel returned success") } + catch is CancellationError {} + FixtureBrowserState.error = nil + let first = Task { try await browser.authenticate(url, scheme: "synthetic") } + for _ in 0..<30 { await Task.yield() } + try await rejected(["browser.operationInProgress"]) { _ = try await browser.authenticate(url, scheme: "synthetic") } + browser.cancel() + do { _ = try await first.value; preconditionFailure("cancel returned success") } + catch is CancellationError {} + print("PASS native browser missing-anchor/start-rejected/failed/overlap and cancellation without GUI") + } +} diff --git a/apple-tests/authentication/JournalCognitoTests.swift b/apple-tests/authentication/JournalCognitoTests.swift index d89e7938..f89d4916 100644 --- a/apple-tests/authentication/JournalCognitoTests.swift +++ b/apple-tests/authentication/JournalCognitoTests.swift @@ -13,6 +13,11 @@ import Security var browserError: Error? var tokenError: String? var status = 200 + var tokenData: Data? + var jwksData: Data? + var jwksStatus = 200 + var redirect = false + var transportError: Error? var tokenHold: CheckedContinuation? var holdTokens = false var browserHold: CheckedContinuation? @@ -91,14 +96,16 @@ import Security if self.holdBrowser { return try await withCheckedThrowingContinuation { self.browserHold = $0 } } return self.callback() }, cancel: { self.cancellations += 1 }), transport: { request in + if let error = self.transportError { throw error } self.requests.append(request) var data = Data() var status = 200 - if request.url!.path.hasSuffix("jwks.json") { data = self.jwks } + if request.url!.path.hasSuffix("jwks.json") { data = self.jwksData ?? self.jwks; status = self.jwksStatus } else if request.url!.path == "/oauth2/token" { if self.holdTokens { await withCheckedContinuation { self.tokenHold = $0 } } status = self.status - if let error = self.tokenError { + if let override = self.tokenData { data = override } + else if let error = self.tokenError { data = try JSONSerialization.data(withJSONObject: ["error": error, "error_description": "never display server secrets"]) } else { var object: [String: Any] = ["id_token": try self.token("id"), "access_token": try self.token("access"), @@ -107,7 +114,7 @@ import Security data = try JSONSerialization.data(withJSONObject: object) } } - return (data, HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!) + return (data, HTTPURLResponse(url: self.redirect ? URL(string: "https://secret.example/private?token=private-token-sentinel")! : request.url!, statusCode: status, httpVersion: nil, headerFields: nil)!) }, now: { self.now }) } var tokenRequests: [URLRequest] { requests.filter { $0.url?.path == "/oauth2/token" } } @@ -172,6 +179,43 @@ import Security print("PASS network cancellation retires owned refresh/sign-in I/O and retains session for retry") } @MainActor static func main() async throws { + // Drive the production session's response boundary; never inject an already + // classified reason. The owner must retain safe transport/shape metadata. + for mode in ["tokenHTTP", "unknownOAuth", "tokenShape", "tokenSize", "redirect", "jwksHTTP", "jwksShape", "jwksSize"] { + let f = try Issuer() + let expected: [String] + switch mode { + case "tokenHTTP": f.status = 503; f.tokenError = "server_error"; expected = ["token.httpStatus", "http 503", "oauth.server_error"] + case "unknownOAuth": f.status = 401; f.tokenError = "private-token-sentinel"; expected = ["token.httpStatus", "http 401", "oauth.unknown"] + case "tokenShape": f.tokenData = Data("{\"id_token\":\"private-token-sentinel\"}".utf8); expected = ["token.responseShape"] + case "tokenSize": f.tokenData = Data(repeating: 65, count: 131073); expected = ["token.responseTooLarge"] + case "redirect": f.redirect = true; expected = ["token.redirect"] + case "jwksHTTP": f.jwksStatus = 502; expected = ["jwks.httpStatus", "http 502"] + case "jwksShape": f.jwksData = Data("private-token-sentinel".utf8); expected = ["jwks.responseShape"] + default: f.jwksData = Data(repeating: 65, count: 262145); expected = ["jwks.responseTooLarge"] + } + do { try await f.session().signIn(); preconditionFailure("Expected \(mode) rejection") } + catch { + let issue = JournalAccountIssue.classify(error, stage: .identityLookup) + for part in expected { precondition(issue.diagnostic.contains(part), "missing safe cause \(part): \(issue.diagnostic)") } + precondition(issue.kind == .unavailable && issue.offersSignIn, "diagnostics must preserve account policy") + precondition(!issue.diagnostic.contains("private-") && !issue.diagnostic.contains("https://")) + } + precondition(f.stored == nil, "failed token response persisted credentials") + } + print("PASS bounded token/JWKS HTTP, OAuth, shape, size and redirect reasons without bodies or URLs") + for (error, expectedKind, reason) in [ + (URLError(.timedOut) as Error, JournalAccountIssue.Kind.network, "url -1001"), + (NSError(domain: NSOSStatusErrorDomain, code: -25308) as Error, .secureStorageUnavailable, "osstatus -25308"), + (NSError(domain: "private-token-sentinel", code: 77) as Error, .unavailable, "token.transport")] { + let f = try Issuer(); f.transportError = error + do { try await f.session().signIn(); preconditionFailure("transport failure admitted") } + catch { + let issue = JournalAccountIssue.classify(error, stage: .identityLookup) + precondition(issue.kind == expectedKind, "transport wrapper changed account recovery category") + precondition(issue.diagnostic.contains(reason) && !issue.diagnostic.contains("private-")) + } + } try await cancelledNetworkingRetainsSession() let issuer = try Issuer(), session = issuer.session(); try await session.signIn() let q = issuer.query(issuer.authorization!) diff --git a/apple-tests/native-events/JournalLUIEventsTests.swift b/apple-tests/native-events/JournalLUIEventsTests.swift index 6209b621..86116f0d 100644 --- a/apple-tests/native-events/JournalLUIEventsTests.swift +++ b/apple-tests/native-events/JournalLUIEventsTests.swift @@ -26,6 +26,8 @@ private func platformResponse(_ bytes: UnsafePointer?, _ length: Int32) - @_silgen_name("journal_ocaml_platform_failure") private func platformFailure(_ bytes: UnsafePointer?, _ length: Int32) -> Int32 +@_silgen_name("journal_ocaml_failure_reason") private func failureReason(_ operation:Int32) -> Int32 +nonisolated(unsafe) private var nestedFailure = false nonisolated(unsafe) private var patches: [String] = [] nonisolated(unsafe) private var callbackFailure: String? nonisolated(unsafe) private var reenter = false @@ -36,6 +38,11 @@ private let receivePatch: PatchCallback = { source in // Applying a real backend patch synchronously delivers deferred UI events. // Even a read-only bridge call must be able to reacquire the OCaml runtime. if rootNode() != 42 { callbackFailure = "callback could not query the runtime" } + if nestedFailure { + nestedFailure = false + if MainActor.assumeIsolated({JournalLUIEvents.dispatch(.textChanged(node:42,text:"raise"))}) != 0 + || failureReason(7) != 2 { callbackFailure = "nested reason unavailable" } + } if reenter { reenter = false if MainActor.assumeIsolated({ @@ -118,9 +125,19 @@ private let receivePatch: PatchCallback = { source in JournalLUIEvents.dispatch(.textChanged(node: node, text: "raise")) == 0, "OCaml exception reported success") try check(patches.isEmpty, "OCaml exception emitted a patch") + try check(failureReason(7) == 2 && failureReason(3) == 0, "reason must belong to the exact operation") + for (text, reason) in [("raise-startup",Int32(5)),("raise-worker",6),("raise-mount",7)] { + try check(JournalLUIEvents.dispatch(.textChanged(node:node,text:text)) == 0, + "typed startup exception was accepted") + try check(failureReason(7) == reason, "fixed startup exception reason was lost") + } try check( JournalLUIEvents.dispatch(.press(node: node)) == 1 && patches.count == 1, "runtime did not recover after an exception") + try check(failureReason(7) == 0, "success must clear old reason") + nestedFailure = true + try check(JournalLUIEvents.dispatch(.press(node:node)) == 1 && failureReason(7) == 0, + "outer success must restore its own result after nested failure") patches.removeAll() reenter = true try check(JournalLUIEvents.dispatch(.press(node: node)) == 1, "outer dispatch failed") diff --git a/apple-tests/native-events/bridge_fixture.ml b/apple-tests/native-events/bridge_fixture.ml index dbba976b..b5592a57 100644 --- a/apple-tests/native-events/bridge_fixture.ml +++ b/apple-tests/native-events/bridge_fixture.ml @@ -1,3 +1,30 @@ +(* Public native failure codec; these bytes contain no request body. *) +let () = + let failure=Bytes.make 24 '\000' in + Bytes.blit_string "LJF1" 0 failure 0 4; + Bytes.set_uint16_le failure 4 6; + Bytes.set_uint16_le failure 6 6; + Bytes.set_int64_le failure 8 7L; + Bytes.set_int64_le failure 16 9L; + assert(Journal_bridge.decode_platform_failure(Bytes.to_string failure)=Some(6,"native.platform.serviceFailed",Some(7L,9L))); + assert(Journal_bridge.decode_platform_failure(String.sub(Bytes.to_string failure)0 23)=None); + Bytes.set_uint16_le failure 6 99; + assert(Journal_bridge.decode_platform_failure(Bytes.to_string failure)=None); + Bytes.set_uint16_le failure 6 1; + assert(Journal_bridge.decode_platform_failure(Bytes.to_string failure)=Some(6,"native.platform.cancelled",Some(7L,9L))); + Bytes.set_int64_le failure 8 (-1L); + assert(Journal_bridge.decode_platform_failure(Bytes.to_string failure)=None); + let legacy=Bytes.make 32 '\000' in + Bytes.blit_string "LJP2" 0 legacy 0 4;Bytes.set_uint16_le legacy 4 2;Bytes.set_uint16_le legacy 6 6; + assert(Journal_bridge.decode_platform_failure(Bytes.to_string legacy)=Some(6,"native.platform.serviceFailed",None)); + Bytes.set_uint16_le legacy 4 3;assert(Journal_bridge.decode_platform_failure(Bytes.to_string legacy)=None); + assert(Journal_bridge.native_diagnostic "diagnostic.nativePropsDecode.keyNotFound"= + Some("decodeNativeProps","native.decodeNativeProps.keyNotFound · A required native field is missing.")); + assert(Journal_bridge.native_diagnostic "diagnostic.nativeScrollDecode.SECRET /private/token"= + Some("decodeNativeScroll","native.decodeNativeScroll.unknown · The native operation could not finish. Retry the operation.")); + assert(Journal_bridge.native_diagnostic "SECRET /private/token"=None) +;; + (* ABI ownership is in the host adapter, not the pure application reducer. These public hooks observe exactly what crossed the production C boundary. *) open Lui_protocol @@ -37,6 +64,9 @@ let dispatch = function | Press node -> patch node "press" | LongPress node -> patch node "longPress" | TextChanged (_, "raise") -> failwith "native event fixture rejection" + | TextChanged (_, "raise-startup") -> failwith "application.decodeStartup.failed" + | TextChanged (_, "raise-worker") -> failwith "application.startWorker.failed" + | TextChanged (_, "raise-mount") -> failwith "application.mount.failed" | TextChanged (node, text) -> patch node ("textChanged:" ^ text) | Submit node -> patch node "submit" | Dismiss node -> patch node "dismiss" diff --git a/apple-tests/photo-save/JournalPhotoSaveTests.swift b/apple-tests/photo-save/JournalPhotoSaveTests.swift index 3eb9c04b..b5090a69 100644 --- a/apple-tests/photo-save/JournalPhotoSaveTests.swift +++ b/apple-tests/photo-save/JournalPhotoSaveTests.swift @@ -4,10 +4,10 @@ import ImageIO import UniformTypeIdentifiers @main struct Tests { - @MainActor static func main() { + @MainActor static func main() async { var authorization = JournalPhotoSave.Authorization.notDetermined var permission: ((JournalPhotoSave.Authorization) -> Void)? - var completion: ((Bool) -> Void)? + var completion: ((Result) -> Void)? var staged: [URL] = [] var written: [URL] = [] var released = 0 @@ -34,16 +34,16 @@ import UniformTypeIdentifiers grant(.authorized) precondition(written == [a]) let firstCompletion = completion! - firstCompletion(true) - firstCompletion(false) + firstCompletion(.success(())) + firstCompletion(.failure(.init(stage: .photoWrite, category: .noSpace))) precondition(released == 1 && feedback == [.saved] && !pending.busy) authorization = .authorized pending.save(b) grant(.authorized) - firstCompletion(false) + firstCompletion(.failure(.init(stage: .photoWrite, category: .noSpace))) precondition(staged == [a,b] && written == [a,b] && released == 1 && pending.busy) - completion!(false) - precondition(feedback.last == .failed && released == 2) + completion!(.failure(.init(stage: .photoWrite, category: .noSpace))) + precondition(feedback.last == .failed(.init(stage: .photoWrite, category: .noSpace)) && released == 2) for status in [JournalPhotoSave.Authorization.denied, .restricted] { authorization = status let blocked = owner() @@ -70,8 +70,8 @@ import UniformTypeIdentifiers writing!.close() writing = nil precondition(released == 3) - done(true) - done(true) + done(.success(())) + done(.success(())) precondition(released == 4 && feedback.count == feedbackCount) let invalid = JournalPhotoSave(dependencies: .init( authorization: { .authorized }, requestAuthorization: { _ in }, @@ -86,13 +86,57 @@ import UniformTypeIdentifiers write: { _, _ in preconditionFailure("missing image written") }, feedback: { feedback.append($0) })) missing.save(a) - precondition(feedback.last == .failed && !missing.busy) + precondition(feedback.last == .failed(.init(stage: .photoStage, category: .sourceUnavailable)) && !missing.busy) authorization = .notDetermined let refusal = owner() refusal.save(a) permission!(.denied) precondition(!refusal.busy && released == 5 && feedback.last == .denied) - staging() + // The owner must preserve a bounded staging cause, never NSError text. + for (code, reason) in [(CocoaError.Code.fileReadNoSuchFile, "source-unavailable"), + (.fileReadNoPermission, "permission-denied"), + (.fileWriteOutOfSpace, "no-space")] { + var result: JournalPhotoSave.Feedback? + let classified = JournalPhotoSave(dependencies: .init( + authorization: { .authorized }, requestAuthorization: { _ in }, + stage: { _ in throw NSError(domain: NSCocoaErrorDomain, code: code.rawValue, + userInfo: [NSLocalizedDescriptionKey: "PRIVATE_FILENAME_TOKEN"]) }, + write: { _, _ in preconditionFailure("failed staging admitted") }, + feedback: { result = $0 })) + classified.save(a) + precondition(String(describing: result).contains(reason), "staging reason was discarded: \(reason)") + precondition(!String(describing: result).contains("PRIVATE_FILENAME_TOKEN")) + } + for (code, reason) in [(3305, "no-space"), (3311, "permission-denied"), + (3164, "network-required"), (3302, "invalid-resource"), + (3114, "library-unavailable")] { + let diagnostic = JournalAssetDiagnostic.classify(NSError(domain: "PHPhotosErrorDomain", code: code, + userInfo: [NSLocalizedDescriptionKey: "PRIVATE_FILENAME_TOKEN"]), stage: .photoWrite) + precondition(diagnostic.description.contains(reason), "Photos cause was discarded: \(reason)") + precondition(!diagnostic.description.contains("PRIVATE")) + } + precondition(JournalAssetDiagnostic.summary([]) == nil) + let summary = JournalAssetDiagnostic.summary([ + .init(stage: .fileCopy, category: .noSpace), .init(stage: .fileCopy, category: .noSpace), + .classify(NSError(domain: "PRIVATE_DOMAIN", code: 999, + userInfo: [NSLocalizedDescriptionKey: "PRIVATE_FILENAME_TOKEN"]), stage: .photoLoad)])! + precondition(summary.contains("file-copy.no-space=2") && summary.contains("photo-load.provider-unavailable=1")) + precondition(!summary.contains("PRIVATE")) + let stagedData = await JournalAssetStaging.shared.write(Data("synthetic bytes".utf8), ext: "bin") + guard case .success(let temporary) = stagedData else { preconditionFailure("staging failed") } + precondition(try! Data(contentsOf: temporary) == Data("synthetic bytes".utf8)) + guard case .success(let copied) = await JournalAssetStaging.shared.copy(temporary) else { + preconditionFailure("existing source copy failed") + } + precondition(try! Data(contentsOf: copied) == Data("synthetic bytes".utf8)) + JournalAssetStaging.discard(copied) + JournalAssetStaging.discard(copied) // Missing temporary file remains idempotent. + JournalAssetStaging.discard(temporary) + guard case .failure(let absent) = await JournalAssetStaging.shared.copy(temporary) else { + preconditionFailure("missing source accepted") + } + precondition(absent == .init(stage: .fileCopy, category: .sourceUnavailable)) + if !CommandLine.arguments.contains("--owner-only") { staging() } print("PASS: save authorization, selected source, duplicate/stale callbacks, failures and close/file lifetime") } @MainActor static func staging() { diff --git a/apple-tests/platform-services/JournalAccountThreadingTests.swift b/apple-tests/platform-services/JournalAccountThreadingTests.swift index 45c83a60..31307c87 100644 --- a/apple-tests/platform-services/JournalAccountThreadingTests.swift +++ b/apple-tests/platform-services/JournalAccountThreadingTests.swift @@ -106,6 +106,51 @@ enum JournalLocalAccountBindingStore { } @MainActor static func main() async throws { + // Configuration encoding is owned by the native startup producer before + // any OCaml request. These strings do not access the filesystem/network. + for (path,origin) in [("SECRET-relative-path","https://example.invalid"), + ("/tmp/native-synthetic","http://example.invalid"), + ("/"+String(repeating:"x",count:4097),"https://example.invalid")] { + do { + _ = try JournalNativeServices.startupPayload(applicationSupportPath:path,managedSyncOrigin:origin) + preconditionFailure("invalid startup configuration was admitted") + } catch { + precondition((error as? LocalizedError)?.errorDescription == + "The native startup configuration is invalid. Reopen Journal.", + "native startup producer discarded its known configuration reason") + } + } + let expectedStartup = try JournalStartupConfiguration.encode(applicationSupportPath:"/tmp/native-synthetic", + managedSyncOrigin:"https://example.invalid") + let actualStartup = try JournalNativeServices.startupPayload(applicationSupportPath:"/tmp/native-synthetic", + managedSyncOrigin:"https://example.invalid") + let expectedJSON = try JSONSerialization.jsonObject(with:Data(expectedStartup.dropFirst(8))) as! NSDictionary + let actualJSON = try JSONSerialization.jsonObject(with:Data(actualStartup.dropFirst(8))) as! NSDictionary + precondition(actualStartup.prefix(8) == expectedStartup.prefix(8) && actualJSON.isEqual(expectedJSON), + "startup header and payload facts must remain unchanged") + // The native recovery owner must distinguish format/shape while retaining + // both the canonical binding and opaque recovery bytes on every rejection. + for mode in ["format", "corrupt"] { + let bytes = mode == "format" ? try JSONSerialization.data(withJSONObject: [ + "version": 9, "id": UUID().uuidString, "replacement": Data("private-token-sentinel".utf8).base64EncodedString() + ]) : Data("private-token-sentinel".utf8) + let original = JournalLocalAccountBindingStore.state.withLock { state in + state.recovery = bytes; return state.value + } + let owner = JournalNativeServices.makePlatformServices() + do { _ = try await owner.response(for: .localAccount); preconditionFailure("Rejected recovery was exposed") } + catch let issue as JournalAccountIssue { + precondition(issue.kind == .unavailable && issue.stage == .bindingWrite && issue.offersSignIn) + let expected = mode == "format" ? "recovery.unsupportedFormat" : "recovery.invalidRecord" + precondition(issue.diagnostic.contains(expected), "recovery cause was discarded: \(issue.diagnostic)") + if mode == "format" { precondition(issue.diagnostic.contains("format 9")) } + precondition(!issue.diagnostic.contains("private-")) + } + precondition(JournalLocalAccountBindingStore.state.withLock { $0.value == original && $0.recovery == bytes }, + "Invalid recovery must fail closed without changing original bytes") + } + JournalLocalAccountBindingStore.state.withLock { $0.recovery = nil } + print("PASS recovery unsupported format vs invalid record, retained bytes and unchanged recovery policy") let platform = JournalNativeServices.makePlatformServices() let initial = try await platform.response(for: .localAccount) precondition(initial == .localAccount(userID: "fixture-user", origin: "https://api.logseq.io")) diff --git a/apple-tests/platform-services/JournalPlatformServicesTests.swift b/apple-tests/platform-services/JournalPlatformServicesTests.swift index 12a7c48f..f2329c3d 100644 --- a/apple-tests/platform-services/JournalPlatformServicesTests.swift +++ b/apple-tests/platform-services/JournalPlatformServicesTests.swift @@ -4,6 +4,7 @@ import Foundation var user: String? = nil var token = "fixture-token" var failSignOut = false + var userFailure: Error? var userCalls = 0 var tokenCalls = 0 var signOutCalls = 0 @@ -14,6 +15,7 @@ import Foundation func currentUserID() async throws -> String? { userCalls += 1 if holdUser { return try await withCheckedThrowingContinuation { heldUser = $0 } } + if let userFailure { throw userFailure } return user } func freshIDToken() async throws -> String { tokenCalls += 1; return token } @@ -50,6 +52,49 @@ import Foundation static func require(_ condition: Bool) { precondition(condition) } @MainActor static func main() async throws { let origin = "https://api.logseq.io" + // The Swift account owner catches these failures before any OCaml event. + // Drive its real response boundary so its projected issue is verified. + let reasonAuth = Auth() + let reasonService = JournalPlatformServices(auth: reasonAuth, account: Store(nil).capability, + managedSyncOrigin: origin) + _ = try await reasonService.response(for: .onlineRequested) + let cognitoReasons: [(JournalCognitoError, String)] = [ + (.configuration, "configuration"), (.callback, "callback"), (.expired, "expired"), + (.invalidToken, "invalidToken"), (.storage, "storage"), (.unavailable, "unavailable"), + (.request, "request"), (.invalidGrant, "invalidGrant")] + for (error, code) in cognitoReasons { + reasonAuth.userFailure = error + do { _ = try await reasonService.response(for: .authenticatedUser); fatalError("expected account failure") } + catch is JournalCognitoError {} + precondition(reasonService.accountIssue?.errorDescription == error.errorDescription, + "known Cognito reason must survive native account classification: \(code)") + precondition(reasonService.accountIssue?.diagnostic == "account.identityLookup.unavailable · cognito.\(code)") + precondition(reasonService.accountIssue?.offersSignIn == true, "preserve existing recovery policy") + } + let safeFallback = JournalAccountIssue(kind: .unavailable, stage: .identityLookup) + for (failure, expected, kind) in [ + (URLError(.timedOut, userInfo: [NSURLErrorFailingURLErrorKey: URL(string: "https://private-token-sentinel")!]) as Error, + "url -1001", JournalAccountIssue.Kind.network), + (NSError(domain: NSOSStatusErrorDomain, code: -25308, userInfo: [NSLocalizedDescriptionKey: "private-token-sentinel"]) as Error, + "osstatus -25308", JournalAccountIssue.Kind.secureStorageUnavailable)] { + reasonAuth.userFailure = failure + do { _ = try await reasonService.response(for: .authenticatedUser); fatalError("expected native failure") } + catch { } + precondition(reasonService.accountIssue?.kind == kind) + precondition(reasonService.accountIssue?.diagnostic.contains(expected) == true, "native code was discarded") + precondition(reasonService.accountIssue?.diagnostic.contains("private-") == false) + } + reasonAuth.userFailure = NSError(domain: "private-account-sentinel", code: 73, + userInfo: [NSLocalizedDescriptionKey: "private-token-sentinel"]) + do { _ = try await reasonService.response(for: .authenticatedUser); fatalError("expected unknown failure") } + catch { precondition((error as NSError).domain == "private-account-sentinel") } + precondition(reasonService.accountIssue == safeFallback, "unknown account error must keep safe fallback") + reasonAuth.userFailure = CancellationError() + do { _ = try await reasonService.response(for: .authenticatedUser); fatalError("expected cancellation") } + catch is CancellationError {} + precondition(reasonService.accountIssue == safeFallback, "cancellation must not publish a new account issue") + let existingIssue = JournalAccountIssue(kind: .unsupportedFormat, stage: .bindingRead, formatVersion: 9) + precondition(JournalAccountIssue.classify(existingIssue, stage: .identityLookup) == existingIssue) let old = JournalLocalAccount(userID: "offline-user", managedSyncOrigin: origin) let auth = Auth() let store = Store(old) diff --git a/apple-tests/platform-services/JournalRuntimeFailureTests.swift b/apple-tests/platform-services/JournalRuntimeFailureTests.swift index a75e2d78..2dc7acb7 100644 --- a/apple-tests/platform-services/JournalRuntimeFailureTests.swift +++ b/apple-tests/platform-services/JournalRuntimeFailureTests.swift @@ -58,7 +58,7 @@ let journalAppIcons: [String: String] = [:] case "pump": runtime.pump() case "platform-event": runtime.sendPlatformEvent(Data([0, 1])) default: - platform.response = scenario == "platform-response" ? Data([0, 1]) : nil + platform.response = scenario == "platform-response" ? Data([0, 1]) : (Data([76,74,70,49,6,0,6,0])+Data(repeating:0,count:16)) await runtime.deliverPlatformRequest(Data([0, 1])) } let expected: [String: JournalRuntime.Failure] = ["pump": .pump, "platform-event": .platformEvent, @@ -76,6 +76,8 @@ let journalAppIcons: [String: String] = [:] let message = reported!.localizedDescription precondition(!message.contains("SENSITIVE"), "Native failure must exclude raw renderer details") precondition(message.contains("runtime."), "Native failure must identify its fixed stage/code") + precondition(message.contains(scenario == "invalid-patch" ? "backendUnsupportedProperty" : "callbackFailed"), + "native runtime must preserve the safe producer reason") } print("PASS runtime \(scenario)") } diff --git a/apple-tests/platform-services/JournalRuntimeStartupBackend.swift b/apple-tests/platform-services/JournalRuntimeStartupBackend.swift index a707c4b7..99896010 100644 --- a/apple-tests/platform-services/JournalRuntimeStartupBackend.swift +++ b/apple-tests/platform-services/JournalRuntimeStartupBackend.swift @@ -1,5 +1,6 @@ import Foundation +public enum LUIBackendError: Error { case invalidBatch(String) } public struct LUIEvent: Sendable {} public struct LUIAppleExtensionRegistry { public init() {} } @MainActor public final class LUIAppleBackend { diff --git a/apple-tests/platform-services/JournalRuntimeStartupBridge.c b/apple-tests/platform-services/JournalRuntimeStartupBridge.c index 95cc67a1..995155a5 100644 --- a/apple-tests/platform-services/JournalRuntimeStartupBridge.c +++ b/apple-tests/platform-services/JournalRuntimeStartupBridge.c @@ -1,12 +1,13 @@ #include #include #include +int32_t journal_ocaml_failure_reason(int32_t operation){return 2;} static void (*request)(const char*,int32_t); static int mode,inside,calls,reentrant; int32_t fixture_inside(void){return inside;} int32_t fixture_calls(void){return calls;} int32_t fixture_reentrant(void){return reentrant;} void fixture_mode(int32_t m){mode=m;} -void fixture_emit(void){char bytes[]={1,2};request(bytes,2);memset(bytes,9,2);} +void fixture_emit(void){char bytes[32]={76,74,80,50,2,0,6,0};if(mode==5){request(bytes,32);memset(bytes,9,32);}else{char small[]={1,2};request(small,2);memset(small,9,2);}} void* worker(void*x){fixture_emit();return 0;} int32_t lui_ocaml_start(void (*patch)(const char*),int32_t p,int32_t h,const char*d,int32_t n){ inside=1; diff --git a/apple-tests/platform-services/JournalRuntimeStartupTests.swift b/apple-tests/platform-services/JournalRuntimeStartupTests.swift index b339b048..3f216e5c 100644 --- a/apple-tests/platform-services/JournalRuntimeStartupTests.swift +++ b/apple-tests/platform-services/JournalRuntimeStartupTests.swift @@ -16,7 +16,7 @@ let journalAppIcons: [String: String] = [:] func disconnect() { connected = false } func request(_ bytes: Data) async -> Data? { precondition(inside() == 0 && connected, "request before native start/connect finished") - precondition(Array(bytes) == [1, 2], "C bytes must be owned after callback") + precondition(Array(bytes) == [1, 2] || (bytes.count == 32 && bytes.prefix(8) == Data([76,74,80,50,2,0,6,0])), "C bytes must be owned after callback") begins += 1 if hold { return await withCheckedContinuation { waiters.append($0) } } return bytes @@ -25,7 +25,7 @@ let journalAppIcons: [String: String] = [:] @MainActor @main struct Test { static func main() async throws { let scenario = CommandLine.arguments[1] - mode(["rejected": 1, "patch-failure": 2, "worker": 3, "multiple": 4][scenario] ?? 0) + mode(["rejected": 1, "patch-failure": 2, "worker": 3, "multiple": 4,"same-tag":5][scenario] ?? 0) let p = JournalApplicationPlatform() let r = try JournalRuntime( platform: p, startupPayload: Data(), extensionRegistry: LUIAppleExtensionRegistry()) @@ -52,7 +52,19 @@ let journalAppIcons: [String: String] = [:] precondition(p.begins == count, "buffered requests were not delivered exactly once") precondition(!published, "root published before initial account request completed") precondition(calls() == 0 && reentrant() == 0) - if scenario == "cancelled" { + if scenario == "same-tag" { + emit() // Same tag enters the real C ingress while the first awaits. + while p.waiters.count < 2 { await Task.yield() } + let failure = Data([76,74,70,49,6,0,6,0]) + Data(repeating:0,count:16) + p.waiters.removeFirst().resume(returning:failure) + for _ in 0..<50 { await Task.yield() } + precondition(calls() == 0, "retired same-tag request completed its replacement") + p.waiters.removeFirst().resume(returning:failure) + try await boot.value + for _ in 0..<100 { if calls() == 1 { break }; await Task.yield() } + precondition(calls() == 1, "current same-tag failure did not complete") + r.stop() + } else if scenario == "cancelled" { boot.cancel() for _ in 0..<100 { if !p.connected { break } @@ -93,7 +105,7 @@ let journalAppIcons: [String: String] = [:] for w in p.waiters { w.resume(returning: scenario == "nil-response" ? nil : Data([1, 2])) } p.waiters = [] try await boot.value - precondition(published && calls() == count && reentrant() == 0) + precondition(published && calls() == (scenario == "nil-response" ? 0 : count) && reentrant() == 0) r.stop() } } diff --git a/apple-tests/platform-services/JournalSafeDiagnosticTests.swift b/apple-tests/platform-services/JournalSafeDiagnosticTests.swift new file mode 100644 index 00000000..b35753dc --- /dev/null +++ b/apple-tests/platform-services/JournalSafeDiagnosticTests.swift @@ -0,0 +1,23 @@ +import Foundation + +@main struct JournalSafeDiagnosticTests { + struct Value: Decodable { let count: Int } + static func main() throws { + let cases = [("{\"count\":\"SECRET-token\"}", "typeMismatch"), ("{}", "keyNotFound"), + ("{\"count\":null}", "valueNotFound"), ("SECRET /private/body", "dataCorrupted")] + for (source, expected) in cases { + do { let _:Value = try JournalSafeDiagnostic.decode(Value.self, from: Data(source.utf8)); fatalError("expected decode failure") } + catch let reason as JournalSafeDiagnostic { + precondition(reason.rawValue == expected) + precondition(!reason.localizedDescription.contains("SECRET") && !reason.localizedDescription.contains("private")) + } + } + precondition(JournalSafeDiagnostic.classify(NSError(domain:"SECRET",code:99, + userInfo:[NSLocalizedDescriptionKey:"token /private/body"])) == .unknown) + precondition(JournalSafeDiagnostic.backendBatch("unsupported property") == .backendUnsupportedProperty) + precondition(JournalSafeDiagnostic.backendBatch("SECRET custom graph content") == .backendInvalidBatch) + precondition(JournalSafeDiagnostic.classify(CocoaError(.fileWriteOutOfSpace)) == .noSpace) + precondition(JournalSafeDiagnostic.classify(CocoaError(.fileReadNoPermission)) == .permissionDenied) + print("PASS bounded native decoder/backend/storage reasons exclude arbitrary payloads") + } +} diff --git a/docs/agent-guide/implemented/bugfix/2026-10-24-complete-error-message-audit.md b/docs/agent-guide/implemented/bugfix/2026-10-24-complete-error-message-audit.md new file mode 100644 index 00000000..e694ade4 --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-24-complete-error-message-audit.md @@ -0,0 +1,45 @@ +# Complete Error Message Audit + +## Problem + +The first error-reason patch repairs the authoritative/worker path but its remaining-audit categories are too broad. Several listed omissions can be fixed without protected interfaces, while true enum/unit/nil information loss needs distinct producer-side work. These must not be conflated with permission blockers or intentional cancellation. + +## Decision + +Produce an exact inventory of every remaining confirmed loss from the two existing audit scopes: source function/line, lost information, current user surface, prior reason for leaving it, minimal safe repair and actual spec approval scope. Complete bounded existing-interface fixes with meaningful narrow tests, including special read mapping, available typed asset ledger projection, safe source forwarding and known native account classification. Preserve current top-level codes, blocking, retry, cancellation, ownership and data behavior. No real graph or Simulator operations in this follow-up. Protected spec and Dune files remain unchanged. + +## Alternatives considered + +### Classify every remaining item as intentional hiding + +Rejected: a known safe typed reason being erased is a reporting defect; privacy only forbids raw content, not finite cause categories. + +### Expand all protected interfaces immediately + +Rejected: AGENTS requires an explicit request to modify OCaml under spec/. Show exact proposed field/signature changes first when no existing path preserves the cause. + +## Acceptance criteria + +- Each remaining source from the earlier inventories has an exact disposition, with neither vague deferral nor false permission blockers. +- Small existing-interface fixes have demonstrated failing then passing behavior at their actual owner, without redundant new CI targets. +- Existing relevant tests pass; current source and prior6504 runtime evidence are distinguished. +- No real graph/DB/account, Simulator, protected spec, Dune, PR or release operation occurs. + +## Risks + +- Native Boolean/nil callbacks and asset enums may require genuine protocol/producer work; a list is not an implementation. +- Unknown external source text must remain a bounded safe summary, never raw body/credential/path content. + +## Questions + +- None for the bounded follow-up. Explicit permission for any protected spec change must be sought with its exact scope; no such change is assumed authorized. + +## Consequences + +The final external checklist enumerates every remaining finding from both original audit inventories: named source functions and points, the lost information, the actual current user surface, the unimplemented producer/receiver work and any real protected-interface alternative. It explicitly corrects the earlier grouping of deferred work as intentional hiding or permission blocking. Existing safe typed fields are not a reason to hide their category; raw private strings are not a safe fallback. Integer/Boolean/nil boundaries do not already contain a missing OCaml message. + +Four bounded follow-up repairs are implemented: the four special read Error branches use the existing safe mapping while retaining their previous Corrupt_storage top-level codes; the service projects fourteen existing typed asset failures into bounded diagnostics; the public worker reducer distinguishes invalid recovery pages from backend read failure; native AccountIssue preserves eight known Cognito reasons before authentication presentation. Asset retry/control outcomes, recovery termination/retry, scope and cancellation rules are unchanged. The new ordinary service projection value is in a non-spec lui .mli. No protected spec or Dune file changed. + +The service projection and recovery assertions each failed against the old behavior and pass after implementation. The native classifier reproduced its loss through actual Services.response before its fix; Services, Authentication and Cognito tests pass. The final runner suite passes50 cases, worker pure reducer20 cases and the service suite exits0; the application OCaml library compiles. The read adapter cannot deterministically create a closed-snapshot race through the public Runner interface: no test hook or new architecture was added solely for test classification, and no chosen reducer Error injection is mislabeled as producer reproduction. Its mechanical mapping uses inspection, prior safe-classifier coverage and existing real runner regression. + +This follow-up performs no App build, Simulator, user account, real graph/DB, PR or release operation. The earlier6504 build/runtime evidence is retained as an earlier source snapshot and is not claimed to contain these additional source changes. The full current patch and a follow-up-only patch are external reviewable deliverables. Unknown-directory preservation, obsolete/cancelled replies and bounded latest-error history remain deliberate behavior. Other named producer/bridge gaps remain explicitly unimplemented engineering work; only chosen changes to named protected spec signatures require explicit approval. diff --git a/docs/agent-guide/implemented/bugfix/2026-10-24-finish-error-reason-propagation.md b/docs/agent-guide/implemented/bugfix/2026-10-24-finish-error-reason-propagation.md new file mode 100644 index 00000000..20675808 --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-24-finish-error-reason-propagation.md @@ -0,0 +1,46 @@ +# Finish Error Reason Propagation + +## Problem + +The audit lists real cause losses that do not require protected spec changes. Stopping at that inventory does not fulfill the request to expose the available safe reasons. + +## Decision + +Implement the remaining audited cause propagation in the existing owners and output channels. The user explicitly approved the minimal snapshot state and scoped sync/worker runner spec changes. Preserve original result variants, blocking, retry, transaction and cancellation decisions. + +Use finite safe error descriptors, typed numeric HTTP/URL/OSStatus metadata and exact Int64 cursor strings. Asset causes are correlated to the exact internal request and graph/account generation, then retained across the matching terminal notification fanout. Preserve original native callback signatures and add an operation-owned finite reason getter. Versioned platform failures contain only finite reasons, tags and scope generations. Keep different current decoding causes visible and suppress only consecutive identical causes. Make Diagnostics reachable from account startup failure. + +## Alternatives considered + +### Stop at the detailed inventory + +Rejected by the user's explicit instruction to complete remaining authorized implementation. + +### Change failure categories to carry messages + +Rejected: diagnostics must not change retry/recovery/sync semantics. Existing string/notice paths and safe side channels are preferred; genuine spec changes require explicit permission. + +## Acceptance criteria + +- Every unprotected audit point is implemented or has a concrete discovered implementation blocker; arbitrary raw exception strings remain excluded. +- Exact protected files/minimal signatures are reported before edits, then paused unless explicit permission arrives. +- Necessary owner-level tests, privacy checks, applicable existing suites and isolated native rendering pass against final source. +- No real graph/account/DB, PR, push or release operations occur. + +## Risks + +- Runtime bridge diagnostics need operation and generation isolation to avoid replacing a newer failure with stale context. +- Unknown external causes may only be safely summarized as finite classes; no promise of raw original messages is made. + +## Questions + +- The user approved snapshot state reasons and scope/request-correlated sync diagnostic callbacks on 2026-10-24. No permission extends to business failure enums or Dune files. + +## Consequences + +- Existing authoritative checks and persistence decisions remain unchanged. Diagnostics do not prove or fix the original user Capture failure. +- Snapshot state errors now expose all six owner branches; the guarded checkpoint branch retains finite schema/checksum subreasons. Five publicly reachable states are tested with synthetic public Database calls; no corrupted private state is fabricated. +- Current errors reach the existing diagnostics/native feedback. Secondary cleanup and revocation failures retain safe support-log causes without reversing primary success. Canceled/retired work and diagnostic-delivery fallback remain intentionally quiet/nonrecursive. +- Existing suite and owner probes provide regression evidence, including actual decoder, same-operation cause changes, scoped asset request/fanout, C/Swift ingress and protected crypto boundaries. The unchanged full ImageIO photo fixture has the same executor-dependent failure at untouched HEAD; the owner suite and actual iOS typecheck pass. +- Final App packaging and isolated native rendering use the maintained build workflow and prepared dependencies. No normal Journal installation or real graph/account is used in this completion phase. Host restamping remains experimental with cross_abi_validated=false. +- Temporary probes, reports, hashes and screenshots remain in external task evidence. No Dune, extra business spec, PR, push or release changes are authorized. diff --git a/docs/agent-guide/implemented/bugfix/2026-10-24-safe-sync-error-reasons.md b/docs/agent-guide/implemented/bugfix/2026-10-24-safe-sync-error-reasons.md new file mode 100644 index 00000000..663f542c --- /dev/null +++ b/docs/agent-guide/implemented/bugfix/2026-10-24-safe-sync-error-reasons.md @@ -0,0 +1,52 @@ +# Safe Sync Error Reasons + +## Problem + +Authoritative integrity failures discard their producer reason in the effect runner, then the Worker ledger discards details again. Similar mappers flatten useful bounded reasons, while raw source strings may contain graph content or secrets. + +## Decision + +Preserve producer-owned reason codes and bounded numeric context through the existing Error details and Diagnostic signal path. Integrity diagnostics contain checkpoint, expected cursor, batch through, frozen interval, outbox operation/state and datom counts when available, never transaction values or custom property names. Keep existing error variants, blocking and durability behavior. Audit comparable catch/mapping sites and preserve known safe native account messages. Retain the existing uncommitted Local tx / Remote tx enhancement. + +## Alternatives considered + +### Forward raw error strings + +Rejected: some payload mismatch strings contain private property names, and external exceptions can contain paths, URLs or credentials. + +### Change public sync variants or validation + +Rejected: this is diagnostic work. Existing string payloads can carry validated versioned metadata without changing protected spec interfaces or synchronization decisions. + +## Acceptance criteria + +- A real synthetic own-echo mismatch propagates a specific safe reason and context into Last error and Worker errors without advancing the checkpoint or consuming the outbox. +- All known integrity sources have stable distinct reason codes. Invalid or unknown diagnostic input fails closed; 64-bit cursor values remain exact strings. +- Similar error mappings are catalogued with fixes or explicit safety/recovery rationale. Known safe native account errors retain their text; cancellation and retired owners remain quiet. +- Existing applicable tests pass. An isolated Simulator fixture renders a concrete integrity reason and Local tx / Remote tx without touching the authorized live graph. +- No spec/ or dune modifications; no PR, release, production sync algorithm or real graph fault injection. + +## Risks + +- Unknown raw exceptions cannot safely be exposed verbatim; only controlled categories are shown until a producer supplies structured context. +- Host-restamped Simulator acceptance remains experimental and does not prove full target ABI equivalence. + +## Questions + +- Scope and authorization are resolved by the explicit user request to expose error reasons and inspect swallowed messages. No additional product decision is needed. + +## Consequences + +The producer now emits twelve finite integrity reasons (plus unknown), preserving safe checkpoint/through/expected cursor, frozen interval, outbox operation/state, and datom counts when available. Exact int64 cursors remain decimal strings. The former attribute-name shape concatenation is removed. Worker Error details and the scoped diagnostic signal preserve these fields into Last error and Worker errors. Existing Error.code values, failure branches, commit checks and outbox retention behavior remain unchanged. + +Other repaired mappings include local commit, outbox, snapshot activation, open/listen/inspect, close, lower SQLite stage/source forwarding, known native account errors and current retry failures. HTTP/WebSocket TLS setup retains finite DNS/address/configuration/authentication categories; Upgrade retains its numeric status. Unknown external text is reduced to a safe class, not echoed. Existing cancellation/cleanup rules remain. Diagnostics are published only when Sync accepts the associated failure; existing pure tests caught both snapshot and inherited late-restore/authoritative scope misattribution before the common fence repair. + +Regression ownership is split by actual boundary: the pure worker reducer owns stale diagnostic publication; it cannot execute database integrity comparison, protected replay, runner mapping, service-carrier validation or native account presentation. A public temporary Database fixture creates and accepts a local mutation, then the actual runner applies a distinct authoritative echo. It verifies specific reason propagation, unchanged checkpoint and retained outbox. The existing service tests own strict carrier decoding/privacy/bounds. The existing storage test verifies safe snapshot-parser forwarding. Native submit/retry tests use their actual presentation owners. No protected spec or Dune file changed; the public lib-only SQLite diagnostic formatter and bounded diagnostic interfaces are ordinary implementation APIs. + +Final applicable checks pass: 318 sync cases (including nine new real transport cases), 50 worker runner cases, 20 worker pure reducer cases, 85 storage cases, 98 overlay sync cases, and the service suite. Native authentication/services/Cognito suites and build-helper checks pass (54 helper tests, one skipped). Standalone source-class/forwarding probes demonstrate rejection of sensitive sentinels, invalid descriptors, duplicate fields and noncanonical/overflowing cursors. + +An account-free, bundle-isolated Simulator fixture mounts the production Diagnostics page using values exported by the synthetic runner failure; Local tx 0 / Remote tx 1, payload-mismatch, datom counts, frozen interval and accepted outbox state are visually readable. It uses the production extension registry. The ordinary local candidate 1.0 (1.5877.6504) also starts on A135 with the authorized test graph, Current/Ready/Open, Local tx 930 / Remote tx 930, outbox 0 and no Last error row. No new graph blocks or real fault injection were performed. CoreSimulator reassigned its data-container path during update; existing test markers, checkpoint and outbox metadata remained present. These are host-restamped experimental builds with cross_abi_validated=false, not full iOS ABI proof or a TestFlight release. + +Two external source audit inventories and the final disposition report distinguish repaired mappings from safe generic/optional/cancellation behavior and enum/nil/native-boundary information already erased upstream. This is not a claim to expose arbitrary raw exception messages or to have diagnosed the user's original Capture failure. Future detailed asset/native-platform causes need producer-owned safe metadata; no protected-interface changes were made speculatively. + +All reports, temporary probes, generated hosts, cache contents and screenshots remain in the task's external evidence directory. The decision passes its own validation; the repository-wide decision check has the same five pre-existing unrelated format failures. No commit, push, PR, merge or release was performed. diff --git a/docs/agent-guide/implemented/feature/2026-10-23-2026-10-23-diagnostics-sync-cursors.md b/docs/agent-guide/implemented/feature/2026-10-23-2026-10-23-diagnostics-sync-cursors.md new file mode 100644 index 00000000..837e472d --- /dev/null +++ b/docs/agent-guide/implemented/feature/2026-10-23-2026-10-23-diagnostics-sync-cursors.md @@ -0,0 +1,39 @@ +# 2026 10 23 Diagnostics Sync Cursors + +## Problem + +Diagnostics does not show the local applied sync checkpoint beside the latest known remote server cursor, making authoritative validation failures difficult to assess. + +## Decision + +Add Local tx and Remote tx rows to the existing Diagnostics groups. Local tx is the committed applied_server_t; Remote tx is the maximum current-graph cursor observed from snapshot metadata, Hello/Changed, admitted Pull responses, valid acceptance barriers and valid rejection cursors. Keep its observation independent of pull work so failures and completed pulls retain useful diagnostics. Display unknown values explicitly and clear observations on graph/account scope changes. Preserve int64 formatting and all existing diagnostics. No synchronization policy, protected interface, Dune, database format or transport changes. + +## Alternatives considered + +### Reuse transient pull work + +Rejected because pull demand can disappear after catchup or failure, while the last observed server cursor is still useful. + +## Acceptance criteria + +- Public reducer tests demonstrate unknown values, exact int64 values, monotonic remote observation, commit-only local progress, diagnostics publication, error retention, and scope clearing with stale-message rejection. +- Existing diagnostic rows remain available and sync instructions retain their existing policy. + +## Risks + +- Remote tx is the latest observed server cursor, not a live query. Values may be unknown before the current graph receives server evidence. +- This change diagnoses an integrity failure; it does not fix or bypass the validation. + +## Questions + +- None. The user explicitly requested these two business sync cursors without a synchronization algorithm change. + +## Consequences + +The private reducer retains a typed, graph-scoped remote observation and projects both business cursors into the existing diagnostics groups. Diagnostic-only updates publish even when an existing pull is occupied; other synchronization instructions and their ownership remain unchanged. The LUI service forwards all groups and the application renders all nonempty groups, so no Swift or application view change is needed. + +Four new public reducer cases cover unknown values, exact int64 maxima, remote notifications and failed synchronization, graph/account fencing, commit-only local advancement, metadata before snapshot activation and stale rejection evidence. Existing canonical flow assertions now include the new rows and diagnostic publications, including acceptance evidence. The initial two cases and the additional metadata/rejection cases each failed for missing application behavior before implementation. + +All 309 sync tests pass, including synthetic loopback transport tests. The native app library and worker reducer/diagnostic service executables compile; the 20 worker reducer cases and diagnostic service suite pass. There is no Simulator, physical device, screenshot, live service or user graph validation. The first restricted loopback run could not bind ::1; the authorized rerun passed. An intermediate run from the external evidence directory could not locate source for an existing source-check test; the final run used the repository root with external logs and passed. Temporary build outputs and all evidence remain outside this repository. + +The full decision-document check retains five pre-existing unrelated format failures; this decision passes its individual check. No commit, push, PR, database modification or release was performed. diff --git a/docs/agent-guide/implemented/testing/2026-10-24-align-wrapped-key-diagnostic-regression.md b/docs/agent-guide/implemented/testing/2026-10-24-align-wrapped-key-diagnostic-regression.md new file mode 100644 index 00000000..987e2b69 --- /dev/null +++ b/docs/agent-guide/implemented/testing/2026-10-24-align-wrapped-key-diagnostic-regression.md @@ -0,0 +1,41 @@ +# Align Wrapped Key Diagnostic Regression + +## Problem + +PR #114 compiles in CI but the existing wrapped-key crypto harness still expects the old generic `crypto-failed` log result. The production bridge now reports the finite underlying `cryptoAuthenticationFailed` cause for the synthetic invalid AES tag. The regression must continue checking the actual C entrypoint, opt-in logging, stages, operation identity, duration and privacy rather than dropping the terminal-result assertion. + +## Decision + +Reproduce the existing failing harness, inspect its actual JSON response and terminal log, then align that harness with the precise finite reason. Assert the response is unsuccessful with the authentication reason and that its matching terminal record has the expected operation, request, stage and a nonnegative numeric duration. Retain log caps, secret-marker checks and missing wrapped-key classification. Change no production source, spec, Dune or dependencies if the real producer already satisfies those requirements. + +## Alternatives considered + +### Revert the producer to a generic error + +Rejected because it would discard the safe underlying reason the user explicitly requested. + +### Remove the assertion or rerun CI unchanged + +Rejected because neither verifies terminal logging and neither addresses the deterministic mismatch. + +## Acceptance criteria + +- The unchanged existing harness fails for the same terminal-result assertion as CI. +- The corrected existing harness verifies the exact response and terminal record, opt-in silence, stage progression, missing-cache distinction, secret exclusion and output cap. +- The existing owner harness passes locally, then the updated PR head passes full CI and is checked for review findings. +- Production source and the previously verified 6506 App remain byte-identical; no additional graph writes or rebuild are required for a test-only correction. + +## Risks + +- An overly broad terminal-string check could accidentally pass when a different request emits the reason; match the exact operation/request record and numeric duration. + +## Questions + +- No unanswered product or permission questions. The user authorized this diagnostic change and the parent explicitly requested fixing this PR's CI without skipping failures; this correction is limited to the existing synthetic test. + +## Consequences + +- The original harness reproduced CI's terminal-result assertion failure locally. An external probe of its actual C entrypoint showed `result=cryptoAuthenticationFailed stage=open-private-key elapsed_ms=2697`; the failure was a stale generic-result expectation, not absent terminal telemetry. +- The existing harness now decodes the actual response, requires the exact authentication reason and exactly one terminal record for operation 109, and checks the actual stage and a nonnegative integer duration. Existing opt-in silence, stage progression, secret exclusion, log cap and wrapped-key recovery checks remain. +- The complete existing wrapped-key harness passes locally after this test-only correction. CI on the updated PR head remains required; no test is skipped or added to a new CI route. +- No production source, native/Swift input, protected spec, Dune file or dependency changed. The 6506 normal App/Web acceptance remains valid for the identical product code, with its documented runtime coverage limits. diff --git a/logseq_db_storage/lib/asset_upload_store.ml b/logseq_db_storage/lib/asset_upload_store.ml index 76949a0b..bf660af3 100644 --- a/logseq_db_storage/lib/asset_upload_store.ml +++ b/logseq_db_storage/lib/asset_upload_store.ml @@ -71,8 +71,7 @@ let decode payload = Logseq_db_types.Json_codec.decode checkpoint_of_yojson (Yojson.Safe.from_string payload) - |> Result.map_error (fun error -> - "Unsupported or invalid upload checkpoint: " ^ error) + |> Result.map_error (fun _ -> "Upload checkpoint shape is invalid") in if value.format_version <> 1 then Error "Unsupported upload checkpoint format" @@ -81,6 +80,7 @@ let decode payload = Asset.version ~checksum:value.version.checksum ~file_type:value.version.file_type + |> Result.map_error (fun _ -> "Upload checkpoint asset version is invalid") in let* prepared = Intent.prepare @@ -97,10 +97,13 @@ let decode payload = ~target:value.target ~local_mutation:value.local_mutation ~metadata_mutation:value.metadata_mutation + |> Result.map_error (fun _ -> "Upload checkpoint intent is invalid") in Intent.restore prepared ~phase:value.phase ~revision:value.revision + |> Result.map_error (fun _ -> "Upload checkpoint phase is invalid") with - | Yojson.Json_error _ | Failure _ -> Error "Unsupported or invalid upload checkpoint" + | Yojson.Json_error _ -> Error "Upload checkpoint JSON is invalid" + | Failure _ | Invalid_argument _ -> Error "Upload checkpoint shape is invalid" ;; let protect f = @@ -287,22 +290,26 @@ let captured_graph_to_yojson capture = let captured_graph_of_yojson json = try let open Yojson.Safe.Util in - if json |> member "version" |> to_int <> 1 then failwith "Unsupported retirement capture"; + let* () = if json |> member "version" |> to_int <> 1 + then Error "Unsupported retirement capture format" else Ok () in let origin = json |> member "origin" |> to_string in let account = json |> member "account" |> to_string in - let* graph = json |> member "graph" |> Uuid.of_yojson in + let* graph = json |> member "graph" |> Uuid.of_yojson + |> Result.map_error (fun _ -> "Retirement capture graph identity is invalid") in let rec decode_rows acc = function | [] -> Ok acc | `List [id; `String payload] :: rest -> - let* id = Uuid.of_yojson id in let* intent = decode payload in + let* id = Uuid.of_yojson id + |> Result.map_error (fun _ -> "Retirement capture operation identity is invalid") in + let* intent = decode payload in if intent.origin <> origin || intent.account <> account || not (Uuid.equal graph intent.graph) || not (Uuid.equal id intent.operation_id) then Error "Retirement capture scope mismatch" else if Captured_rows.mem id acc then Error "Duplicate retirement checkpoint" else decode_rows (Captured_rows.add id payload acc) rest - | _ -> Error "Invalid retirement capture" in + | _ -> Error "Retirement capture row shape is invalid" in let* rows = decode_rows Captured_rows.empty (json |> member "rows" |> to_list) in Ok {captured_origin=origin;captured_account=account;captured_graph=graph;captured_rows=rows} - with _ -> Error "Invalid retirement capture" + with _ -> Error "Retirement capture shape is invalid" ;; let delete_captured_graph db capture = diff --git a/logseq_db_storage/lib/logseq_sqlite_storage.ml b/logseq_db_storage/lib/logseq_sqlite_storage.ml index 8794256e..028a8c3b 100644 --- a/logseq_db_storage/lib/logseq_sqlite_storage.ml +++ b/logseq_db_storage/lib/logseq_sqlite_storage.ml @@ -684,3 +684,18 @@ let restore_database connection = | Invalid_argument message -> corrupt message | exn -> corrupt (Printexc.to_string exn)) ;; + +(* Keep address/path/value text inside storage; expose only the typed stage and + the bounded source classification across the existing string boundary. *) +let diagnostic_error ~operation error = + let reason,source=match error with + | Pragma_mismatch s -> "pragmaMismatch",s + | Corrupt_storage s -> "storageCorrupt",s + | Begin_failed s -> "storageBeginFailed",s + | Write_failed{message;_} -> "storageWriteFailed",message + | Commit_failed s -> "storageCommitFailed",s + | Checkpoint_failed s -> "storageCheckpointFailed",s + | Close_failed s -> "storageCloseFailed",s in + Logseq_db_types.Failure_diagnostic.of_source ~operation ~reason source + |> Logseq_db_types.Failure_diagnostic.encode +;; diff --git a/logseq_db_storage/lib/logseq_sqlite_storage.mli b/logseq_db_storage/lib/logseq_sqlite_storage.mli index 31b34350..5d4123e9 100644 --- a/logseq_db_storage/lib/logseq_sqlite_storage.mli +++ b/logseq_db_storage/lib/logseq_sqlite_storage.mli @@ -84,3 +84,6 @@ val garbage_stats : callbacks -> (garbage_stats, error) result val collect_garbage : callbacks -> (unit, error) result val checkpoint : callbacks -> (unit, error) result val close : callbacks -> (unit, error) result + +(** A bounded stage/source diagnostic; omits storage addresses and raw text. *) +val diagnostic_error : operation:string -> error -> string diff --git a/logseq_db_storage/test/test_asset_upload_store.ml b/logseq_db_storage/test/test_asset_upload_store.ml index 4aecb252..2cbdf7c5 100644 --- a/logseq_db_storage/test/test_asset_upload_store.ml +++ b/logseq_db_storage/test/test_asset_upload_store.ml @@ -170,23 +170,29 @@ let generated_checkpoint_validation () = Sqlite3.Rc.check (Sqlite3.bind_text stmt 1 payload); Sqlite3.Rc.check (Sqlite3.step stmt)) in - let reject label payload = + let reject label expected payload = write payload; - Alcotest.(check bool) - label - true - (Result.is_error (S.read db ~operation:original.operation_id)) + match S.read db ~operation:original.operation_id with + | Error message -> Alcotest.(check string) label expected message + | Ok _ -> Alcotest.fail "invalid checkpoint was accepted" in + reject "invalid JSON excludes sensitive input" "Upload checkpoint JSON is invalid" + "SENSITIVE-fixture-body"; + reject "unsupported version" "Unsupported upload checkpoint format" + (replace "\"format_version\":1" "\"format_version\":2" !payload); Alcotest.(check bool) "generated phase variant" true (String.contains !payload '['); reject "old checkpoint phase rejected" + "Upload checkpoint shape is invalid" (replace "[\"Prepared\"]" "\"prepared\"" !payload); reject "invalid UUID rejected" + "Upload checkpoint shape is invalid" (replace (U.to_string original.graph) "not-a-uuid" !payload); - reject "negative asset size rejected" (replace "\"size\":4" "\"size\":-1" !payload); + reject "negative asset size rejected" "Upload checkpoint intent is invalid" (replace "\"size\":4" "\"size\":-1" !payload); reject "revision invariant rejected" + "Upload checkpoint phase is invalid" (replace "\"revision\":0" "\"revision\":-1" !payload); Alcotest.(check bool) "oversized encoded checkpoint rejected" true (Result.is_error (S.save db ~expected:None @@ -233,13 +239,25 @@ let exact_retirement_survives_restart_and_replacement () = (get (S.read db ~operation:fresh.operation_id) = Some fresh))) ;; +let retirement_capture_decode_causes () = + let check expected input = match S.captured_graph_of_yojson input with + | Error message -> Alcotest.(check string) "finite retirement cause" expected message + | Ok _ -> Alcotest.fail "invalid capture was accepted" in + check "Retirement capture shape is invalid" (`String "SENSITIVE-fixture-body"); + check "Unsupported retirement capture format" (`Assoc ["version", `Int 2]); + check "Retirement capture graph identity is invalid" + (`Assoc ["version", `Int 1; "origin", `String "synthetic"; + "account", `String "synthetic"; "graph", `String "SENSITIVE-invalid-uuid"]) +;; + let () = Alcotest.run "asset upload storage" [ ( "SQLite" , List.map (fun (n, f) -> Alcotest.test_case n `Quick f) - [ "retirement preserves changed operations", exact_retirement_preserves_new_and_changed_operations + [ "retirement capture decode causes", retirement_capture_decode_causes + ; "retirement preserves changed operations", exact_retirement_preserves_new_and_changed_operations ; "retirement restart and replacement isolation", exact_retirement_survives_restart_and_replacement ; "generated checkpoint validation", generated_checkpoint_validation ; "scoped cleanup", scoped_cleanup diff --git a/logseq_db_types/lib/authoritative_diagnostic.ml b/logseq_db_types/lib/authoritative_diagnostic.ml index 4b640403..678c7803 100644 --- a/logseq_db_types/lib/authoritative_diagnostic.ml +++ b/logseq_db_types/lib/authoritative_diagnostic.ml @@ -2,6 +2,7 @@ type stage = | Prepare | Decode | Application + | Integrity type reason = | Transit_syntax @@ -24,7 +25,7 @@ type reason = type t = (string * Yojson.Safe.t) list -let stages = [ "prepare"; "decode"; "application" ] +let stages = [ "prepare"; "decode"; "application"; "integrity" ] let reasons = [ "transit-syntax" @@ -47,6 +48,60 @@ let reasons = ] ;; +let integrity_reasons = + [ "payload-mismatch" + ; "expected-simulation-failed" + ; "expected-cursor-missing" + ; "missing-durable-origin" + ; "multiple-local-matches" + ; "origin-context-length" + ; "missing-frozen-interval" + ; "cursor-overflow" + ; "negative-interval" + ; "stale-delete-non-delete" + ; "stale-delete-context-length" + ; "submitted-delete-missing-t-before" + ; "unknown" + ] +;; + +let cursor_keys = [ "checkpoint"; "through"; "expectedCursor"; "tBefore" ] + +let integrity_counters = + [ "ordinal" + ; "count" + ; "expectedDatoms" + ; "actualDatoms" + ; "matchingSubmissions" + ; "cursorCount" + ; "transactionCount" + ; "rootCount" + ] +;; + +let integrity_descriptors = + [ "outboxState", [ "queued"; "submitted"; "accepted"; "stale-rejected"; "blocked" ] + ; ( "mutation" + , [ "save-block" + ; "insert-blocks" + ; "create-journal-page" + ; "delete-blocks" + ; "set-task-status" + ; "clear-task-status" + ; "publish-asset" + ; "set-asset-reference" + ] ) + ] +;; + +let valid_cursor text = + try + let n = Int64.of_string text in + n >= 0L && Int64.to_string n = text + with + | Failure _ -> false +;; + let operations = [ "add"; "retract"; "retract-attribute"; "retract-entity"; "cas"; "other" ] ;; @@ -100,7 +155,7 @@ let counter_keys = let string_lists = [ "stage", stages - ; "reason", reasons + ; "reason", List.sort_uniq String.compare (reasons @ integrity_reasons) ; "operation", operations ; "attribute", attributes ; "field", fields @@ -122,10 +177,26 @@ let key_order = ; "field" ; "type" ; "exception" + ; "checkpoint" + ; "through" + ; "expectedCursor" + ; "tBefore" + ; "ordinal" + ; "count" + ; "expectedDatoms" + ; "actualDatoms" + ; "matchingSubmissions" + ; "cursorCount" + ; "transactionCount" + ; "rootCount" + ; "outboxState" + ; "mutation" ] ;; let prefix = "authoritative-decode:v1:" +let integrity_prefix = "authoritative-integrity:v1:" +let integrity_signal_prefix = "worker.applyAuthoritative.authoritativeIntegrityFailure:" let detail_name = "authoritativeDiagnostic" let signal_prefix = "worker.applyAuthoritative.authoritativeDecodeFailed:" let maximum_counter = 1_000_000_000 @@ -134,6 +205,7 @@ let stage_name = function | Prepare -> "prepare" | Decode -> "decode" | Application -> "application" + | Integrity -> "integrity" ;; let reason_name = function @@ -249,9 +321,67 @@ let create |> canonical ;; -let encode value = prefix ^ Yojson.Safe.to_string (`Assoc value) +let integrity_context_value key value = + if List.mem key cursor_keys && valid_cursor value + then Some (`String value) + else if List.mem key integrity_counters + then ( + try + let n = int_of_string value in + if n >= 0 && n <= maximum_counter && string_of_int n = value + then Some (`Int n) + else None + with + | Failure _ -> None) + else ( + match List.assoc_opt key integrity_descriptors with + | Some values when List.mem value values -> Some (`String value) + | _ -> None) +;; + +let with_integrity_context context value = + if List.assoc_opt "stage" value <> Some (`String "integrity") + then value + else ( + let entries = + List.fold_left + (fun entries (key, value) -> + match integrity_context_value key value with + | None -> entries + | Some v -> (key, v) :: List.remove_assoc key entries) + value + context + |> canonical + in + (* Worker Error detail strings share its 512-byte display-safe budget. *) + let rec fit entries = + if + String.length integrity_prefix + + String.length (Yojson.Safe.to_string (`Assoc entries)) + <= 512 + then entries + else fit (List.rev (List.tl (List.rev entries))) + in + fit entries) +;; + +let integrity ~reason ?(context = []) () = + [ "stage", `String "integrity" + ; "reason", `String (fixed integrity_reasons "unknown" reason) + ] + |> with_integrity_context context +;; + +let encode value = + (if List.assoc_opt "stage" value = Some (`String "integrity") + then integrity_prefix + else prefix) + ^ Yojson.Safe.to_string (`Assoc value) +;; let decode source = + let is_integrity = String.starts_with ~prefix:integrity_prefix source in + let prefix = if is_integrity then integrity_prefix else prefix in if String.length source > 512 || not (String.starts_with ~prefix source) then None else ( @@ -276,14 +406,43 @@ let decode source = | None, `Int n when List.mem key counter_keys -> (n >= if key = "transaction" || key = "operationIndex" then 1 else 0) && n <= maximum_counter + | None, `String s when is_integrity && List.mem key cursor_keys -> + valid_cursor s + | None, `Int n when is_integrity && List.mem key integrity_counters -> + n >= 0 && n <= maximum_counter + | None, `String s when is_integrity -> + (match List.assoc_opt key integrity_descriptors with + | Some allowed -> List.mem s allowed + | None -> false) | _ -> false) entries && - if List.assoc_opt "stage" entries = Some (`String "application") + if is_integrity then - List.assoc_opt "reason" entries = Some (`String "application-exception") - && not (List.mem_assoc "operationIndex" entries) - else List.assoc_opt "reason" entries <> Some (`String "application-exception") + List.assoc_opt "stage" entries = Some (`String "integrity") + && (match List.assoc_opt "reason" entries with + | Some (`String r) -> List.mem r integrity_reasons + | _ -> false) + && List.for_all + (fun (key, _) -> + List.mem + key + ([ "stage"; "reason" ] + @ cursor_keys + @ integrity_counters + @ List.map fst integrity_descriptors)) + entries + else + List.assoc_opt "stage" entries <> Some (`String "integrity") + && (match List.assoc_opt "reason" entries with + | Some (`String r) -> List.mem r reasons + | _ -> false) + && + if List.assoc_opt "stage" entries = Some (`String "application") + then + List.assoc_opt "reason" entries = Some (`String "application-exception") + && not (List.mem_assoc "operationIndex" entries) + else List.assoc_opt "reason" entries <> Some (`String "application-exception") in if valid then Some (canonical entries) else None | _ -> None @@ -301,5 +460,36 @@ let message value = | `Int n -> string_of_int n | _ -> "unknown" in - "Authoritative transaction rejected (" ^ String.concat "; " (List.map part value) ^ ")." + (if List.assoc_opt "stage" value = Some (`String "integrity") + then "Authoritative integrity validation failed (" + else "Authoritative transaction rejected (") + ^ String.concat "; " (List.map part value) + ^ ")." +;; + +let integrity_of_source source = + match decode source with + | Some value when List.assoc_opt "stage" value = Some (`String "integrity") -> value + | _ -> + let reason = + match source with + | "accepted transaction has no durable origin evidence" -> "missing-durable-origin" + | "authoritative transaction ambiguously matches local submissions" -> + "multiple-local-matches" + | "authoritative transaction origins do not match their cursors" -> + "origin-context-length" + | "own transaction omitted its frozen submission interval" -> + "missing-frozen-interval" + | "server cursor interval overflows int64" -> "cursor-overflow" + | "negative server cursor interval" -> "negative-interval" + | "stale delete state contains a non-delete mutation" -> "stale-delete-non-delete" + | "authoritative transaction roots do not match their cursors" -> + "stale-delete-context-length" + | "submitted delete omitted its durable t_before" -> + "submitted-delete-missing-t-before" + | s when String.starts_with ~prefix:"accepted transaction payload mismatch:" s -> + "payload-mismatch" + | _ -> "unknown" + in + integrity ~reason () ;; diff --git a/logseq_db_types/lib/authoritative_diagnostic.mli b/logseq_db_types/lib/authoritative_diagnostic.mli index b55afd81..5e8da5ff 100644 --- a/logseq_db_types/lib/authoritative_diagnostic.mli +++ b/logseq_db_types/lib/authoritative_diagnostic.mli @@ -1,13 +1,15 @@ (** A bounded diagnostic containing no transaction values or exception text. String descriptors are reduced to finite allowlists by [create]. [Application] records use [Application_exception] and omit [operation_index]; - other stages do not use that reason. Invalid decoded combinations fail closed. *) + other stages do not use that reason. Integrity uses [integrity] and its + separate versioned carrier. Invalid decoded combinations fail closed. *) type t type stage = | Prepare | Decode | Application + | Integrity type reason = | Transit_syntax @@ -62,3 +64,11 @@ val decode : string -> t option val message : t -> string val detail_name : string val signal_prefix : string + +(** Integrity reason and context are reduced to finite descriptors and bounded + counters. Cursors are canonical nonnegative decimal int64 strings. *) +val integrity : reason:string -> ?context:(string * string) list -> unit -> t + +val integrity_of_source : string -> t +val with_integrity_context : (string * string) list -> t -> t +val integrity_signal_prefix : string diff --git a/logseq_db_types/lib/failure_diagnostic.ml b/logseq_db_types/lib/failure_diagnostic.ml new file mode 100644 index 00000000..7bf39408 --- /dev/null +++ b/logseq_db_types/lib/failure_diagnostic.ml @@ -0,0 +1,669 @@ +type t = + { operation_name : string + ; reason_name : string + ; source_name : string + ; cursor : string option + ; http_status : int option + } + +let operations = + [ "inspectMirror", "sync" + ; "attachGraph", "sync" + ; "inspectSync", "sync" + ; "applyOutbox", "sync" + ; "applyAuthoritative", "sync" + ; "commitLocal", "mutation" + ; "closeDatabase", "lifecycle" + ; "deleteMirror", "storage" + ; "activateSnapshot", "sync" + ; "resetAccount", "account" + ; "assetPreview", "asset" + ; "assetMetadata", "asset" + ; "assetRelease", "asset" + ; "restoreUploads", "upload" + ; "assetUpload", "upload" + ; "assetDownload", "asset" + ; "loadCatalog", "storage" + ; "saveCatalog", "storage" + ; "prepareCatalog", "storage" + ; "platformCrypto", "crypto" + ; "decryptValue", "crypto" + ; "decodeSnapshot", "sync" + ; "assetCache", "asset" + ; "projectGraph", "presentation" + ; "decodeListEvent", "native" + ; "decodeListRowEvent", "native" + ; "decodeExtensionEvent", "native" + ; "decodeAttachmentSelection", "native" + ; "decodeEnvironment", "native" + ; "workerOperation", "worker" + ] +;; + +let reasons = + [ "unknown", "Operation failed" + ; "databaseClosed", "Database is closed" + ; "tokenConflict", "Sync version changed" + ; "generationInvalidated", "Database generation changed" + ; "busy", "Storage is busy" + ; "persistenceFailed", "Persistence failed" + ; "fatalState", "Storage requires recovery" + ; "transitionInvalid", "Transition is invalid" + ; "integrityFailed", "Integrity validation failed" + ; "cryptoRequired", "Decrypted or protected values are required" + ; "cryptoUnexpected", "Unexpected cryptographic values" + ; "cryptoInvalid", "Cryptographic result is invalid" + ; "preparationConsumed", "Preparation was already consumed" + ; "capacityUnavailable", "Capacity is unavailable" + ; "invalidRequest", "Request is invalid" + ; "invalidMutation", "Mutation is invalid" + ; "missingPrecondition", "Required precondition is missing" + ; "preconditionConflict", "Precondition changed" + ; "identityConflict", "Mutation identity conflicts" + ; "limitExceeded", "Resource limit exceeded" + ; "unsupportedFootprint", "Mutation footprint is unsupported" + ; "mirrorAbsent", "Local mirror is absent" + ; "ownershipConflict", "Local mirror is owned by another session" + ; "attachmentStale", "Graph attachment is stale" + ; "restoreFailed", "Graph restoration failed" + ; "corruptStore", "Authoritative storage is invalid" + ; "corruptOutbox", "Outbox storage is invalid" + ; "corruptReceipt", "Mutation receipt storage is invalid" + ; "resourceFailed", "Resource could not be opened" + ; "invalidPath", "Storage path is invalid" + ; "invalidExpectedRows", "Expected row count is invalid" + ; "inputTooLarge", "Input exceeds its size limit" + ; "mirrorExists", "Local mirror already exists" + ; "staleInspection", "Mirror inspection is stale" + ; "stateError", "Operation state is invalid" + ; "parseFailed", "Input could not be parsed" + ; "cancelled", "Operation was cancelled" + ; "staleCommit", "Commit is stale" + ; "commitConsumed", "Commit was already consumed" + ; "inspectionFailed", "Mirror inspection failed" + ; "deleteFailed", "Local deletion failed" + ; "closeFailed", "Storage could not be closed" + ; "unavailable", "Resource is unavailable" + ; "networkFailed", "Network operation failed" + ; "authenticationFailed", "Authentication failed" + ; "checksumMismatch", "Checksum does not match" + ; "notFound", "Asset was not found" + ; "locked", "Graph key is locked" + ; "storageFull", "Storage is full" + ; "missingSource", "Attachment source is missing" + ; "sizeRejected", "Attachment exceeds its size limit" + ; "revokedAccess", "Attachment source access was revoked" + ; "invalidContent", "Content is invalid" + ; "cleanupFailed", "Cleanup could not finish" + ; "pragmaMismatch", "SQLite configuration does not match" + ; "storageCorrupt", "SQLite storage is invalid" + ; "storageBeginFailed", "SQLite transaction could not begin" + ; "storageWriteFailed", "SQLite storage write failed" + ; "storageCommitFailed", "SQLite transaction could not commit" + ; "storageCheckpointFailed", "SQLite checkpoint failed" + ; "storageCloseFailed", "SQLite storage could not close" + ] +;; + +(* These are exact producer-owned literals, never substrings of graph values. *) +let producer_sources = + [ "empty-submit-group", "empty submit group" + ; "duplicate-submit-member", "duplicate submit member" + ; "submit-member-missing", "submit member is missing" + ; "submit-member-not-queued", "submit member is not queued" + ; "cursor-overflow", "server cursor interval overflows int64" + ; "submit-members-reordered", "submit members are reordered" + ; "retry-batch-missing", "retry batch is missing" + ; "retry-batch-not-uncertain", "retry batch is not transport-uncertain" + ; "retry-protected-bytes-missing", "retry batch has no protected bytes" + ; "acceptance-after-stale", "acceptance contradicts settled Stale evidence" + ; "terminal-acceptance-mismatch", "terminal acceptance mismatch" + ; ( "acceptance-after-nonexecution" + , "acceptance contradicts proven conditional non-execution" ) + ; "accept-batch-missing", "accept batch is missing" + ; "acceptance-checksum-mismatch", "acceptance checksum mismatch" + ; "terminal-stale-mismatch", "terminal Stale rejection mismatch" + ; "batch-already-accepted", "batch already accepted" + ; "reject-batch-missing", "reject batch is missing" + ; "stale-barrier-not-advancing", "Stale barrier does not advance the submitted baseline" + ; "stale-after-acceptance", "Stale rejection contradicts acceptance" + ; "pending-stale-mismatch", "pending Stale rejection mismatch" + ; "crypto-preparation-inconsistent", "preparation crypto state is inconsistent" + ; ( "own-submission-interval-missing" + , "own transaction omitted its frozen submission interval" ) + ; "negative-cursor-interval", "negative server cursor interval" + ; ( "acceptance-before-own-interval" + , "acceptance barrier precedes its own submission interval" ) + ; ( "covered-acceptance-origin-missing" + , "covered acceptance has no durable origin evidence" ) + ; "asset-destination-changed", "Asset reference or destination changed" + ; "uploaded-version-changed", "Uploaded version no longer matches the live asset" + ; "legacy-status-replay-only", "Legacy status is replay-only" + ; ( "invalid-status-definition" + , "Status is missing, recycled or belongs to another property" ) + ; "attachment-reference-changed", "Attachment reference changed" + ; "snapshot-crypto-incomplete", "snapshot crypto input is incomplete" + ; "snapshot-database-commit", "snapshot database commit failed" + ; "snapshot-metadata-commit", "snapshot metadata commit failed" + ; "restore-open-failed", "unable to open mirror" + ; "restore-database-failed", "unable to restore authoritative database" + ; "lower-storage-close-failed", "lower storage close failed" + ; "legacy-status-identity-invalid", "invalid legacy status identity" + ; "asset-insertion-invalid", "Asset insertion requires one block and a bounded size" + ; "insert-tree-duplicate-uuids", "insert tree contains duplicate block UUIDs" + ; ( "snapshot-row-address-type" + , "snapshot row address must be a non-negative Transit integer" ) + ; "snapshot-row-addresses-type", "snapshot row addresses must be JSON text or nil" + ; "snapshot-row-shape", "snapshot row must be [addr, content, addresses]" + ; "snapshot-frame-shape", "snapshot frame payload must be a Transit row array" + ; "snapshot-frame-limit", "snapshot frame exceeds configured size limit" + ; "snapshot-stream-incomplete", "incomplete framed snapshot stream" + ; "snapshot-address-order", "snapshot row addresses must be strictly increasing" + ; "snapshot-extra-rows", "snapshot contains more rows than advertised" + ; "snapshot-negative-row-count", "snapshot expected row count must be non-negative" + ; "snapshot-row-count-mismatch", "snapshot row count does not match server metadata" + ; "snapshot-root-missing", "snapshot is missing DataScript root row 0" + ; "snapshot-tail-missing", "snapshot is missing DataScript tail row 1" + ; "snapshot-row-count-header", "snapshot row-count header is invalid" + ; "snapshot-gzip-empty", "snapshot gzip layer is empty" + ; "snapshot-gzip-invalid", "snapshot gzip layer is invalid" + ; "snapshot-decompression-bound", "snapshot decompressed bound must be positive" + ; "snapshot-gzip-layer-limit", "snapshot exceeds the supported gzip layer count" + ; "snapshot-gzip-staging-paths", "two private gzip staging paths are required" + ; "snapshot-sqlite-open", "snapshot SQLite storage cannot be opened" + ; "snapshot-sqlite-corrupt", "snapshot SQLite storage is corrupt" + ; "snapshot-import-failed", "The downloaded graph snapshot could not be imported." + ; "snapshot-remote-identity", "The downloaded graph has an unexpected remote identity." + ; ( "snapshot-sync-identity" + , "The downloaded graph has an invalid synchronization identity." ) + ; "snapshot-unsupported-value", "The downloaded graph contains unsupported values." + ; "snapshot-store-corrupt", "The downloaded graph storage is corrupt." + ; "snapshot-close-failed", "snapshot storage cannot be closed" + ; "snapshot-storage-closed", "snapshot storage is already closed" + ; "snapshot-plaintext-incomplete", "snapshot plaintext batch is incomplete" + ; "snapshot-plaintext-target-type", "snapshot plaintext target is not a string" + ; "snapshot-already-activated", "snapshot staging artifact is already activated" + ; "active-mirror-exists", "active mirror already exists" + ; "snapshot-checksum-mismatch", "snapshot checksum mismatch" + ; "storage-table-contract", "unexpected kvs table contract" + ; "storage-nested-staging", "nested storage staging is forbidden" + ; "storage-staging-missing", "storage staging has not begun" + ; "storage-root-missing", "storage root address 0 is missing" + ; "storage-root-payload", "storage root address 0 has the wrong payload" + ; "storage-tail-missing", "storage tail address 1 is missing" + ; "storage-tail-payload", "storage tail address 1 has the wrong payload" + ; "storage-root-index-metadata", "storage root index metadata is malformed" + ; "storage-root-metadata", "storage root metadata is malformed" + ; "storage-restore-empty", "DataScript restore returned no database" + ; "storage-eavt-root-type", "EAVT root address is not an index node" + ; "storage-eavt-root-missing", "EAVT root address is missing" + ; "storage-aevt-root-type", "AEVT root address is not an index node" + ; "storage-aevt-root-missing", "AEVT root address is missing" + ; "storage-avet-root-type", "AVET root address is not an index node" + ; "storage-avet-root-missing", "AVET root address is missing" + ; "crypto-item-missing", "missing cryptographic item" + ; "crypto-item-extra", "extra cryptographic item" + ; "crypto-item-duplicate", "duplicate cryptographic item" + ; "crypto-items-reordered", "reordered cryptographic items" + ; "crypto-result-stale", "stale cryptographic result" + ; "crypto-result-limit", "cryptographic result limit exceeded" + ; "decode-protected-value-failed", "decode protected value failed" + ; "decode-decrypted-value-failed", "decode decrypted value failed" + ; "decode-encrypted-private-key-failed", "decode encrypted private key failed" + ; "decode-encrypted-graph-key-failed", "decode encrypted graph key failed" + ; "e2ee-graph-key-response-shape-is-invalid", "E2EE graph key response shape is invalid" + ; "e2ee-user-keys-response-shape-is-invalid", "E2EE user keys response shape is invalid" + ; "asset-http-status", "Asset HTTP response status" + ; "invalid-plaintext-size-limit", "Invalid plaintext size limit" + ; "asset-exceeds-wire-size-limit", "Asset exceeds wire size limit" + ; "invalid-asset-aes-gcm-envelope", "Invalid asset AES-GCM envelope" + ; "asset-transit-payload-is-invalid", "Asset Transit payload is invalid" + ; "asset-checksum-mismatch", "Asset checksum mismatch" + ; "asset-namespace-is-not-a-directory", "asset namespace is not a directory" + ; "unsupported-asset-retirement", "unsupported asset retirement" + ; "retired-asset-identity-changed", "retired asset identity changed" + ; "canonical-asset-identity-changed", "canonical asset identity changed" + ; "asset-retirement-rename-failed", "asset retirement rename failed" + ; "asset-restore-conflict-retirement-retained", "asset restore conflict; retirement retained" + ; "asset-restore-is-incomplete-retirement-retained", "asset restore is incomplete; retirement retained" + ; "invalid-cache-size-limits", "Invalid cache size limits" + ; "asset-exceeds-cache-file-limit", "Asset exceeds cache file limit" + ; "invalid-staged-file", "Invalid staged file" + ; "invalid-staged-file-type", "Invalid staged file type" + ; "invalid-staging-budget", "Invalid staging budget" + ; "staged-import-already-exists", "Staged import already exists" + ; "import-source-must-be-a-regular-file", "Import source must be a regular file" + ; "import-exceeds-file-size-limit", "Import exceeds file size limit" + ; "import-source-changed-during-staging", "Import source changed during staging" + ; "asset-key-handle-missing", "Asset graph key handle is missing" + ; "asset-key-scope-mismatch", "Asset graph key scope does not match" + ; "asset-key-unavailable", "graph key handle is unavailable or out of scope" + ; "authentication-failed", "Authentication failed." + ; "authorization-failed", "Authorization failed." + ; "decrypted-transit-is-invalid", "decrypted Transit is invalid" + ; "catalog-directory-is-not-a-directory", "catalog directory is not a directory" + ; "asset-upload-source-is-not-a-regular-file", "Asset upload source is not a regular file" + ; "asset-upload-source-grew-during-reading", "Asset upload source grew during reading" + ; "asset-upload-source-ended-during-reading", "Asset upload source ended during reading" + ; "asset-upload-checksum-mismatch", "Asset upload checksum mismatch" + ; "asset-upload-base-url-is-invalid", "Asset upload base URL is invalid" + ; "asset-staging-storage-is-full", "Asset staging storage is full" + ; "asset-staging-scope-is-stale", "Asset staging scope is stale" + ; "asset-staging-checksum-mismatch", "Asset staging checksum mismatch" + ; "asset-exceeds-plaintext-size-limit", "Asset exceeds plaintext size limit" + ; "invalid-aes-gcm-output", "Invalid AES-GCM output" + ; "asset-cache-manifest-exceeds-size-limit", "Asset cache manifest exceeds size limit" + ; "asset-cache-manifest-shape-is-invalid", "Asset cache manifest shape is invalid" + ; "asset-cache-manifest-asset-version-is-invalid", "Asset cache manifest asset version is invalid" + ; "asset-cache-manifest-size-is-invalid", "Asset cache manifest size is invalid" + ; "asset-cache-manifest-filename-does-not-match-its-identity", "Asset cache manifest filename does not match its identity" + ; "asset-cache-manifest-json-is-invalid", "Asset cache manifest JSON is invalid" + ; "asset-cache-metadata-shape-is-invalid", "Asset cache metadata shape is invalid" + ; "upload-checkpoint-shape-is-invalid", "Upload checkpoint shape is invalid" + ; "upload-checkpoint-asset-version-is-invalid", "Upload checkpoint asset version is invalid" + ; "upload-checkpoint-intent-is-invalid", "Upload checkpoint intent is invalid" + ; "upload-checkpoint-phase-is-invalid", "Upload checkpoint phase is invalid" + ; "upload-checkpoint-json-is-invalid", "Upload checkpoint JSON is invalid" + ; "unsupported-upload-checkpoint-format", "Unsupported upload checkpoint format" + ; "upload-checkpoint-exceeds-size-limit", "Upload checkpoint exceeds size limit" + ; "retirement-capture-graph-identity-is-invalid", "Retirement capture graph identity is invalid" + ; "retirement-capture-operation-identity-is-invalid", "Retirement capture operation identity is invalid" + ; "retirement-capture-row-shape-is-invalid", "Retirement capture row shape is invalid" + ; "retirement-capture-shape-is-invalid", "Retirement capture shape is invalid" + ; "unsupported-retirement-capture-format", "Unsupported retirement capture format" + ; "retirement-capture-scope-mismatch", "Retirement capture scope mismatch" + ; "duplicate-retirement-checkpoint", "Duplicate retirement checkpoint" + ; "snapshot-gzip-input-could-not-be-opened", "snapshot gzip input could not be opened" + ; "snapshot-gzip-output-could-not-be-opened", "snapshot gzip output could not be opened" + ; "snapshot-gzip-decode-failed", "snapshot gzip decode failed" + ; "snapshot-gzip-output-write-failed", "snapshot gzip output write failed" + ; "snapshot-gzip-decompressed-limit-exceeded", "snapshot gzip decompressed limit exceeded" + ; "snapshot-artifact-exceeds-its-decompressed-bound", "snapshot artifact exceeds its decompressed bound" + ; "platform-crypto-response-json-is-invalid", "platform crypto response JSON is invalid" + ; "platform-crypto-response-shape-is-invalid", "platform crypto response shape is invalid" + ; "platform-crypto-response-error-is-missing", "platform crypto response error is missing" + ; "platform-crypto-provider-failed", "platform crypto provider failed" + ; "platform-crypto-returned-invalid-data", "platform crypto returned invalid data" + ; "platform-crypto-response-is-incomplete", "platform crypto response is incomplete" + ; "platform-crypto-unavailable", "platform crypto unavailable" + ; "crypto-invalid-request", "cryptoInvalidRequest" + ; "crypto-authentication-failed", "cryptoAuthenticationFailed" + ; "crypto-invalid-private-key", "cryptoInvalidPrivateKey" + ; "crypto-rsadecryption-failed", "cryptoRSADecryptionFailed" + ; "crypto-invalid-graph-key-length", "cryptoInvalidGraphKeyLength" + ; "crypto-key-store-locked", "cryptoKeyStoreLocked" + ; "crypto-key-store-permission-denied", "cryptoKeyStorePermissionDenied" + ; "crypto-key-store-unavailable", "cryptoKeyStoreUnavailable" + ; "crypto-key-store-decode-failed", "cryptoKeyStoreDecodeFailed" + ; "crypto-key-store-failed", "cryptoKeyStoreFailed" + ; "crypto-key-store-invalid-type", "cryptoKeyStoreInvalidType" + ; "crypto-operation-failed", "cryptoOperationFailed" + ; "wrapped-graph-key-unavailable", "wrappedGraphKeyUnavailable" + ; "crypto-invalid-aesinput", "cryptoInvalidAESInput" + ; "crypto-engine-private-key-unavailable", "cryptoEnginePrivateKeyUnavailable" + ; "crypto-allocation-failed", "cryptoAllocationFailed" + ; "crypto-native-unavailable", "cryptoNativeUnavailable" + ; "crypto-native-response-unavailable", "cryptoNativeResponseUnavailable" + ; "platform-binary-crypto-invalid-request", "platform binary crypto invalid request" + ; "platform-binary-crypto-authentication-failed", "platform binary crypto authentication failed" + ; "platform-binary-crypto-allocation-failed", "platform binary crypto allocation failed" + ; "platform-binary-crypto-operation-failed", "platform binary crypto operation failed" + ; "platform-binary-crypto-unavailable", "platform binary crypto unavailable" + ; "platform-binary-crypto-invalid-response", "platform binary crypto invalid response" + ; "snapshot-root-unavailable", "snapshot root directory is unavailable" + ; "snapshot-next-after-persist-or-commit", "snapshot next batch requested after persist or commit" + ; "snapshot-crypto-pending", "snapshot crypto batch is pending" + ; "snapshot-supply-after-persist-or-commit", "snapshot supply requested after persist or commit" + ; "snapshot-persist-awaiting-crypto", "snapshot persist requested while awaiting crypto" + ; "snapshot-checkpoint-invalid", "snapshot checkpoint is invalid" + ; "snapshot-schema-negative", "sync schema version must be non-negative" + ; "snapshot-checksum-invalid", "sync checksum must be 16 lowercase hexadecimal characters" + ; "retirement-directory-type", "Local retirement path is not a physical directory" + ; "retirement-write-stalled", "retirement journal write stalled" + ; "retirement-journal-unsupported", "Unsupported retirement journal" + ; "retirement-account-mismatch", "Retirement journal account mismatch" + ; "import-identity-used", "This import identity is already in use" + ; "retirement-response-unexpected", "Unexpected attachment retirement response" + ; "native-event-json", "Native event JSON syntax is invalid" + ; "native-event-shape", "Native event shape is invalid" + ; "native-event-utf8-field", "invalid UTF-8 field name" + ; "native-event-duplicate-field", "duplicate JSON field" + ; "native-event-utf8-value", "invalid UTF-8 string" + ; "native-event-number", "non-finite JSON number" + ; "native-event-nonstandard", "nonstandard JSON value" + ; "native-event-bound", "JSON exceeds byte budget" + ; "native-event-value", "invalid JSON value" + ; "native-attachment-identity", "Invalid import identity" + ; "native-attachment-selection", "Invalid attachment selection" + ; "native-attachment-batch", "Invalid attachment batch" + ; "native-attachment-source", "Invalid attachment source" + ; "native-attachment-action", "Invalid attachment action" + ; "native-environment-shape", "environment event: Native event shape is invalid" + ; "native-environment-brightness", "environment event: environment brightness is unsupported" + ; "native-environment-json", "environment event is not valid JSON" + ; "native-environment-utf8-field", "environment event: invalid UTF-8 field name" + ; "native-environment-duplicate-field", "environment event: duplicate JSON field" + ; "native-environment-utf8-value", "environment event: invalid UTF-8 string" + ; "native-environment-number", "environment event: non-finite JSON number" + ; "native-environment-nonstandard", "environment event: nonstandard JSON value" + ; "native-environment-bound", "environment event: JSON exceeds byte budget" + ; "native-environment-value", "environment event: invalid JSON value" + ; "native-envelope-truncated", "application platform envelope is truncated" + ; "native-envelope-exceeds-its-bound", "application platform envelope exceeds its bound" + ; "native-envelope-magic-invalid", "application platform envelope magic is invalid" + ; "native-envelope-version-unsupported", "application platform envelope version is unsupported" + ; "native-envelope-reserved-field-invalid", "application platform envelope reserved field is invalid" + ; "native-envelope-payload-length-invalid", "application platform envelope payload length is invalid" + ; "native-envelope-tag-unsupported", "application platform envelope tag is unsupported" + ; "native-envelope-generation-fields-are-invalid", "application platform envelope generation fields are invalid" + ; "projection-uuid-invalid", "invalid UUID" + ; "projection-capture-not-visible", "The captured block was not visible after commit." + ; "projection-update-not-visible", "The updated block was not visible after commit." + ; "projection-calendar-unavailable", "The host calendar is unavailable." + ; "projection-block-revision-missing", "The block revision is not retained." + ; "projection-page-revision-missing", "The page revision is not retained." + ; "projection-structure-revision-missing", "The structure revision is not retained." + ; "projection-status-unavailable", "This task status is unavailable. Choose another status before saving." + ; "projection-default-status-missing", "Choose a task status; this graph has no available default." + ; "projection-status-invalid", "Choose a valid task status before saving." + ; "projection-feed-day-limit", "The feed day limit must be positive." + ; "projection-feed-block-limit", "The feed block limit must be positive." + ; "projection-feed-slot-limit", "The feed slot limit must reserve one day and one block." + ; "projection-journal-page-missing", "The requested journal page is not retained." + ; "projection-capture-calendar-changed", "The local calendar changed before capture admission." + ; "projection-block-page-missing", "The block page is not retained." + ; "projection-child-calendar-changed", "The local calendar changed before child admission." + ; "projection-append-parent-missing", "The append parent is unavailable." + ; "projection-conflict-block-missing", "The conflicted block was not visible during reconciliation." + ; "projection-append-parent-mismatch", "The append completion does not match the selected parent." + ; "projection-page-unavailable", "This page is unavailable." + ; "projection-block-page-unavailable", "This block's page is unavailable." + ; "projection-target-page-unavailable", "The target journal page is unavailable." + ; "projection-favorite-ancestry-limit", "Favorite ancestry is cyclic or exceeds the read limit." + ; "projection-capture-block-missing", "The captured block is unavailable." + ; "projection-favorite-membership-limit", "Favorite memberships exceed the bounded read limit." + ; "projection-favorite-cursor-limit", "Favorite membership cursor exceeds the bounded read limit." + ; "projection-favorite-dependency-limit", "Favorite dependencies exceed the bounded read limit." + ; "projection-favorite-pending-limit", "Favorite pending reads exceed the bounded read limit." + ; "projection-changed-query-missing", "The changed page query is no longer retained." + ; "projection-block-id-invalid", "Journal entry ID must be a UUID" + ; "projection-page-id-invalid", "Journal page ID must be a UUID" + ; "projection-journal-day-invalid", "Journal day must be a valid YYYYMMDD date" + ; "projection-parent-id-invalid", "Journal parent ID must be a UUID" + ; "projection-parent-self-reference", "Journal entry cannot be its own parent" + ; "projection-sibling-order-invalid", "Journal sibling order is invalid" + ; "projection-child-count-invalid", "Journal child count must not be negative" + ; "projection-revision-empty", "Journal revision must not be empty" + ; "projection-mutation-id-invalid", "Journal mutation ID must be a UUID" + ; "projection-creation-day-invalid", "creation local day is invalid" + ; "projection-creation-minute-invalid", "creation local minute must be between 0 and 1439" + ; "projection-source-limit", "source exceeds 65,536 UTF-8 bytes" + ; "projection-source-utf8", "source is not valid UTF-8" + ; "projection-source-nul", "source contains NUL" + ; "projection-mutation-id", "mutation ID: invalid UUID" + ; "projection-page-uuid", "page UUID: invalid UUID" + ; "projection-block-uuid", "block UUID: invalid UUID" + ; "projection-status-uuid", "status UUID: invalid UUID" + ; "projection-child-uuid", "child UUID: invalid UUID" + ; "projection-parent-uuid", "parent UUID: invalid UUID" + ] +;; + +let source_classes = + producer_sources + @ [ "unknown", "No safe source detail is available" + ; "sqlite-locked", "SQLite database is locked" + ; "sqlite-full", "SQLite storage is full" + ; "sqlite-readonly", "SQLite database is read-only" + ; "sqlite-corrupt", "SQLite database is malformed" + ; "sqlite-open", "SQLite database could not be opened" + ; "sqlite-io", "SQLite I/O failed" + ; "sqlite-constraint", "SQLite constraint failed" + ; "sqlite-schema", "SQLite schema object is unavailable" + ; "sqlite-error", "SQLite operation failed" + ; "sqlite-no-memory", "SQLite allocation failed" + ; "sqlite-interrupted", "SQLite operation was interrupted" + ; "sqlite-aborted", "SQLite operation was aborted" + ; "sqlite-too-large", "SQLite value exceeds its size limit" + ; "sqlite-type-mismatch", "SQLite value type does not match" + ; "io-no-space", "Storage has no free space" + ; "io-permission", "Storage permission was denied" + ; "io-missing", "Storage object was not found" + ; "exception-failure", "An operation raised Failure" + ; "exception-invalid-argument", "An operation raised Invalid_argument" + ; "exception-sys-error", "An operating system operation failed" + ; "exception-not-found", "An expected object was not found" + ; "exception-eof", "Input ended unexpectedly" + ; "exception-timeout", "Operation timed out" + ; "external-exception", "An external operation raised an exception" + ] +;; + +let sqlite_literals = + [ "database is locked", "sqlite-locked" + ; "database table is locked", "sqlite-locked" + ; "database or disk is full", "sqlite-full" + ; "attempt to write a readonly database", "sqlite-readonly" + ; "database disk image is malformed", "sqlite-corrupt" + ; "file is not a database", "sqlite-corrupt" + ; "unable to open database file", "sqlite-open" + ; "disk I/O error", "sqlite-io" + ; "constraint failed", "sqlite-constraint" + ; "SQLITE_BUSY", "sqlite-locked" + ; "SQLITE_LOCKED", "sqlite-locked" + ; "SQLITE_FULL", "sqlite-full" + ; "SQLITE_READONLY", "sqlite-readonly" + ; "SQLITE_CORRUPT", "sqlite-corrupt" + ; "SQLITE_NOTADB", "sqlite-corrupt" + ; "SQLITE_CANTOPEN", "sqlite-open" + ; "SQLITE_IOERR", "sqlite-io" + ; "SQLITE_CONSTRAINT", "sqlite-constraint" + ; "BUSY", "sqlite-locked" + ; "LOCKED", "sqlite-locked" + ; "FULL", "sqlite-full" + ; "READONLY", "sqlite-readonly" + ; "CORRUPT", "sqlite-corrupt" + ; "NOTADB", "sqlite-corrupt" + ; "CANTOPEN", "sqlite-open" + ; "IOERR", "sqlite-io" + ; "CONSTRAINT", "sqlite-constraint" + ; "ERROR", "sqlite-error" + ; "NOMEM", "sqlite-no-memory" + ; "SQLITE_NOMEM", "sqlite-no-memory" + ; "INTERRUPT", "sqlite-interrupted" + ; "SQLITE_INTERRUPT", "sqlite-interrupted" + ; "ABORT", "sqlite-aborted" + ; "SQLITE_ABORT", "sqlite-aborted" + ; "TOOBIG", "sqlite-too-large" + ; "SQLITE_TOOBIG", "sqlite-too-large" + ; "MISMATCH", "sqlite-type-mismatch" + ; "SQLITE_MISMATCH", "sqlite-type-mismatch" + ] +;; + +let source_prefixes = + [ "local time conversion failed: Failure(", "exception-failure" + ; "local time conversion failed: Invalid_argument(", "exception-invalid-argument" + ; "local time conversion failed: Sys_error(", "exception-sys-error" + ; "local time conversion failed:", "external-exception" + ; "UNIQUE constraint failed:", "sqlite-constraint" + ; "NOT NULL constraint failed:", "sqlite-constraint" + ; "CHECK constraint failed:", "sqlite-constraint" + ; "no such table:", "sqlite-schema" + ; "no such column:", "sqlite-schema" + ; "Unix.Unix_error(Unix.ENOSPC,", "io-no-space" + ; "Unix.Unix_error(Unix.EACCES,", "io-permission" + ; "Unix.Unix_error(Unix.EPERM,", "io-permission" + ; "Unix.Unix_error(Unix.ENOENT,", "io-missing" + ; "Sqlite3.", "sqlite-error" + ; "Failure(", "exception-failure" + ; "Invalid_argument(", "exception-invalid-argument" + ; "Sys_error(", "exception-sys-error" + ] +;; + +let valid_http_status value = value >= 100 && value <= 599 + +let http_status_of_source source = + let prefix = "Asset HTTP status " in + if String.length source = String.length prefix + 3 && String.starts_with ~prefix source + then + let text = String.sub source (String.length prefix) 3 in + match int_of_string_opt text with + | Some value when valid_http_status value && string_of_int value = text -> Some value + | _ -> None + else None +;; + +let classify_source source = + if String.length source > 4096 + then "unknown" + else if Option.is_some (http_status_of_source source) + then "asset-http-status" + else ( + match List.find_opt (fun (_, literal) -> source = literal) producer_sources with + | Some (name, _) -> name + | None -> + let sqlite = + List.find_opt + (fun (literal, _) -> + source = literal + || List.exists + (fun constructor -> source = constructor ^ "(\"" ^ literal ^ "\")") + [ "Sqlite3.SqliteError"; "Sqlite3.Error"; "Failure" ]) + sqlite_literals + in + (match sqlite with + | Some (_, name) -> name + | None -> + (match + List.find_opt + (fun (prefix, _) -> String.starts_with ~prefix source) + source_prefixes + with + | Some (_, name) -> name + | None -> + (match source with + | "Not_found" -> "exception-not-found" + | "End_of_file" -> "exception-eof" + | "Eio.Time.Timeout" -> "exception-timeout" + | _ when String.contains source '(' -> "external-exception" + | _ -> "unknown")))) +;; + +let fixed values fallback value = if List.mem_assoc value values then value else fallback + +let create ~operation ~reason ?source ?cursor ?http_status () = + { operation_name = fixed operations "workerOperation" operation + ; reason_name = fixed reasons "unknown" reason + ; source_name = Option.fold ~none:"unknown" ~some:classify_source source + ; cursor = + Option.bind cursor (fun value -> + if value < 0L then None else Some (Int64.to_string value)) + ; http_status = + (match http_status with + | Some status when valid_http_status status -> Some status + | Some _ -> None + | None -> Option.bind source http_status_of_source) + } +;; + +let operation value = value.operation_name +let phase value = List.assoc value.operation_name operations +let reason value = value.reason_name +let source_class value = value.source_name +let code value = value.operation_name ^ "." ^ value.reason_name + +let message value = + let summary = value.operation_name ^ ": " ^ List.assoc value.reason_name reasons in + let cause = + if value.source_name = "unknown" + then "" + else ". Cause: " ^ List.assoc value.source_name source_classes + in + let http = Option.fold ~none:"" ~some:(fun status -> Printf.sprintf " (http=%d)" status) value.http_status in + let text = summary ^ cause ^ http ^ "." in + if String.length text <= 512 then text else summary ^ "." +;; + +let prefix = "journal-failure:v1:" + +let encode value = + let fields = + [ "operation", `String value.operation_name + ; "reason", `String value.reason_name + ; "source", `String value.source_name + ] + @ Option.fold ~none:[] ~some:(fun cursor -> [ "cursor", `String cursor ]) value.cursor + @ Option.fold ~none:[] ~some:(fun status -> [ "httpStatus", `Int status ]) value.http_status + in + prefix ^ Yojson.Safe.to_string (`Assoc fields) +;; + +let decode source = + if String.length source > 1024 || not (String.starts_with ~prefix source) + then None + else ( + try + let encoded = + String.sub + source + (String.length prefix) + (String.length source - String.length prefix) + in + match Yojson.Safe.from_string encoded with + | `Assoc fields -> + let keys = List.map fst fields in + let valid_keys = List.sort String.compare keys in + if + valid_keys <> [ "operation"; "reason"; "source" ] + && valid_keys <> [ "cursor"; "operation"; "reason"; "source" ] + && valid_keys <> [ "httpStatus"; "operation"; "reason"; "source" ] + && valid_keys <> [ "cursor"; "httpStatus"; "operation"; "reason"; "source" ] + then None + else ( + match + ( List.assoc_opt "operation" fields + , List.assoc_opt "reason" fields + , List.assoc_opt "source" fields ) + with + | ( Some (`String operation_name) + , Some (`String reason_name) + , Some (`String source_name) ) + when List.mem_assoc operation_name operations + && List.mem_assoc reason_name reasons + && List.mem_assoc source_name source_classes -> + let cursor = + match List.assoc_opt "cursor" fields with + | None -> Some None + | Some (`String text) -> + (match Int64.of_string_opt text with + | Some value when value >= 0L && Int64.to_string value = text -> + Some (Some text) + | _ -> None) + | _ -> None + in + let http_status = + match List.assoc_opt "httpStatus" fields with + | None -> Some None + | Some (`Int value) when valid_http_status value -> Some (Some value) + | _ -> None in + (match cursor, http_status with + | Some cursor, Some http_status -> + Some { operation_name; reason_name; source_name; cursor; http_status } + | _ -> None) + | _ -> None) + | _ -> None + with + | Yojson.Json_error _ -> None) +;; + +let detail_name = "failureDiagnostic" +let signal_prefix = "worker.failure:" + +let of_source ~operation ~reason source = + match decode source with + | Some value -> + { value with operation_name = fixed operations "workerOperation" operation } + | None -> create ~operation ~reason ~source () +;; diff --git a/logseq_db_types/lib/failure_diagnostic.mli b/logseq_db_types/lib/failure_diagnostic.mli new file mode 100644 index 00000000..7f110f10 --- /dev/null +++ b/logseq_db_types/lib/failure_diagnostic.mli @@ -0,0 +1,39 @@ +(** Bounded diagnostic context with finite operation, reason and source classes. + Arbitrary source text, exception arguments and paths are never retained. + Unknown constructor descriptors normalize to fixed safe fallbacks. Messages + use at most 512 bytes; source classification examines at most 4096 bytes. *) +type t + +val create + : operation:string + -> reason:string + -> ?source:string + -> ?cursor:int64 + -> ?http_status:int + -> unit + -> t + +(** A valid carrier retains its safe lower reason, source class, cursor and + bounded HTTP status; only the operation is replaced with the caller's + normalized operation. + Other input is classified without retaining arbitrary text. Only canonical + producer-owned HTTP status messages retain their numeric status (100–599). *) +val of_source : operation:string -> reason:string -> string -> t + +val operation : t -> string +val phase : t -> string +val code : t -> string +val reason : t -> string +val source_class : t -> string +val message : t -> string + +(** Versioned carriers use at most 1024 bytes and reject unknown/duplicate fields + and non-allowlisted values. + A non-negative cursor is encoded as a canonical decimal string, never a JSON + number. HTTP status is an optional integer in the range 100–599. + Oversized or malformed carriers fail closed. *) +val encode : t -> string + +val decode : string -> t option +val detail_name : string +val signal_prefix : string diff --git a/logseq_db_worker/lib/effect_runner/effect_runner.ml b/logseq_db_worker/lib/effect_runner/effect_runner.ml index b2406627..8be5702d 100644 --- a/logseq_db_worker/lib/effect_runner/effect_runner.ml +++ b/logseq_db_worker/lib/effect_runner/effect_runner.ml @@ -335,6 +335,16 @@ type asset_waiter = ; mutable delivered : Sync.asset_output option ; mutable accepted : bool ; mutable reclaimed : bool + ; mutable diagnostic : Logseq_db_types.Failure_diagnostic.t option + } + +type notice_diagnostic_key = Upload_notice of Graph.Uuid.t | Download_notice of Graph.Uuid.t + +type notice_diagnostic = + { scope : Sync.graph_scope + ; key : notice_diagnostic_key + ; detail : Logseq_db_types.Failure_diagnostic.t + ; ticket_id : int } type protected_waiter = @@ -367,6 +377,7 @@ type t = ; upload_operations : (Upload.ticket, unit Eio.Promise.u) Hashtbl.t ; protected_waiters : (string, protected_waiter) Hashtbl.t ; asset_waiters : (string, asset_waiter) Hashtbl.t + ; mutable notice_diagnostics : notice_diagnostic list ; asset_operations : (Logseq_sync_pure_reducer.Asset_transfer.ticket, string) Hashtbl.t ; mutable next_asset_operation : int64 ; upload_staging_lock : Eio.Mutex.t @@ -405,6 +416,7 @@ let create ~sw dependencies ~post ~recovery_current ~upload_current ~asset_curre ; upload_operations = Hashtbl.create 32 ; protected_waiters = Hashtbl.create 32 ; asset_waiters = Hashtbl.create 32 + ; notice_diagnostics = [] ; asset_operations = Hashtbl.create 8 ; next_asset_operation = 0L ; upload_staging_lock = Eio.Mutex.create () @@ -478,11 +490,26 @@ let reclaim_waiter t waiter = Option.iter (reclaim_asset_output t) waiter.delivered) ;; -let asset_call ?(started = fun _ -> ()) t ~scope action = +let current_graph_scope t (scope : Sync.graph_scope) = + match t.latest_snapshot with + | Some snapshot -> not t.stopped + && snapshot.startup.account_generation = scope.account.account_generation + && snapshot.startup.graph_generation = scope.graph_generation + && snapshot.selected_graph = Some scope.graph_id + | None -> false +;; + +let publish_scoped_asset_detail t scope detail = + if current_graph_scope t scope then ( + let module D = Logseq_db_types.Failure_diagnostic in + try t.dependencies.publish (Core.Diagnostic (D.signal_prefix ^ D.encode detail)) with _ -> ()) +;; + +let asset_call ?(started = fun _ -> ()) ?on_diagnostic t ~scope action = let request = asset_request t ~scope action in let promise, resolve = Eio.Promise.create () in let waiter = - { request; resolve; delivered = None; accepted = false; reclaimed = false } + { request; resolve; delivered = None; accepted = false; reclaimed = false; diagnostic = None } in Hashtbl.add t.asset_waiters request.operation waiter; Fun.protect @@ -497,6 +524,11 @@ let asset_call ?(started = fun _ -> ()) t ~scope action = post_asset_request t request; let output = Eio.Promise.await promise in waiter.accepted <- true; + (match output.Sync.result, waiter.diagnostic with + | Error Sync.Asset_cancelled, _ | Ok _, _ | _, None -> () + | Error _, Some detail -> + (try (Option.value on_diagnostic ~default:(publish_scoped_asset_detail t scope)) detail with _ -> ())); + waiter.diagnostic <- None; output.Sync.result) ;; @@ -540,7 +572,11 @@ let decrypt_protected_value t key source = ;; let asset_failure_message = function - | Sync.Asset_invalid_content message -> message + | Sync.Asset_invalid_content source -> + let module D = Logseq_db_types.Failure_diagnostic in + Option.value (D.decode source) + ~default:(D.of_source ~operation:"assetPreview" ~reason:"invalidContent" source) + |> D.message | Asset_network -> "Asset network request failed" | Asset_not_found | Asset_missing_source -> "Asset source is unavailable" | Asset_checksum_mismatch -> "Asset checksum mismatch" @@ -552,11 +588,18 @@ let asset_failure_message = function | Asset_cancelled -> "Asset operation was cancelled" ;; -let asset_unit t ~scope action = - match asset_call t ~scope action with +let asset_unit ?(on_failure = fun _ -> ()) ?on_diagnostic t ~scope action = + let detail = ref None in + match asset_call ~on_diagnostic:(fun value -> detail := Some value) t ~scope action with | Ok Sync.Asset_unit -> Ok () - | Error failure -> Error (asset_failure_message failure) - | Ok _ -> Error "Unexpected asset completion" + | Error failure -> + (try match !detail with + | Some detail -> (Option.value on_diagnostic ~default:(publish_scoped_asset_detail t scope)) detail + | None -> on_failure failure with _ -> ()); + Error (asset_failure_message failure) + | Ok _ -> + (try on_failure (Sync.Asset_invalid_content "Unexpected asset completion") with _ -> ()); + Error "Unexpected asset completion" ;; let with_staging_lock t action = @@ -641,8 +684,177 @@ let worker_error ~code message = Logseq_db_worker_contract.Error.create ~code ~message ~details:[] |> Result.get_ok ;; +let diagnostic_error + ?(code = Logseq_db_worker_contract.Error.Storage_busy) + ~operation + ~reason + ?source + () + = + let module E = Logseq_db_worker_contract.Error in + let module D = Logseq_db_types.Failure_diagnostic in + let diagnostic = + match source with + | None -> D.create ~operation ~reason () + | Some source -> D.of_source ~operation ~reason source + in + let message = D.message diagnostic in + let origin = + E.create_cause + ~component:E.Managed_sync + ~operation:(D.operation diagnostic) + ~code:(Some (D.reason diagnostic)) + ~message + |> Result.get_ok + in + E.create_with_origin + ~code + ~message + ~details:[ E.{ name = D.detail_name; value = Detail_string (D.encode diagnostic) } ] + ~origin + |> Result.get_ok +;; + +let crypto_result_reason = function + | Overlay.Crypto_result_missing_item _ -> "missing cryptographic item" + | Crypto_result_extra_item _ -> "extra cryptographic item" + | Crypto_result_duplicate_item _ -> "duplicate cryptographic item" + | Crypto_result_reordered -> "reordered cryptographic items" + | Crypto_result_stale -> "stale cryptographic result" + | Crypto_result_limit_exceeded -> "cryptographic result limit exceeded" +;; + +let outbox_transition_error error = + let reason, source = + match error with + | Overlay.Outbox_database_closed | Outbox_commit_database_closed -> + "databaseClosed", None + | Outbox_sync_token_conflict | Outbox_commit_token_conflict -> "tokenConflict", None + | Outbox_transition_invalid s -> "transitionInvalid", Some s + | Outbox_dependency_ineligible _ -> "missingPrecondition", None + | Outbox_delete_requires_singleton -> "unsupportedFootprint", None + | Outbox_limit_exceeded -> "limitExceeded", None + | Outbox_crypto_required -> "cryptoRequired", None + | Outbox_crypto_unexpected -> "cryptoUnexpected", None + | Outbox_crypto_error e -> "cryptoInvalid", Some (crypto_result_reason e) + | Outbox_preparation_consumed -> "preparationConsumed", None + | Outbox_commit_generation_invalidated -> "generationInvalidated", None + | Outbox_commit_busy -> "busy", None + | Outbox_commit_persistence_failed s -> "persistenceFailed", Some s + | Outbox_commit_integrity_failure s -> "integrityFailed", Some s + in + diagnostic_error ~operation:"applyOutbox" ~reason ?source () +;; + +let mirror_error error = + let reason, source = + match error with + | Overlay.Invalid_application_support_directory -> "invalidPath", None + | Mirror_location_unavailable s -> "resourceFailed", Some s + | Mirror_inspection_failed s -> "inspectionFailed", Some s + in + diagnostic_error ~operation:"inspectMirror" ~reason ?source () +;; + +let snapshot_activation_error error = + let reason, source = + match error with + | Overlay.Invalid_snapshot_path -> "invalidPath", None + | Invalid_expected_rows -> "invalidExpectedRows", None + | Snapshot_input_too_large -> "inputTooLarge", None + | Mirror_exists -> "mirrorExists", None + | Stale_snapshot_inspection -> "staleInspection", None + | Snapshot_state_error state -> + let source = match state with + | Overlay.Snapshot_root_unavailable -> "snapshot root directory is unavailable" + | Snapshot_next_after_persist_or_commit -> "snapshot next batch requested after persist or commit" + | Snapshot_crypto_batch_pending -> "snapshot crypto batch is pending" + | Snapshot_supply_after_persist_or_commit -> "snapshot supply requested after persist or commit" + | Snapshot_persist_awaiting_crypto -> "snapshot persist requested while awaiting crypto" + | Snapshot_checkpoint_invalid `Schema_negative -> "sync schema version must be non-negative" + | Snapshot_checkpoint_invalid `Checksum_invalid -> "sync checksum must be 16 lowercase hexadecimal characters" + | Snapshot_checkpoint_invalid `Unknown -> "snapshot checkpoint is invalid" in + "stateError", Some source + | Snapshot_crypto_result_error e -> "cryptoInvalid", Some (crypto_result_reason e) + | Snapshot_crypto_error s -> "cryptoInvalid", Some s + | Snapshot_parse_error s -> "parseFailed", Some s + | Snapshot_limit_exceeded -> "limitExceeded", None + | Snapshot_preparation_canceled -> "cancelled", None + | Snapshot_commit_stale -> "staleCommit", None + | Snapshot_commit_consumed -> "commitConsumed", None + | Snapshot_commit_persistence_failed s -> "persistenceFailed", Some s + in + diagnostic_error ~operation:"activateSnapshot" ~reason ?source () +;; + +let open_error error = + let reason, source = + match error with + | Overlay.Mirror_absent -> "mirrorAbsent", None + | Invalid_graph_name -> "invalidRequest", None + | Ownership_conflict -> "ownershipConflict", None + | Attachment_stale -> "attachmentStale", None + | Restore_failed s -> "restoreFailed", Some s + | Corrupt_authoritative_store s -> "corruptStore", Some s + | Corrupt_outbox s -> "corruptOutbox", Some s + | Corrupt_mutation_receipt s -> "corruptReceipt", Some s + | Open_resource_failed s -> "resourceFailed", Some s + in + diagnostic_error ~operation:"attachGraph" ~reason ?source () +;; + +let listen_error error = + let reason, source = + match error with + | Overlay.Listen_database_closed -> "databaseClosed", None + | Subscription_inactive | Subscription_already_active -> "stateError", None + | Listen_capacity_unavailable -> "capacityUnavailable", None + | Listen_fatal_state s -> "fatalState", Some s + in + diagnostic_error ~operation:"attachGraph" ~reason ?source () +;; + +let sync_read_error = function + | Overlay.Sync_database_closed -> + diagnostic_error ~operation:"inspectSync" ~reason:"databaseClosed" () + | Sync_fatal_state source -> + diagnostic_error ~operation:"inspectSync" ~reason:"fatalState" ~source () +;; + +let close_error = function + | Overlay.Close_failed source -> + diagnostic_error ~operation:"closeDatabase" ~reason:"closeFailed" ~source () + | Fatal_close source -> + diagnostic_error ~operation:"closeDatabase" ~reason:"fatalState" ~source () +;; + +let local_commit_error error = + let code, reason, source = + match error with + | Overlay.Local_commit_busy -> + Logseq_db_worker_contract.Error.Storage_busy, "busy", None + | Local_commit_persistence_failed s -> Storage_busy, "persistenceFailed", Some s + | Local_commit_fatal_state s -> Storage_busy, "fatalState", Some s + | Local_database_closed -> Conflict, "databaseClosed", None + | Invalid_local_mutation s -> Conflict, "invalidMutation", Some s + | Mutation_identity_conflict -> Conflict, "identityConflict", None + | Missing_precondition _ -> Conflict, "missingPrecondition", None + | Target_precondition_conflict -> Conflict, "preconditionConflict", None + | Planner_limit_exceeded -> Conflict, "limitExceeded", None + | Delete_unsupported_for_footprint -> Conflict, "unsupportedFootprint", None + in + diagnostic_error ~code ~operation:"commitLocal" ~reason ?source () +;; + let effect_error message = - worker_error ~code:Logseq_db_worker_contract.Error.Storage_busy message + let module D = Logseq_db_types.Failure_diagnostic in + let module E = Logseq_db_worker_contract.Error in + match D.decode message with + | None -> worker_error ~code:E.Storage_busy message + | Some detail -> + E.create ~code:E.Storage_busy ~message:(D.message detail) + ~details:[ E.{name=D.detail_name;value=Detail_string (D.encode detail)} ] + |> Result.get_ok ;; let failure request code message = @@ -741,12 +953,94 @@ let publish_projection_change t session = function ;; let report_current_graph t (scope : Sync.graph_scope) code = - match t.latest_snapshot with - | Some snapshot when not t.stopped - && snapshot.startup.account_generation = scope.account.account_generation - && snapshot.startup.graph_generation = scope.graph_generation -> + if current_graph_scope t scope then t.dependencies.publish (Core.Diagnostic code) - | _ -> () +;; + +let failure_detail ?source ~operation ~reason () = + let module D = Logseq_db_types.Failure_diagnostic in + match source with + | None -> D.create ~operation ~reason () + | Some source -> D.of_source ~operation ~reason source +;; + +let publish_failure report detail = + let module D = Logseq_db_types.Failure_diagnostic in + try report (D.signal_prefix ^ D.encode detail) with _ -> () +;; + +let exception_failure ~operation ~reason error = + let module D = Logseq_db_types.Failure_diagnostic in + let literal = match error with Failure source | Invalid_argument source -> Some source | _ -> None in + let detail = failure_detail ?source:literal ~operation ~reason () in + if D.source_class detail <> "unknown" then detail + else failure_detail ~source:(Printexc.to_string error) ~operation ~reason () +;; + +let asset_failure_detail ~operation failure = + let reason, source = match failure with + | Sync.Asset_network -> "networkFailed", None + | Asset_not_found -> "notFound", None + | Asset_missing_source -> "missingSource", None + | Asset_checksum_mismatch -> "checksumMismatch", None + | Asset_authentication -> "authenticationFailed", None + | Asset_locked -> "locked", None + | Asset_storage_full -> "storageFull", None + | Asset_size_rejected -> "sizeRejected", None + | Asset_revoked_access -> "revokedAccess", None + | Asset_cancelled -> "cancelled", None + | Asset_invalid_content source -> "invalidContent", Some source in + failure_detail ?source ~operation ~reason () +;; + +let report_asset_failure ?detail t scope operation = function + | Sync.Asset_cancelled -> () + | failure -> + let detail = match detail with + | None -> asset_failure_detail ~operation failure + | Some detail -> + let module D = Logseq_db_types.Failure_diagnostic in + D.of_source ~operation ~reason:(D.reason detail) (D.encode detail) in + publish_failure (report_current_graph t scope) detail +;; + +let report_source_failure t scope operation reason source = + publish_failure (report_current_graph t scope) (failure_detail ~source ~operation ~reason ()) +;; + +let clear_notice_diagnostic ?ticket_id t scope key = + t.notice_diagnostics <- List.filter (fun item -> item.scope <> scope || item.key <> key + || Option.fold ~none:false ~some:(fun id -> item.ticket_id<>id) ticket_id) + t.notice_diagnostics +;; + +let queue_notice_diagnostic t scope key ticket_id detail = + clear_notice_diagnostic t scope key; + if current_graph_scope t scope then ( + let module D = Logseq_db_types.Failure_diagnostic in + let operation = match key with Upload_notice _ -> "assetUpload" | Download_notice _ -> "assetDownload" in + let detail = D.of_source ~operation ~reason:(D.reason detail) (D.encode detail) in + let rec take n = function [] -> [] | _ when n=0 -> [] | x::xs -> x::take (n-1) xs in + t.notice_diagnostics <- take 64 ({scope;key;detail;ticket_id} :: + List.filter (fun item -> current_graph_scope t item.scope) t.notice_diagnostics)) +;; + +let publish_asset_notice t scope notice output = + let key, failed, settled = match notice with + | Core.Upload_status {operation;status;_} -> + Some (Upload_notice operation), (match status with Upload.Failed_upload _ -> true | _ -> false), + (match status with Upload.Uploaded | Cancelled -> true | _ -> false) + | Asset_availability {asset;availability;_} -> + Some (Download_notice asset), (match availability with + Logseq_sync_pure_reducer.Asset_transfer.Failed {retry_scheduled=false;_} -> true | _ -> false), + (match availability with Logseq_sync_pure_reducer.Asset_transfer.Ready _ -> true | _ -> false) + | Asset_demand_accepted _ | Asset_backpressure _ | Asset_capacity_available -> None, false, false in + let detail = Option.bind key (fun key -> + let found = List.find_opt (fun item -> item.scope=scope && item.key=key) t.notice_diagnostics in + if settled then clear_notice_diagnostic t scope key; + Option.map (fun item -> item.detail) found) in + if not t.stopped then t.dependencies.publish output; + if failed then Option.iter (publish_scoped_asset_detail t scope) detail ;; let close_database_by_id ?scope t database_id = @@ -761,7 +1055,7 @@ let close_database_by_id ?scope t database_id = let result = Database.close session.database in (match result with | Ok () -> () - | Error _ -> + | Error error -> Option.iter (fun (scope:Sync.graph_scope) -> match t.latest_snapshot with | Some snapshot when not t.stopped @@ -770,12 +1064,15 @@ let close_database_by_id ?scope t database_id = || snapshot.selected_graph=None) -> (* Returning to the picker advances graph_generation before its owned detach runs; a newly selected graph fences that close. *) - t.dependencies.publish (Core.Diagnostic "worker.closeDatabase") + let module D=Logseq_db_types.Failure_diagnostic in + let reason,source=match error with Overlay.Close_failed s->"closeFailed",s | Fatal_close s->"fatalState",s in + let detail=D.of_source ~operation:"closeDatabase" ~reason source in + t.dependencies.publish (Core.Diagnostic(D.signal_prefix ^ D.encode detail)) | _ -> ()) scope; report_warning "journal: operation=closeDatabase code=cleanupPending\n"); Hashtbl.remove t.databases database_id; Result.map_error - (fun _ -> effect_error "The overlay database failed to close.") + close_error result ;; @@ -896,12 +1193,7 @@ let execute_mutation database request command = | Error message -> failure request Conflict message | Ok expected -> (match Database.commit_local database ~expected mutation with - | Error - ( Overlay.Local_commit_busy - | Local_commit_persistence_failed _ - | Local_commit_fatal_state _ ) -> - failure request Storage_busy "The mutation could not be stored." - | Error _ -> failure request Conflict "The mutation precondition did not match." + | Error error -> Protocol.failed ~request_id:request.Protocol.request_id (local_commit_error error) | Ok (Overlay.Local_existing (Overlay.Existing_applied commit)) -> response request (committed_outcome commit) | Ok (Local_existing _) -> @@ -925,15 +1217,24 @@ let inspect_admission database request = }) ;; -let read_failure request = function +let read_failure ?code request error = + let fail default message = failure request (Option.value code ~default) message in + let safe_message reason source = + Logseq_db_types.Failure_diagnostic.of_source + ~operation:"workerOperation" + ~reason + source + |> Logseq_db_types.Failure_diagnostic.message + in + match error with | Overlay.Stale_read_cursor -> - failure request Stale_read_cursor "The read continuation is no longer current." - | Invalid_read_request message -> failure request Invalid_request message + fail Stale_read_cursor "The read continuation is no longer current." + | Invalid_read_request source -> fail Invalid_request (safe_message "invalidRequest" source) | Read_limit_exceeded -> - failure request Response_too_large "The read exceeded its resource limit." + fail Response_too_large "The read exceeded its resource limit." | Database_closed | Snapshot_released | Snapshot_generation_invalidated -> - failure request Closed_session "The read snapshot is no longer available." - | Fatal_read_state message -> failure request Corrupt_storage message + fail Closed_session "The read snapshot is no longer available." + | Fatal_read_state source -> fail Corrupt_storage (safe_message "fatalState" source) ;; let read_snapshot ~on_enrichment_error database request command = @@ -976,7 +1277,7 @@ let read_snapshot ~on_enrichment_error database request command = match command with | Protocol.V2_graph_info -> (match Database.graph_info snapshot with - | Error _ -> failure request Corrupt_storage "Graph info is unavailable." + | Error error -> read_failure ~code:Corrupt_storage request error | Ok info -> let limits = info.Overlay.limits in response @@ -1127,7 +1428,8 @@ let read_snapshot ~on_enrichment_error database request command = (Protocol.V2_page_outcome (V2_missing_page { uuid; revision = Overlay.Page_state_revision.to_string revision })) - | Ok _ | Error _ -> failure request Corrupt_storage "The page lookup failed.") + | Error error -> read_failure ~code:Corrupt_storage request error + | Ok _ -> failure request Corrupt_storage "The page lookup failed.") | V2_get_outline_block { block; _ } -> (match Database.get_outline_blocks snapshot [ block ] with | Ok [ Overlay.Present_block { value; revision } ] -> @@ -1144,7 +1446,8 @@ let read_snapshot ~on_enrichment_error database request command = (Protocol.V2_block_outcome (V2_missing_block { uuid; revision = Overlay.Block_state_revision.to_string revision })) - | Ok _ | Error _ -> failure request Corrupt_storage "The block lookup failed.") + | Error error -> read_failure ~code:Corrupt_storage request error + | Ok _ -> failure request Corrupt_storage "The block lookup failed.") | V2_get_outline_block_summary { block; limit; cursor } -> let version = Database.snapshot_version snapshot in let summary lookup page scope_revision items next_cursor = @@ -1227,7 +1530,8 @@ let read_snapshot ~on_enrichment_error database request command = "" [] None - | Ok _ | Error _ -> + | Error error -> read_failure ~code:Corrupt_storage request error + | Ok _ -> failure request Corrupt_storage "The target page lookup failed.") | Error error -> read_failure request error | Ok _ -> failure request Corrupt_storage "The target block lookup failed.") @@ -1461,6 +1765,16 @@ let authoritative_transition_error error = let diagnostic = match error with | Overlay.Authoritative_decode_failed source -> Diagnostic.decode source + | Overlay.Authoritative_integrity_failure source -> + Some (Diagnostic.integrity_of_source source) + | _ -> None + in + let bounded = + match error with + | Overlay.Authoritative_crypto_error e -> + Some ("cryptoInvalid", crypto_result_reason e) + | Authoritative_commit_persistence_failed s -> Some ("persistenceFailed", s) + | Authoritative_commit_fatal_state s -> Some ("fatalState", s) | _ -> None in let code, category, message = @@ -1513,7 +1827,10 @@ let authoritative_transition_error error = | Overlay.Authoritative_integrity_failure _ -> ( Error.Ambiguous_sync_state , "authoritativeIntegrityFailure" - , "Authoritative integrity validation failed." ) + , Option.fold + ~none:"Authoritative integrity validation failed." + ~some:Diagnostic.message + diagnostic ) | Overlay.Authoritative_commit_database_closed -> ( Error.Closed_session , "authoritativeCommitDatabaseClosed" @@ -1537,6 +1854,18 @@ let authoritative_transition_error error = , "authoritativeCommitFatalState" , "Authoritative storage requires recovery." ) in + let bounded = + Option.map + (fun (reason, source) -> + Logseq_db_types.Failure_diagnostic.of_source + ~operation:"applyAuthoritative" + ~reason + source) + bounded + in + let message = + Option.fold ~none:message ~some:Logseq_db_types.Failure_diagnostic.message bounded + in let origin = Error.create_cause ~component:Error.Managed_sync @@ -1555,6 +1884,15 @@ let authoritative_transition_error error = } ] in + let details = + details + @ Option.fold + ~none:[] + ~some:(fun value -> + let module D = Logseq_db_types.Failure_diagnostic in + [ Error.{ name = D.detail_name; value = Detail_string (D.encode value) } ]) + bounded + in Error.create_with_origin ~code ~message ~details ~origin |> Result.get_ok ;; @@ -1573,6 +1911,19 @@ let deletion_current t request = (not t.stopped) && t.deletion_owner = Some request && deletion_authorized t request ;; +let report_asset_diagnostic t ~scope ~operation ~request_operation detail = + let module D = Logseq_db_types.Failure_diagnostic in + let current = current_graph_scope t scope || + Option.fold ~none:false ~some:(fun request -> + deletion_current t request && request.account=scope.Sync.account + && request.graph_id=scope.graph_id) t.deletion_owner in + match Hashtbl.find_opt t.asset_waiters request_operation with + | Some waiter when current && waiter.request.scope=scope + && not waiter.accepted && waiter.delivered=None && D.operation detail=operation -> + waiter.diagnostic <- Some detail + | Some _ | None -> () +;; + let sync_retirement_directory path = let fd = Unix.openfile path [Unix.O_RDONLY] 0 in Fun.protect ~finally:(fun () -> Unix.close fd) (fun () -> Unix.fsync fd) @@ -1626,14 +1977,30 @@ let remove_local_retirement path = Unix.unlink path; sync_retirement_directory (Filename.dirname path) ;; -let finish_local_retirement t scope deletion captured ~commit = +let mirror_deletion_detail = function + | Overlay.Mirror_delete_busy -> failure_detail ~operation:"deleteMirror" ~reason:"busy" () + | Mirror_delete_stale -> failure_detail ~operation:"deleteMirror" ~reason:"attachmentStale" () + | Mirror_delete_failed source -> failure_detail ~source ~operation:"deleteMirror" ~reason:"deleteFailed" () +;; + +let finish_local_retirement ?(on_diagnostic = fun _ -> ()) t scope deletion captured ~commit = let ( let* ) = Result.bind in - let* () = if commit then with_upload_store t (fun db -> Logseq_db_storage.Asset_upload_store.delete_captured_graph db captured) else Ok () in - asset_unit t ~scope + let* () = if commit then + with_upload_store t (fun db -> Logseq_db_storage.Asset_upload_store.delete_captured_graph db captured) + |> Result.map_error (fun source -> + on_diagnostic (failure_detail ~source ~operation:"deleteMirror" ~reason:"persistenceFailed" ()); source) + else Ok () in + asset_unit ~on_failure:(fun failure -> + if failure <> Sync.Asset_cancelled then on_diagnostic (asset_failure_detail ~operation:"deleteMirror" failure)) + ~on_diagnostic:(fun detail -> + let module D = Logseq_db_types.Failure_diagnostic in + on_diagnostic (D.of_source ~operation:"deleteMirror" ~reason:"cleanupFailed" (D.encode detail))) t ~scope (if commit then Sync.Commit_graph_asset_deletion deletion else Sync.Cancel_graph_asset_deletion deletion) ;; -let recover_local_retirements t (scope : Sync.graph_scope) = +let recover_local_retirements ?on_diagnostic t (scope : Sync.graph_scope) = + let report = Option.value on_diagnostic ~default:(fun detail -> + publish_failure (report_current_graph t scope) detail) in let ( let* ) = Result.bind in let directory = local_retirement_directory t scope in if not (Sys.file_exists directory) then Ok () else @@ -1656,7 +2023,8 @@ let recover_local_retirements t (scope : Sync.graph_scope) = let* prepared, generation = Database.prepare_mirror_deletion ~application_support_directory:t.dependencies.config.application_support_directory ~graph_id:scope.graph_id ~retirement - |> Result.map_error (fun _ -> + |> Result.map_error (fun error -> + report (mirror_deletion_detail error); "Retirement recovery is blocked by mirror ownership or an unknown \ commit state.") in @@ -1664,25 +2032,32 @@ let recover_local_retirements t (scope : Sync.graph_scope) = let committed = Database.mirror_deletion_committed prepared in let original = Overlay.Mirror_generation.equal generation expected in if (not committed) && not original - then + then ( + report (failure_detail ~operation:"deleteMirror" ~reason:"ownershipConflict" ()); Error "Retirement recovery conflict; the captured resources were \ - preserved." + preserved.") else ( let deletion = Sync.asset_deletion_reference ~scope ~retirement in let* () = - finish_local_retirement t scope deletion captured ~commit:committed + finish_local_retirement ~on_diagnostic:report t scope deletion captured ~commit:committed in if committed then ( try remove_local_retirement journal_path with - | _ -> report_warning "journal: phase=retirement operation=removeCoordinatorJournal code=cleanupPending\n") + | error -> + report (exception_failure ~operation:"deleteMirror" ~reason:"cleanupFailed" error); + report_warning "journal: phase=retirement operation=removeCoordinatorJournal code=cleanupPending\n") else remove_local_retirement journal_path; Ok ())) with - | _ -> Error "Local copy recovery is incomplete; its resources were preserved.")) + | error -> + report (exception_failure ~operation:"deleteMirror" ~reason:"restoreFailed" error); + Error "Local copy recovery is incomplete; its resources were preserved.")) (Ok ()) - (Sys.readdir directory) + (try Sys.readdir directory with error -> + report (exception_failure ~operation:"deleteMirror" ~reason:"restoreFailed" error); + raise error) ;; let handle_sync_worker_effect t = function @@ -1697,7 +2072,7 @@ let handle_sync_worker_effect t = function t.dependencies.config.application_support_directory ~graph_id:request.graph.graph_id with - | Error _ -> Error (effect_error "The overlay mirror inspection failed.") + | Error error -> Error (mirror_error error) | Ok inspection -> Hashtbl.replace t.inspections @@ -1723,15 +2098,14 @@ let handle_sync_worker_effect t = function ~expected_checksum:None ~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.") + | Error error -> Error (snapshot_activation_error error) | Ok prepared -> Fun.protect ~finally:(fun () -> Database.cancel_snapshot_activation prepared) (fun () -> let rec supply () = match Database.next_snapshot_unprotection_batch prepared with - | Error _ -> Error (effect_error "The snapshot crypto request failed.") + | Error error -> Error (snapshot_activation_error error) | Ok None -> Ok () | Ok (Some crypto_request) -> let ciphertexts = Database.unprotection_ciphertexts crypto_request in @@ -1757,14 +2131,14 @@ let handle_sync_worker_effect t = function ~request:crypto_request ~plaintexts with - | Error _ -> Error (effect_error "Snapshot crypto supply failed.") + | Error error -> Error (snapshot_activation_error error) | Ok () -> supply ())) in match supply () with | Error error -> Error error | Ok () -> (match Database.commit_snapshot_activation prepared with - | Error _ -> Error (effect_error "Snapshot activation did not commit.") + | Error error -> Error (snapshot_activation_error error) | Ok inspection -> Hashtbl.replace t.inspections key inspection; Ok @@ -1774,19 +2148,26 @@ let handle_sync_worker_effect t = function | Delete_mirror request -> let ( let* ) = Result.bind in let current () = deletion_current t request in + let report detail = + if current () then publish_failure + (fun signal -> t.dependencies.publish (Core.Diagnostic signal)) detail in let scope : Sync.graph_scope = {account=request.account;graph_id=request.graph_id;graph_generation=0} in let result = try let* () = if current () then Ok () else Error "The graph account is no longer current." in let* () = match t.attached with - | Some (_, actual) when actual.graph_id = request.graph_id -> Error "Switch to another graph before deleting." + | Some (_, actual) when actual.graph_id = request.graph_id -> + report (failure_detail ~operation:"deleteMirror" ~reason:"busy" ()); + Error "Switch to another graph before deleting." | _ -> Ok () in - let* () = with_staging_lock t (fun () -> recover_local_retirements t scope) in + let* () = with_staging_lock t (fun () -> recover_local_retirements ~on_diagnostic:report t scope) in let retirement = fresh_retirement () in (* Ownership is acquired before any asset retirement or intent mutation. *) let* prepared, expected = Database.prepare_mirror_deletion ~application_support_directory:t.dependencies.config.application_support_directory ~graph_id:request.graph_id ~retirement - |> Result.map_error (function + |> Result.map_error (fun error -> + report (mirror_deletion_detail error); + match error with | Overlay.Mirror_delete_busy -> "The local copy is still in use. Close attachment previews and try again." | _ -> "The local copy changed. Refresh and try again.") in Fun.protect ~finally:(fun () -> Database.cancel_mirror_deletion prepared) (fun () -> @@ -1799,13 +2180,17 @@ let handle_sync_worker_effect t = function if not (current ()) then Error "The graph account is no longer current." else if not (Hashtbl.fold (fun (ticket : Upload.ticket) _ busy -> busy || same_target ticket.scope) t.upload_operations false) then Ok () - else if attempts=0 then Error "The local copy is still in use. Try again." + else if attempts=0 then ( + report (failure_detail ~operation:"deleteMirror" ~reason:"busy" ()); + Error "The local copy is still in use. Try again.") else (t.dependencies.runtime.sleep 0.005; drain (attempts-1)) in let* () = drain 1000 in with_staging_lock t (fun () -> let* () = if current () then Ok () else Error "The graph account is no longer current." in let* captured = with_upload_store t (fun db -> Logseq_db_storage.Asset_upload_store.capture_graph db - ~origin:(Uri.to_string request.account.managed_sync_origin) ~account:request.account.user_id ~graph:request.graph_id) in + ~origin:(Uri.to_string request.account.managed_sync_origin) ~account:request.account.user_id ~graph:request.graph_id) + |> Result.map_error (fun source -> + report (failure_detail ~source ~operation:"deleteMirror" ~reason:"persistenceFailed" ()); source) in let directory = local_retirement_directory t scope in make_retirement_directory directory; let path = Filename.concat directory (Sync.retirement_id_to_string retirement ^ ".json") in @@ -1815,38 +2200,50 @@ let handle_sync_worker_effect t = function let abort () = if not !committed then ( - match finish_local_retirement t scope deletion captured ~commit:false with + match finish_local_retirement ~on_diagnostic:report t scope deletion captured ~commit:false with | Ok () -> remove_local_retirement path | Error _ -> report_warning "journal: phase=retirement operation=abortLocalRetirement code=cleanupPending\n") in Fun.protect ~finally:abort (fun () -> let* () = match asset_call t ~scope (Sync.Prepare_graph_asset_deletion retirement) with | Ok (Sync.Asset_deletion_prepared actual) when actual=deletion -> Ok () - | Error error -> Error (asset_failure_message error) - | _ -> Error "Unexpected attachment retirement response." in + | Error error -> + if error <> Sync.Asset_cancelled then report (asset_failure_detail ~operation:"deleteMirror" error); + Error (asset_failure_message error) + | _ -> + report (failure_detail ~source:"Unexpected attachment retirement response." ~operation:"deleteMirror" ~reason:"invalidContent" ()); + Error "Unexpected attachment retirement response." in let* () = if current () then Ok () else Error "The graph account is no longer current." in let* _ = Database.commit_mirror_deletion prepared - |> Result.map_error (fun _ -> "Local deletion did not commit; its resources were preserved.") in + |> Result.map_error (fun error -> + report (mirror_deletion_detail error); + "Local deletion did not commit; its resources were preserved.") in committed := true; - let report code = + let report_code code = if not t.stopped && current () then t.dependencies.publish (Core.Diagnostic code) in if not (Database.mirror_deletion_committed prepared) - then report "worker.confirmMirrorRetirement"; + then report_code "worker.confirmMirrorRetirement"; (* The mirror receipt is the durable commit authority. Cleanup failure retains this coordinator journal for exact replay, not a broad sweep. *) (try if Database.mirror_deletion_committed prepared then ( - match finish_local_retirement t scope deletion captured ~commit:true with + match finish_local_retirement ~on_diagnostic:report t scope deletion captured ~commit:true with | Ok () -> remove_local_retirement path - | Error _ -> report "worker.finishLocalRetirement") + | Error _ -> report_code "worker.finishLocalRetirement") with - | _ -> report "worker.finishLocalRetirement"); + | error -> + let detail = exception_failure ~operation:"deleteMirror" ~reason:"cleanupFailed" error in + if current () then publish_failure + (fun signal -> t.dependencies.publish (Core.Diagnostic signal)) detail; + report_code "worker.finishLocalRetirement"); Hashtbl.remove t.inspections (Graph.Uuid.to_string request.graph_id); Ok ()))) with | Eio.Cancel.Cancelled _ as cancelled -> raise cancelled - | _ -> Error "The local copy could not be deleted. Check available storage and try again." + | error -> + report (exception_failure ~operation:"deleteMirror" ~reason:"deleteFailed" error); + Error "The local copy could not be deleted. Check available storage and try again." in Ok (sync_result ~event:(Sync.Mirror_deleted (request, result)) ()) | Attach_graph request -> @@ -1858,12 +2255,12 @@ let handle_sync_worker_effect t = function (match Database.open_ ~sw:t.sw t.dependencies.overlay inspection ~graph_name:request.graph.name with - | Error _ -> Error (effect_error "The overlay graph could not be opened.") + | Error error -> Error (open_error error) | Ok database -> (match Database.listen database with - | Error _ -> + | Error error -> ignore (Database.close database); - Error (effect_error "The overlay change listener could not start.") + Error (listen_error error) | Ok (subscription, predecessor) -> let version = Database.snapshot_version predecessor in Database.release_snapshot predecessor; @@ -1884,18 +2281,18 @@ let handle_sync_worker_effect t = function Database.activate_subscription subscription ~notify:(fun change -> publish_projection_change t session change) with - | Error _ -> + | Error error -> ignore (close_database_by_id t database_id); - Error (effect_error "The overlay change listener could not activate.") + Error (listen_error error) | Ok () -> let sync = Database.inspect_sync database in let opened = Core.database_opened ~database_id ~graph_id:request.graph.graph_id in (match sync with - | Error _ -> + | Error error -> ignore (close_database_by_id t database_id); - Error (effect_error "The overlay Sync view is unavailable.") + Error (sync_read_error error) | Ok sync -> Ok (sync_result @@ -1931,7 +2328,7 @@ let handle_sync_worker_effect t = function | None -> Error (effect_error "The overlay database is unavailable.") | Some session -> (match Database.inspect_sync session.database with - | Error _ -> Error (effect_error "The overlay Sync view is unavailable.") + | Error error -> Error (sync_read_error error) | Ok sync -> Ok (sync_result ~event:(Sync.Sync_inspected { scope; sync }) ()))) | Apply_outbox_transition request -> (match attached_session t request.scope with @@ -1943,7 +2340,7 @@ let handle_sync_worker_effect t = function ~expected:request.expected request.transition with - | Error _ -> Error (effect_error "The overlay outbox transition was rejected.") + | Error error -> Error (outbox_transition_error error) | Ok (prepared, crypto) -> let encrypted = match crypto with @@ -1956,10 +2353,10 @@ let handle_sync_worker_effect t = function (match Database.apply_outbox_transition session.database prepared ~encrypted with - | Error _ -> Error (effect_error "The overlay outbox transition failed.") + | Error error -> Error (outbox_transition_error error) | Ok commit -> (match Database.inspect_sync session.database with - | Error _ -> Error (effect_error "The overlay Sync view is unavailable.") + | Error error -> Error (sync_read_error error) | Ok sync -> Ok (sync_result @@ -1972,7 +2369,7 @@ let handle_sync_worker_effect t = function | None -> Error (effect_error "The overlay database is unavailable.") | Some session -> (match Database.inspect_sync session.database with - | Error _ -> Error (effect_error "The overlay Sync view is unavailable.") + | Error error -> Error (sync_read_error error) | Ok sync -> (match Database.begin_authoritative @@ -2051,33 +2448,39 @@ let complete t (Core.Request (ticket, request)) = t.post (Core.Runner_completed (Core.Sync_worker_effect_completed (ticket, result))) ;; -let report_asset_error = report_current_graph -;; - let run_upload t (context : Sync.asset_context) instruction = let current ticket = (not t.stopped) && t.upload_current ticket in + let on_diagnostic ticket detail = + if current ticket then queue_notice_diagnostic t context.scope + (Upload_notice ticket.Upload.operation) ticket.id detail in let database ticket f = if not (current ticket) then Error Upload.Invalid_content else ( match attached_session t ticket.Upload.scope with - | None -> Error Upload.Invalid_content + | None -> + on_diagnostic ticket (failure_detail ~operation:"assetUpload" ~reason:"databaseClosed" ()); + Error Upload.Invalid_content | Some session -> f session.database) in let finish ticket result success = if current ticket - then + then ( + if Result.is_ok result then clear_notice_diagnostic t context.scope (Upload_notice ticket.Upload.operation); t.post (Core.Upload_completed ( ticket , match result with | Ok () -> success - | Error error -> Upload.Failed error )) + | Error error -> Upload.Failed error ))) in let changed () = t.post (Core.Sync_event Sync.Local_outbox_changed) in match instruction with - | Upload.Cancel_operation _ -> () + | Upload.Cancel_operation ticket -> clear_notice_diagnostic ~ticket_id:ticket.id t context.scope (Upload_notice ticket.operation) | Release_staging intent -> + let detail = ref None in + let on_failure failure = detail := Some (asset_failure_detail ~operation:"assetRelease" failure) in + let on_diagnostic value = detail := Some value in (match let can_release = if intent.Logseq_db_types.Asset_upload_intent.phase = Cancelled @@ -2090,9 +2493,9 @@ let run_upload t (context : Sync.asset_context) instruction = (Sync.Check_asset_cache (intent.asset, intent.version)) with | Ok (Sync.Asset_cached (Some handle)) -> - asset_unit t ~scope:context.scope (Sync.Release_asset_file handle) + asset_unit ~on_failure ~on_diagnostic t ~scope:context.scope (Sync.Release_asset_file handle) | _ -> - asset_unit + asset_unit ~on_failure ~on_diagnostic t ~scope:context.scope (Sync.Publish_staged_asset @@ -2102,18 +2505,20 @@ let run_upload t (context : Sync.asset_context) instruction = })) in Result.bind can_release (fun () -> - asset_unit t ~scope:context.scope (Sync.Release_staged_file intent.staged_file)) + asset_unit ~on_failure ~on_diagnostic t ~scope:context.scope (Sync.Release_staged_file intent.staged_file)) with | Ok () -> () - | Error _ -> - report_asset_error t context.scope "asset.release"; + | Error source -> + let detail = Option.value !detail ~default:(failure_detail ~source ~operation:"assetRelease" ~reason:"cleanupFailed" ()) in + if Logseq_db_types.Failure_diagnostic.reason detail <> "cancelled" then + publish_failure (report_current_graph t context.scope) detail; report_warning "journal: operation=releaseUploadStaging code=cleanupPending\n" ) | Persist (ticket, intent, expected) -> if current ticket then finish ticket (save_upload t intent expected) Upload.Persisted | Inspect (ticket, intent) -> if current ticket then ( - let result = database ticket (fun db -> Upload_database.inspect db intent) in + let result = database ticket (fun db -> Upload_database.inspect ~on_diagnostic:(on_diagnostic ticket) db intent) in if current ticket then t.post @@ -2125,7 +2530,7 @@ let run_upload t (context : Sync.asset_context) instruction = | Apply_local (ticket, intent) -> if current ticket then ( - let result = database ticket (fun db -> Upload_database.apply_local db intent) in + let result = database ticket (fun db -> Upload_database.apply_local ~on_diagnostic:(on_diagnostic ticket) db intent) in (match result with | Ok () -> changed () | Error _ -> ()); @@ -2133,18 +2538,20 @@ let run_upload t (context : Sync.asset_context) instruction = | Apply_metadata (ticket, intent) -> if current ticket then ( - let result = database ticket (fun db -> Upload_database.apply_metadata db intent) in + let result = database ticket (fun db -> Upload_database.apply_metadata ~on_diagnostic:(on_diagnostic ticket) db intent) in (match result with | Ok () -> changed () | Error _ -> ()); finish ticket result Upload.Metadata_applied) | Put (ticket, intent) -> if current ticket - then + then ( + let detail = ref None in finish ticket (match asset_call + ~on_diagnostic:(fun value -> detail := Some value) t ~scope:context.scope (Sync.Put_asset_file @@ -2155,8 +2562,13 @@ let run_upload t (context : Sync.asset_context) instruction = }) with | Ok Sync.Asset_unit -> Ok () - | Ok _ -> Error Upload.Invalid_content + | Ok _ -> + on_diagnostic ticket (failure_detail ~operation:"assetUpload" ~reason:"invalidContent" ()); + Error Upload.Invalid_content | Error failure -> + if current ticket && failure <> Sync.Asset_cancelled then + on_diagnostic ticket (Option.value !detail + ~default:(asset_failure_detail ~operation:"assetUpload" failure)); Error (match failure with | Sync.Asset_network -> Upload.Network @@ -2168,27 +2580,29 @@ let run_upload t (context : Sync.asset_context) instruction = | Asset_storage_full | Asset_cancelled | Asset_invalid_content _ -> Invalid_content)) - Upload.Put_succeeded + Upload.Put_succeeded) | Await_publication (ticket, intent) -> let rec await () = if current ticket then ( - match database ticket (fun db -> Upload_database.inspect db intent) with + match database ticket (fun db -> Upload_database.inspect ~on_diagnostic:(on_diagnostic ticket) db intent) with | Ok Upload.Publication_acknowledged -> finish ticket (Ok ()) Upload.Publication_acked | Ok (Local_present | Metadata_present) -> t.dependencies.runtime.sleep 0.5; await () | Ok (Absent | Entity_cancelled) -> + on_diagnostic ticket (failure_detail ~operation:"assetUpload" ~reason:"missingPrecondition" ()); finish ticket (Error Upload.Invalid_content) Upload.Publication_acked | Error error -> finish ticket (Error error) Upload.Publication_acked) in await () ;; -let submit_upload t context instruction = +let submit_upload t (context : Sync.asset_context) instruction = match instruction with | Upload.Cancel_operation ticket -> + clear_notice_diagnostic ~ticket_id:ticket.id t context.scope (Upload_notice ticket.operation); Option.iter (fun resolver -> ignore (Eio.Promise.try_resolve resolver () : bool)) (Hashtbl.find_opt t.upload_operations ticket) @@ -2202,6 +2616,7 @@ let submit_upload t context instruction = | Await_publication (ticket, _) -> if t.upload_current ticket && not (Hashtbl.mem t.upload_operations ticket) then ( + clear_notice_diagnostic t context.scope (Upload_notice ticket.operation); let cancelled, resolve = Eio.Promise.create () in Hashtbl.add t.upload_operations ticket resolve; t.dependencies.runtime.fork ~sw:t.sw (fun () -> @@ -2251,10 +2666,10 @@ let local_import t ~(scope : Sync.graph_scope) ~asset ~checksum ~current = else None))) ;; -let lookup_local_asset t ~scope ~asset ~checksum ~current call = +let lookup_local_asset t ~scope ~asset ~checksum ~current ~diagnostic call = match local_import t ~scope ~asset ~checksum ~current with - | Error _ -> - if current () then report_asset_error t scope "asset.metadata"; + | Error source -> + if current () then report_source_failure t scope "assetMetadata" "persistenceFailed" source; Error Sync.Asset_cancelled | Ok None -> Ok (Sync.Asset_cached None) | Ok (Some intent) -> @@ -2269,10 +2684,14 @@ let lookup_local_asset t ~scope ~asset ~checksum ~current call = (Sync.Publish_staged_asset { asset; version = intent.version; file = intent.staged_file }) in + let published_diagnostic = !diagnostic in (match published with | Error Sync.Asset_storage_full -> Ok (Sync.Asset_cached (Some lease)) | _ -> - ignore (call (Sync.Release_asset_file lease)); + (match call (Sync.Release_asset_file lease) with + | Error failure when current () -> report_asset_failure t scope "assetRelease" failure + | Ok _ | Error _ -> ()); + diagnostic := published_diagnostic; (match published with | Ok Sync.Asset_unit -> call (Sync.Check_asset_cache (asset, intent.version)) @@ -2296,31 +2715,40 @@ let run_asset t (context : Sync.asset_context) instruction = let valid () = (not t.stopped) && scope = context.scope && t.asset_current ticket in if valid () && not (Hashtbl.mem t.asset_operations ticket) then ( + let asset = match ticket with Transfer.Lookup ticket -> ticket.asset | Download ticket -> ticket.asset in + let ticket_id = match ticket with Transfer.Lookup ticket -> ticket.id | Download ticket -> ticket.id in + clear_notice_diagnostic t context.scope (Download_notice asset); Hashtbl.add t.asset_operations ticket ""; t.dependencies.runtime.fork ~sw:t.sw (fun () -> Fun.protect ~finally:(fun () -> Hashtbl.remove t.asset_operations ticket) (fun () -> + let detail = ref None in let result = if not (valid ()) then Error Sync.Asset_cancelled else - asset_call - ~started:(fun operation -> - Hashtbl.replace t.asset_operations ticket operation) - t - ~scope:context.scope - |> action + let call action = + detail := None; + asset_call ~on_diagnostic:(fun value -> detail := Some value) + ~started:(fun operation -> Hashtbl.replace t.asset_operations ticket operation) + t ~scope:context.scope action in + action detail call in if valid () - then + then ( + let asset = match ticket with Transfer.Lookup ticket -> ticket.asset | Download ticket -> ticket.asset in + (match result with + | Error Sync.Asset_cancelled | Ok _ -> clear_notice_diagnostic t context.scope (Download_notice asset) + | Error failure -> queue_notice_diagnostic t context.scope (Download_notice asset) ticket_id + (Option.value !detail ~default:(asset_failure_detail ~operation:"assetDownload" failure))); t.post (Core.Asset_completed ( context.scope , success (match result with | Ok value -> Ok value - | Error failure -> Error (transfer_failure failure)) )) + | Error failure -> Error (transfer_failure failure)) ))) else ( match result with | Ok (Sync.Asset_cached (Some handle) | Asset_downloaded handle) -> @@ -2332,7 +2760,7 @@ let run_asset t (context : Sync.asset_context) instruction = let work = Transfer.Lookup ticket in perform work - (fun call -> + (fun diagnostic call -> let local checksum = lookup_local_asset t @@ -2340,6 +2768,7 @@ let run_asset t (context : Sync.asset_context) instruction = ~asset:ticket.asset ~checksum ~current:(fun () -> t.asset_current work) + ~diagnostic call in match ticket.source with @@ -2359,7 +2788,7 @@ let run_asset t (context : Sync.asset_context) instruction = | Fetch ticket -> perform (Transfer.Download ticket) - (fun call -> + (fun _diagnostic call -> call (Sync.Fetch_asset { asset = ticket.asset @@ -2374,6 +2803,8 @@ let run_asset t (context : Sync.asset_context) instruction = | _ -> Error (Transfer.Invalid_content "Unexpected download completion")) )) | Cancel ticket -> + let asset, id = match ticket with Transfer.Lookup ticket -> ticket.asset,ticket.id | Download ticket -> ticket.asset,ticket.id in + clear_notice_diagnostic ~ticket_id:id t context.scope (Download_notice asset); Option.iter (fun operation -> if operation <> "" @@ -2441,7 +2872,9 @@ let submit t = function if not t.stopped then submit_upload t context instruction | Core.Run_asset (context, instruction) -> if not t.stopped then run_asset t context instruction - | Close_asset_scope scope -> send_asset t ~scope Sync.Close_asset_scope + | Close_asset_scope scope -> + t.notice_diagnostics <- List.filter (fun item -> item.scope<>scope) t.notice_diagnostics; + send_asset t ~scope Sync.Close_asset_scope | Core.Run_worker (Core.Request (_, Core.Handle_sync_worker_effect (Sync.Delete_mirror deletion)) as request) -> if deletion_authorized t deletion then t.deletion_owner <- Some deletion; if not t.stopped then t.dependencies.runtime.fork ~sw:t.sw (fun () -> complete t request) @@ -2474,9 +2907,11 @@ let submit t = function then t.authorized_graphs <- List.fold_left (fun ids (graph : Sync.graph) -> Authorized_graphs.add (Graph.Uuid.to_string graph.graph_id) ids) Authorized_graphs.empty state.snapshot.catalog; t.latest_snapshot <- Some state.snapshot; + t.notice_diagnostics <- List.filter (fun item -> current_graph_scope t item.scope) t.notice_diagnostics; if not (Option.fold ~none:false ~some:(deletion_authorized t) t.deletion_owner) then t.deletion_owner <- None; if not t.stopped then t.dependencies.publish output + | Publish (Core.Asset_notice (scope, notice) as output) -> publish_asset_notice t scope notice output | Publish output -> if not t.stopped then t.dependencies.publish output ;; @@ -2507,6 +2942,7 @@ let shutdown t = then ( t.post (Core.Sync_event Sync.Shutdown); t.stopped <- true; + t.notice_diagnostics <- []; Hashtbl.iter (fun _ resolve -> ignore (Eio.Promise.try_resolve resolve () : bool)) t.upload_operations; @@ -2523,19 +2959,24 @@ let shutdown t = ;; let retain_asset_file t ~scope ~handle = - match asset_call t ~scope (Sync.Retain_asset_file handle) with + let detail = ref None in + match asset_call ~on_diagnostic:(fun value -> detail := Some value) t ~scope (Sync.Retain_asset_file handle) with | Ok (Sync.Asset_retained file) -> file | Error Sync.Asset_cancelled -> None - | Ok _ | Error _ -> report_asset_error t scope "asset.preview"; None + | Error failure -> report_asset_failure ?detail:!detail t scope "assetPreview" failure; None + | Ok _ -> report_source_failure t scope "assetPreview" "invalidContent" "Unexpected asset completion"; None ;; let release_asset_file t ~scope ~handle = - match asset_call t ~scope (Sync.Release_asset_file handle) with + let detail = ref None in + match asset_call ~on_diagnostic:(fun value -> detail := Some value) t ~scope (Sync.Release_asset_file handle) with | Ok Sync.Asset_unit | Error Sync.Asset_cancelled -> () - | Ok _ | Error _ -> report_asset_error t scope "asset.release" + | Error failure -> report_asset_failure ?detail:!detail t scope "assetRelease" failure + | Ok _ -> report_source_failure t scope "assetRelease" "invalidContent" "Unexpected asset completion" ;; let prepare_import_unlocked + ~on_diagnostic t ~(context : Sync.asset_context) (request : Logseq_db_types.Asset_import.t) @@ -2544,6 +2985,8 @@ let prepare_import_unlocked let module I = Logseq_db_types.Asset_upload_intent in let module A = Logseq_db_types.Asset_descriptor in let ( let* ) = Result.bind in + let source_error reason result = Result.map_error (fun source -> + on_diagnostic (failure_detail ~source ~operation:"assetMetadata" ~reason ()); source) result in let prepare version size staged_file = I.prepare ~replace_reference:request.replace_reference @@ -2560,15 +3003,15 @@ let prepare_import_unlocked ~size ~staged_file in - let* version = A.version ~checksum:(String.make 64 '0') ~file_type:request.file_type in - let* _ = prepare version 0L "validation.bin" in + let* version = source_error "invalidRequest" (A.version ~checksum:(String.make 64 '0') ~file_type:request.file_type) in + let* _ = source_error "invalidRequest" (prepare version 0L "validation.bin") in if (not (current ())) || t.stopped then Error "The selected graph is no longer active" else - let* () = reconcile_staging t context.scope ~current in + let* () = source_error "restoreFailed" (reconcile_staging t context.scope ~current) in let* existing = - with_upload_store t (fun db -> - Logseq_db_storage.Asset_upload_store.read db ~operation:request.operation) + source_error "persistenceFailed" (with_upload_store t (fun db -> + Logseq_db_storage.Asset_upload_store.read db ~operation:request.operation)) in match existing with | Some intent @@ -2582,11 +3025,13 @@ let prepare_import_unlocked && intent.local_mutation = request.local_mutation && intent.replace_reference = request.replace_reference && intent.metadata_mutation = request.metadata_mutation -> Ok intent - | Some _ -> Error "This import identity is already in use" + | Some _ -> source_error "identityConflict" (Error "This import identity is already in use") | None -> + let detail = ref None in let* file, checksum, size = match asset_call + ~on_diagnostic:(fun value -> detail := Some value) t ~scope:context.scope (Sync.Stage_asset_file @@ -2596,21 +3041,24 @@ let prepare_import_unlocked }) with | Ok (Sync.Asset_staged { file; checksum; size }) -> Ok (file, checksum, size) - | Error failure -> Error (asset_failure_message failure) - | Ok _ -> Error "Unexpected staging completion" + | Error failure -> + if failure <> Sync.Asset_cancelled then + on_diagnostic (Option.value !detail ~default:(asset_failure_detail ~operation:"assetMetadata" failure)); + Error (asset_failure_message failure) + | Ok _ -> source_error "invalidContent" (Error "Unexpected staging completion") in let persistence_attempted = ref false in let result = - let* version = A.version ~checksum ~file_type:request.file_type in - let* intent = prepare version size file in + let* version = source_error "invalidRequest" (A.version ~checksum ~file_type:request.file_type) in + let* intent = source_error "invalidRequest" (prepare version size file) in if (not (current ())) || t.stopped then Error "The selected graph is no longer active" else ( persistence_attempted := true; let* () = - Eio.Cancel.protect (fun () -> + source_error "persistenceFailed" (Eio.Cancel.protect (fun () -> with_upload_store t (fun db -> - Logseq_db_storage.Asset_upload_store.save db ~expected:None intent)) + Logseq_db_storage.Asset_upload_store.save db ~expected:None intent))) in Ok intent) in @@ -2618,13 +3066,17 @@ let prepare_import_unlocked | Ok _ -> () | Error _ when not !persistence_attempted -> Eio.Cancel.protect (fun () -> - ignore (asset_unit t ~scope:context.scope (Sync.Release_staged_file file))) + ignore (asset_unit + ~on_failure:(fun failure -> if current () then report_asset_failure t context.scope "assetRelease" failure) + t ~scope:context.scope (Sync.Release_staged_file file))) | Error _ -> ()); result ;; let prepare_import t ~(context : Sync.asset_context) request ~current = with_staging_lock t (fun () -> + let on_diagnostic detail = if current () then + publish_failure (report_current_graph t context.scope) detail in Result.map (fun (intent : Logseq_db_types.Asset_upload_intent.t) -> Hashtbl.replace @@ -2633,7 +3085,8 @@ let prepare_import t ~(context : Sync.asset_context) request ~current = intent.operation_id; if intent.phase <> Logseq_db_types.Asset_upload_intent.Cancelled && current () then - (match asset_call + (let detail = ref None in + match asset_call ~on_diagnostic:(fun value -> detail := Some value) t ~scope:context.Sync.scope (Sync.Publish_staged_asset @@ -2642,10 +3095,10 @@ let prepare_import t ~(context : Sync.asset_context) request ~current = ; file = intent.staged_file }) with | Ok Sync.Asset_unit | Error Sync.Asset_cancelled -> () - | Ok _ | Error _ -> - if current () then report_asset_error t context.scope "asset.preview"); + | Error failure -> if current () then report_asset_failure ?detail:!detail t context.scope "assetPreview" failure + | Ok _ -> if current () then report_source_failure t context.scope "assetPreview" "invalidContent" "Unexpected asset completion"); intent) - (prepare_import_unlocked t ~context request ~current)) + (prepare_import_unlocked ~on_diagnostic t ~context request ~current)) ;; let retain_imported_file t ~(scope : Sync.graph_scope) ~operation = @@ -2661,10 +3114,12 @@ let retain_imported_file t ~(scope : Sync.graph_scope) ~operation = && intent.account = scope.account.user_id && intent.graph = scope.graph_id && intent.phase <> Logseq_db_types.Asset_upload_intent.Cancelled -> - (match asset_call t ~scope (Sync.Retain_staged_file intent.staged_file) with + (let detail = ref None in + match asset_call ~on_diagnostic:(fun value -> detail := Some value) t ~scope (Sync.Retain_staged_file intent.staged_file) with | Ok (Sync.Asset_retained file) -> file | Error Sync.Asset_cancelled -> None - | Ok _ | Error _ -> report_asset_error t scope "asset.preview"; None) + | Error failure -> report_asset_failure ?detail:!detail t scope "assetPreview" failure; None + | Ok _ -> report_source_failure t scope "assetPreview" "invalidContent" "Unexpected asset completion"; None) | Ok _ -> None - | Error _ -> report_asset_error t scope "asset.metadata"; None) + | Error source -> report_source_failure t scope "assetMetadata" "persistenceFailed" source; None) ;; diff --git a/logseq_db_worker/lib/effect_runner/upload_database.ml b/logseq_db_worker/lib/effect_runner/upload_database.ml index 5fc01e48..d64bdb69 100644 --- a/logseq_db_worker/lib/effect_runner/upload_database.ml +++ b/logseq_db_worker/lib/effect_runner/upload_database.ml @@ -3,9 +3,53 @@ module I = Logseq_db_types.Asset_upload_intent module A = Logseq_db_types.Asset_descriptor module D = Logseq_overlay_db.Database module T = Logseq_overlay_db.Types +module Diagnostic = Logseq_db_types.Failure_diagnostic let ( let* ) = Result.bind -let read result = Result.map_error (fun _ -> U.Invalid_content) result +let reject on_diagnostic reason source = + let detail = match source with + | None -> Diagnostic.create ~operation:"assetUpload" ~reason () + | Some source -> Diagnostic.of_source ~operation:"assetUpload" ~reason source in + (* Secondary reporting cannot change the existing upload failure result. *) + (try on_diagnostic detail with _ -> ()); + Error U.Invalid_content +;; + +let map_error on_diagnostic classify = function + | Ok value -> Ok value + | Error error -> let reason, source = classify error in reject on_diagnostic reason source +;; + +let read on_diagnostic = map_error on_diagnostic (function + | T.Database_closed | Snapshot_released | Snapshot_generation_invalidated -> "databaseClosed", None + | Read_limit_exceeded -> "limitExceeded", None + | Stale_read_cursor -> "staleCommit", None + | Invalid_read_request source -> "invalidRequest", Some source + | Fatal_read_state source -> "fatalState", Some source) +;; + +let local_result on_diagnostic = map_error on_diagnostic (function + | T.Local_database_closed -> "databaseClosed", None + | Invalid_local_mutation source -> "invalidMutation", Some source + | Mutation_identity_conflict -> "identityConflict", None + | Missing_precondition _ -> "missingPrecondition", None + | Target_precondition_conflict -> "preconditionConflict", None + | Planner_limit_exceeded -> "limitExceeded", None + | Delete_unsupported_for_footprint -> "unsupportedFootprint", None + | Local_commit_busy -> "busy", None + | Local_commit_persistence_failed source -> "persistenceFailed", Some source + | Local_commit_fatal_state source -> "fatalState", Some source) +;; + +let sync_result on_diagnostic = map_error on_diagnostic (function + | T.Sync_database_closed -> "databaseClosed", None + | Sync_fatal_state source -> "fatalState", Some source) +;; + +let precondition on_diagnostic = map_error on_diagnostic (function + | T.Duplicate_block_precondition _ | Duplicate_page_precondition _ + | Duplicate_scope_precondition _ -> "preconditionConflict", None) +;; let local (i : I.t) = T.Insert_blocks @@ -23,8 +67,8 @@ let metadata (i : I.t) = { mutation_id = i.metadata_mutation; block = i.asset; version = i.version } ;; -let snapshot db f = - let* s = read (D.current_snapshot db) in +let snapshot on_diagnostic db f = + let* s = read on_diagnostic (D.current_snapshot db) in Fun.protect ~finally:(fun () -> D.release_snapshot s) (fun () -> f s) ;; @@ -38,19 +82,19 @@ let rejected = function | _ -> false ;; -let inspect db (i : I.t) = - let* insertion = read (D.inspect_local_mutation db (local i)) in - let* publication = read (D.inspect_local_mutation db (metadata i)) in +let inspect ?(on_diagnostic = fun _ -> ()) db (i : I.t) = + let* insertion = local_result on_diagnostic (D.inspect_local_mutation db (local i)) in + let* publication = local_result on_diagnostic (D.inspect_local_mutation db (metadata i)) in if rejected insertion || rejected publication then Ok U.Entity_cancelled else - let* assets = snapshot db (fun s -> read (D.get_asset_descriptors s [ i.asset ])) in + let* assets = snapshot on_diagnostic db (fun s -> read on_diagnostic (D.get_asset_descriptors s [ i.asset ])) in match assets with | [] -> Ok (if applied insertion then U.Entity_cancelled else U.Absent) | [ asset ] when asset.current_checksum = Some i.version.checksum -> (match asset.source with | A.Managed (Some version) when A.equal_version version i.version -> - let* sync = read (D.inspect_sync db) in + let* sync = sync_result on_diagnostic (D.inspect_sync db) in let pending = List.exists (fun (s : T.submission_descriptor) -> s.mutation_id = i.metadata_mutation) @@ -67,64 +111,64 @@ let inspect db (i : I.t) = | _ -> Ok U.Entity_cancelled ;; -let commit db mutation expected = - let* outcome = read (D.commit_local db ~expected mutation) in +let commit on_diagnostic db mutation expected = + let* outcome = local_result on_diagnostic (D.commit_local db ~expected mutation) in match outcome with | T.Local_committed _ | Local_existing (Existing_applied _) -> Ok () - | Local_existing _ -> Error U.Invalid_content + | Local_existing _ -> reject on_diagnostic "preconditionConflict" None ;; -let apply_local db (i : I.t) = +let apply_local ?(on_diagnostic = fun _ -> ()) db (i : I.t) = let mutation = local i in - let* receipt = read (D.inspect_local_mutation db mutation) in + let* receipt = local_result on_diagnostic (D.inspect_local_mutation db mutation) in if applied receipt then Ok () else if rejected receipt - then Error U.Invalid_content + then reject on_diagnostic "preconditionConflict" None else let* expected = - snapshot db (fun s -> - let* pages = read (D.get_pages s [ i.target ]) in + snapshot on_diagnostic db (fun s -> + let* pages = read on_diagnostic (D.get_pages s [ i.target ]) in let* blocks, pages = match pages with | [ T.Present_page { revision; _ } ] -> Ok ([], [ i.target, revision ]) | _ -> - let* blocks = read (D.get_outline_blocks s [ i.target ]) in + let* blocks = read on_diagnostic (D.get_outline_blocks s [ i.target ]) in (match blocks with | [ T.Present_block { revision; _ } ] -> Ok ([ i.target, revision ], []) - | _ -> Error U.Invalid_content) + | _ -> reject on_diagnostic "missingPrecondition" None) in let* structure = - read + read on_diagnostic (D.get_structure s (T.Children { parent = i.target; limit = 1; cursor = None })) in match structure with | T.Children_result { revision_scope; scope_revision; _ } -> - read + precondition on_diagnostic (D.write_precondition ~blocks ~pages ~scopes:[ revision_scope, scope_revision ]) - | _ -> Error U.Invalid_content) + | _ -> reject on_diagnostic "invalidContent" None) in - commit db mutation expected + commit on_diagnostic db mutation expected ;; -let apply_metadata db (i : I.t) = - let* observation = inspect db i in +let apply_metadata ?(on_diagnostic = fun _ -> ()) db (i : I.t) = + let* observation = inspect ~on_diagnostic db i in match observation with | U.Metadata_present | Publication_acknowledged -> Ok () | Local_present -> let* expected = - snapshot db (fun s -> - let* blocks = read (D.get_outline_blocks s [ i.asset ]) in + snapshot on_diagnostic db (fun s -> + let* blocks = read on_diagnostic (D.get_outline_blocks s [ i.asset ]) in match blocks with | [ T.Present_block { revision; _ } ] -> - read (D.write_precondition ~blocks:[ i.asset, revision ] ~pages:[] ~scopes:[]) - | _ -> Error U.Invalid_content) + precondition on_diagnostic (D.write_precondition ~blocks:[ i.asset, revision ] ~pages:[] ~scopes:[]) + | _ -> reject on_diagnostic "missingPrecondition" None) in - commit db (metadata i) expected - | _ -> Error U.Invalid_content + commit on_diagnostic db (metadata i) expected + | _ -> reject on_diagnostic "missingPrecondition" None ;; diff --git a/logseq_db_worker/lib/effect_runner/upload_database.mli b/logseq_db_worker/lib/effect_runner/upload_database.mli index 395d1851..f5347df0 100644 --- a/logseq_db_worker/lib/effect_runner/upload_database.mli +++ b/logseq_db_worker/lib/effect_runner/upload_database.mli @@ -1,16 +1,19 @@ val inspect - : Logseq_overlay_db.Database.t + : ?on_diagnostic:(Logseq_db_types.Failure_diagnostic.t -> unit) + -> Logseq_overlay_db.Database.t -> Logseq_db_types.Asset_upload_intent.t -> ( Logseq_db_worker_pure_reducer.Asset_upload.observation , Logseq_db_worker_pure_reducer.Asset_upload.failure ) result val apply_local - : Logseq_overlay_db.Database.t + : ?on_diagnostic:(Logseq_db_types.Failure_diagnostic.t -> unit) + -> Logseq_overlay_db.Database.t -> Logseq_db_types.Asset_upload_intent.t -> (unit, Logseq_db_worker_pure_reducer.Asset_upload.failure) result val apply_metadata - : Logseq_overlay_db.Database.t + : ?on_diagnostic:(Logseq_db_types.Failure_diagnostic.t -> unit) + -> Logseq_overlay_db.Database.t -> Logseq_db_types.Asset_upload_intent.t -> (unit, Logseq_db_worker_pure_reducer.Asset_upload.failure) result diff --git a/logseq_db_worker/lib/logseq_db_worker.ml b/logseq_db_worker/lib/logseq_db_worker.ml index d51f9430..e616ee97 100644 --- a/logseq_db_worker/lib/logseq_db_worker.ml +++ b/logseq_db_worker/lib/logseq_db_worker.ml @@ -191,6 +191,11 @@ let create ~sw ~config ~runner_dependencies = ;; let post t event = route_event t event +let report_asset_diagnostic t ~scope ~operation ~request_operation diagnostic = + if not t.stopped then + Effect_runner.report_asset_diagnostic t.runner ~scope ~operation ~request_operation diagnostic +;; + let view t = Pure_reducer.view !(t.state) let graph_state t = (view t).graph diff --git a/logseq_db_worker/lib/logseq_db_worker.mli b/logseq_db_worker/lib/logseq_db_worker.mli index 4c78ffe0..48daee2a 100644 --- a/logseq_db_worker/lib/logseq_db_worker.mli +++ b/logseq_db_worker/lib/logseq_db_worker.mli @@ -28,6 +28,17 @@ val create -> (t, create_error) result val post : t -> Pure_reducer.event -> unit + +(** Diagnostic-only receipt for an exact active asset operation. Retired or + unknown operations are ignored; the normal completion owns publication. *) +val report_asset_diagnostic + : t + -> scope:Logseq_sync_pure_reducer.Core.graph_scope + -> operation:string + -> request_operation:string + -> Logseq_db_types.Failure_diagnostic.t + -> unit + val view : t -> Pure_reducer.view val graph_state : t -> graph_state diff --git a/logseq_db_worker/lib/pure_reducer/core.ml b/logseq_db_worker/lib/pure_reducer/core.ml index a66a3303..1dbcdde2 100644 --- a/logseq_db_worker/lib/pure_reducer/core.ml +++ b/logseq_db_worker/lib/pure_reducer/core.ml @@ -382,6 +382,32 @@ let execute_completion state ticket result = } ;; +let failure_diagnostic error = + let module D = Logseq_db_types.Failure_diagnostic in + List.find_map + (fun (detail : Logseq_db_worker_contract.Error.detail) -> + match detail.name, detail.value with + | name, Detail_string source when name = D.detail_name -> D.decode source + | _ -> None) + (Logseq_db_worker_contract.Error.details error) +;; + +let failure_signal fallback error = + let module D = Logseq_db_types.Failure_diagnostic in + Option.fold + ~none:fallback + ~some:(fun value -> D.signal_prefix ^ D.encode value) + (failure_diagnostic error) +;; + +let report_sync_failure state transition signal = + (* Sync owns scope acceptance. An ignored completion must not be re-labelled + as an error belonging to whichever graph is now on screen. *) + if Sync.state transition.next.sync_core = Sync.state state.sync_core + then transition + else { transition with effects = Publish (Diagnostic signal) :: transition.effects } +;; + let step state event = if state.shutdown then ( @@ -462,23 +488,22 @@ let step state event = let changed=translate_sync (Sync.step state.sync_core (Sync.Graph_attachment_failed {scope=Sync.effect_scope_of_graph request.scope;message})) state in - {changed with effects=Publish(Diagnostic "worker.inspectMirror")::changed.effects} + report_sync_failure state changed (failure_signal "worker.inspectMirror" error) | Some (Sync.Attach_graph request) -> let changed=translate_sync (Sync.step state.sync_core (Sync.Graph_attachment_failed {scope=Sync.effect_scope_of_graph request.scope;message})) state in - {changed with effects=Publish(Diagnostic "worker.attachGraph")::changed.effects} + report_sync_failure state changed (failure_signal "worker.attachGraph" error) | Some (Sync.Inspect_sync scope) -> let changed=translate_sync (Sync.step state.sync_core (Sync.Graph_attachment_failed {scope=Sync.effect_scope_of_graph scope;message})) state in - {changed with effects=Publish(Diagnostic "worker.inspectSync")::changed.effects} + report_sync_failure state changed (failure_signal "worker.inspectSync" error) | Some (Sync.Activate_snapshot request) -> - translate_sync - (Sync.step - state.sync_core - (Sync.Snapshot_activation_failed (Sync.snapshot_activation_failed request ~message))) - state + let changed=translate_sync + (Sync.step state.sync_core + (Sync.Snapshot_activation_failed (Sync.snapshot_activation_failed request ~message))) state in + report_sync_failure state changed (failure_signal "worker.activateSnapshot" error) | Some (Sync.Apply_authoritative_batch request) -> let transitioned = translate_sync @@ -491,21 +516,19 @@ let step state event = let origin = (Logseq_db_worker_contract.Error.trace error).origin in let signal = match origin.component, origin.operation, origin.code with - | Managed_sync, "applyAuthoritative", Some "authoritativeDecodeFailed" -> + | Managed_sync, "applyAuthoritative", Some ("authoritativeDecodeFailed" | "authoritativeIntegrityFailure" as category) -> let module Diagnostic = Logseq_db_types.Authoritative_diagnostic in let detail = List.find_map (fun (detail:Logseq_db_worker_contract.Error.detail) -> match detail.name,detail.value with | name,Detail_string source when name=Diagnostic.detail_name -> Diagnostic.decode source | _ -> None) (Logseq_db_worker_contract.Error.details error) in - Option.fold ~none:"worker.applyAuthoritative.authoritativeDecodeFailed" - ~some:(fun value -> Diagnostic.signal_prefix ^ Diagnostic.encode value) detail + Option.fold ~none:("worker.applyAuthoritative." ^ category) + ~some:(fun value -> (if category="authoritativeIntegrityFailure" then Diagnostic.integrity_signal_prefix else Diagnostic.signal_prefix) ^ Diagnostic.encode value) detail | Managed_sync, "applyAuthoritative", Some category -> "worker.applyAuthoritative." ^ category | _ -> "worker.applyAuthoritative" in - { next = transitioned.next - ; effects = Publish (Diagnostic signal) :: transitioned.effects - } + report_sync_failure state transitioned (failure_signal signal error) | Some (Sync.Apply_outbox_transition request) -> let transitioned = translate_sync @@ -514,21 +537,15 @@ let step state event = (Sync.Outbox_transition_failed { request; message })) state in - { transitioned with - effects = Publish (Diagnostic "worker.applyOutbox") :: transitioned.effects - } + report_sync_failure state transitioned (failure_signal "worker.applyOutbox" error) | Some (Sync.Detach_graph scope) -> - translate_sync - (Sync.step - state.sync_core - (Sync.Graph_detached (scope, Error "Graph close failed."))) - state + let changed=translate_sync + (Sync.step state.sync_core (Sync.Graph_detached (scope, Error message))) state in + report_sync_failure state changed (failure_signal "worker.closeDatabase" error) | Some (Sync.Delete_mirror request) -> - translate_sync - (Sync.step - state.sync_core - (Sync.Mirror_deleted (request, Error "Mirror deletion failed."))) - state + let changed=translate_sync + (Sync.step state.sync_core (Sync.Mirror_deleted (request, Error message))) state in + report_sync_failure state changed (failure_signal "worker.deleteMirror" error) | Some _ | None -> let code = match worker_effect with | Some (Sync.Inspect_mirror _) -> "worker.inspectMirror" @@ -537,7 +554,7 @@ let step state event = | Some (Sync.Reset_managed_account _) -> "worker.resetAccount" | Some _ | None -> "worker.operation" in - { next = state; effects = [ Publish (Diagnostic code) ] }) + { next = state; effects = [ Publish (Diagnostic (failure_signal code error)) ] }) | Ok result -> let state, lifecycle_effects = match state.graph.phase, worker_effect with @@ -940,10 +957,13 @@ let step state event = restored.next, effects @ restored.effects) (state, []) intents - | Ok _ | Error _ -> + | (Ok _ | Error _) as failure -> + let module D=Logseq_db_types.Failure_diagnostic in + let diagnostic=match failure with + | Ok _ -> D.create ~operation:"restoreUploads" ~reason:"invalidContent" () + | Error source -> D.of_source ~operation:"restoreUploads" ~reason:"persistenceFailed" source in ( { state with recovery = Some (ticket.scope, ticket.after, true) } - , [ Publish (Diagnostic "Upload recovery could not read a valid checkpoint page") - ] )) + , [ Publish (Diagnostic (D.signal_prefix ^ D.encode diagnostic)) ] )) | Sync_event (Sync.Online_recovery_requested _) -> (match asset_scope state with | None -> state, [] 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 6b4ede75..df672d4a 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.ml +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.ml @@ -435,6 +435,17 @@ let production_dependencies () = (* Diagnostic strings are internal signals, never display text. Unknown values receive a fixed fallback so exception/path/content strings cannot escape. *) let diagnostic_report = function + | code when String.starts_with ~prefix:Logseq_db_types.Failure_diagnostic.signal_prefix code -> + let module D=Logseq_db_types.Failure_diagnostic in + let prefix=D.signal_prefix in + (match D.decode(String.sub code (String.length prefix) (String.length code-String.length prefix)) with + | Some detail -> D.operation detail,D.phase detail,D.message detail + | None -> "workerOperation","worker","A Worker operation could not finish. Retry the operation or reopen Journal.") + | code when String.starts_with ~prefix:Logseq_db_types.Authoritative_diagnostic.integrity_signal_prefix code -> + let module D=Logseq_db_types.Authoritative_diagnostic in + let prefix=D.integrity_signal_prefix in + let source=String.sub code (String.length prefix) (String.length code-String.length prefix) in + "applyAuthoritative", "sync", D.message (D.integrity_of_source source) | code when String.starts_with ~prefix:Logseq_db_types.Authoritative_diagnostic.signal_prefix code -> let module Diagnostic = Logseq_db_types.Authoritative_diagnostic in let prefix=Diagnostic.signal_prefix in @@ -498,6 +509,80 @@ let diagnostic_report = function | _ -> "workerOperation", "worker", "A Worker operation could not finish. Retry the operation or reopen Journal." ;; +let diagnostic_code signal operation = + let module D = Logseq_db_types.Failure_diagnostic in + if String.starts_with ~prefix:D.signal_prefix signal + then + Option.fold + ~none:(operation ^ ".failed") + ~some:D.code + (D.decode + (String.sub + signal + (String.length D.signal_prefix) + (String.length signal - String.length D.signal_prefix))) + else if operation = "applyAuthoritative" + then ( + let prefix = "worker.applyAuthoritative." in + let categories = + [ "authoritativeDatabaseClosed" + ; "authoritativeSyncTokenConflict" + ; "authoritativeCursorDiscontinuous" + ; "authoritativeChecksumMismatch" + ; "authoritativeCryptoRequired" + ; "authoritativeCryptoUnexpected" + ; "authoritativeCryptoInvalid" + ; "authoritativeDecodeFailed" + ; "authoritativeOriginUnresolved" + ; "authoritativePreparationConsumed" + ; "authoritativePrepareBusy" + ; "authoritativeIntegrityFailure" + ; "authoritativeCommitDatabaseClosed" + ; "authoritativeCommitTokenConflict" + ; "authoritativeCommitGenerationInvalidated" + ; "authoritativeCommitBusy" + ; "authoritativeCommitPersistenceFailed" + ; "authoritativeCommitFatalState" + ] + in + match + List.find_opt + (fun category -> + signal = prefix ^ category + || String.starts_with ~prefix:(prefix ^ category ^ ":") signal) + categories + with + | Some category -> operation ^ "." ^ category + | None -> operation ^ ".failed") + else operation ^ ".failed" +;; + +(* The service owns this projection; callers retain the original typed notice. *) +let diagnostic_asset_notice notice = + let module D=Logseq_db_types.Failure_diagnostic in + let signal operation reason source = + let detail=match source with + | None->D.create ~operation ~reason () + | Some source->D.of_source ~operation ~reason source in + Some(D.signal_prefix ^ D.encode detail) in + match notice with + | Asset_availability{availability=Failed{failure;retry_scheduled=false;_};_}-> + let reason,source=match failure with + | Logseq_sync_pure_reducer.Asset_transfer.Network -> "networkFailed",None + | Not_found -> "notFound",None | Checksum_mismatch -> "checksumMismatch",None + | Authentication -> "authenticationFailed",None | Locked -> "locked",None + | Storage_full -> "storageFull",None | Invalid_content s -> "invalidContent",Some s in + signal "assetDownload" reason source + | Upload_status{status=Failed_upload failure;_}-> + let reason,source=match failure with + | Logseq_db_worker_pure_reducer.Asset_upload.Network -> "networkFailed",None + | Authentication -> "authenticationFailed",None | Missing_source -> "missingSource",None + | Size_rejected -> "sizeRejected",None | Revoked_access -> "revokedAccess",None + | Invalid_content -> "invalidContent",None | Persistence_failed s -> "persistenceFailed",Some s in + signal "assetUpload" reason source + | _ -> None +;; + let publish context = let latest : state option ref = ref None in let owner = ref None in @@ -519,7 +604,7 @@ let publish context = (Client_state_changed (with_reports state)) in let report code = let operation, phase, message = diagnostic_report code in - let value = Printf.sprintf "%s · phase=%s · code=%s.failed · %s" operation phase operation message in + let value = Printf.sprintf "%s · phase=%s · code=%s · %s" operation phase (diagnostic_code code operation) message in let rec take n = function [] -> [] | _ when n=0 -> [] | x::xs -> x::take (n-1) xs in reports := take 16 ((operation,value)::List.remove_assoc operation !reports); Option.iter emit !latest @@ -529,10 +614,7 @@ let publish context = (match !latest with | Some current when current.snapshot.startup.account_generation=scope.account.account_generation && current.snapshot.startup.graph_generation=scope.graph_generation -> - (match notice with - | Asset_availability {availability=Failed {retry_scheduled=false;_};_} -> report "asset.download" - | Upload_status {status=Failed_upload _;_} -> report "asset.upload" - | _ -> ()) + Option.iter report (diagnostic_asset_notice notice) | _ -> ()); Journal_worker.Session_context.emit context @@ -709,6 +791,10 @@ let create ~(dependencies : dependencies) = ~init:(fun context config -> let sw = Journal_worker.Session_context.switch context in let event_sink = ref (fun (_ : Pure.event) -> ()) in + let asset_diagnostic_sink = + ref (fun ~scope:_ ~operation:_ ~request_operation:_ + (_ : Logseq_db_types.Failure_diagnostic.t) -> ()) in + let authenticated_user = ref None in let token_cache = Worker_runner.id_token_cache @@ -744,7 +830,10 @@ let create ~(dependencies : dependencies) = | Error error -> Error (sync_dependency_error_message error) | Ok runner_dependencies -> (match - Sync_runner.create ~sw runner_dependencies ~post:(fun event -> + Sync_runner.create ~sw runner_dependencies + ~on_asset_diagnostic:(fun ~scope ~operation ~request_operation diagnostic -> + !asset_diagnostic_sink ~scope ~operation ~request_operation diagnostic) + ~post:(fun event -> !event_sink (Pure.Sync_event event)) with | Error error -> Error (sync_create_error_message error) @@ -783,6 +872,7 @@ let create ~(dependencies : dependencies) = | Error (Db.Invalid_create message) -> Error message | Ok worker -> event_sink := Db.post worker; + asset_diagnostic_sink := Db.report_asset_diagnostic worker; let auth_seen = ref false in let auth_sequence = ref 0 and auth_enqueued_sequence = ref 0 in 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 ecd85094..810755d2 100644 --- a/logseq_db_worker/lui/logseq_db_worker_lui_service.mli +++ b/logseq_db_worker/lui/logseq_db_worker_lui_service.mli @@ -298,3 +298,7 @@ val answer_token -> token_request -> (string, string) result -> Journal_worker.control_send_result + +(** Bounded ledger projection of a typed terminal asset failure. Retry/cancel + states return None; the original notice and its delivery remain unchanged. *) +val diagnostic_asset_notice : asset_notice -> string option diff --git a/logseq_db_worker/spec/effect_runner/effect_runner.mli b/logseq_db_worker/spec/effect_runner/effect_runner.mli index 3eda86a4..78661d3e 100644 --- a/logseq_db_worker/spec/effect_runner/effect_runner.mli +++ b/logseq_db_worker/spec/effect_runner/effect_runner.mli @@ -64,6 +64,16 @@ val create val submit : t -> Logseq_db_worker_pure_reducer.Core.instruction -> unit +(** Accept a bounded diagnostic only for its live scoped asset request. The + internal request identifier is used for correlation and is never displayed. *) +val report_asset_diagnostic + : t + -> scope:Logseq_sync_pure_reducer.Core.graph_scope + -> operation:string + -> request_operation:string + -> Logseq_db_types.Failure_diagnostic.t + -> unit + val await_reply : t -> id:Logseq_db_worker_pure_reducer.Core.request_id diff --git a/logseq_db_worker/test/test_effect_runner.ml b/logseq_db_worker/test/test_effect_runner.ml index e699c847..601775ab 100644 --- a/logseq_db_worker/test/test_effect_runner.ml +++ b/logseq_db_worker/test/test_effect_runner.ml @@ -14,7 +14,7 @@ let account ?(generation = 1) user_id : Sync.account_scope = module Sync_runner = Logseq_sync_effect_runner.Effect_runner -let asset_io_runner ~env ~sw ~support ~post = +let asset_io_runner ?on_asset_diagnostic ?(token_failure = "test must not use network") ~env ~sw ~support ~post () = let module R = Sync_runner in let dependencies = R.dependencies @@ -62,11 +62,28 @@ let asset_io_runner ~env ~sw ~support ~post = |> Result.get_ok) ~id_token_provider: (R.id_token_provider - ~acquire:(fun _ -> Error "test must not use network") + ~acquire:(fun _ -> Error token_failure) ~invalidate:(fun _ ~token:_ -> ())) |> Result.get_ok in - R.create ~sw dependencies ~post |> Result.get_ok + R.create ?on_asset_diagnostic ~sw dependencies ~post |> Result.get_ok +;; + +let failure_details outputs = + let module D = Logseq_db_types.Failure_diagnostic in + List.filter_map + (function + | Core.Diagnostic signal when String.starts_with ~prefix:D.signal_prefix signal -> + D.decode (String.sub signal (String.length D.signal_prefix) + (String.length signal - String.length D.signal_prefix)) + | _ -> None) + outputs +;; + +let has_failure_reason outputs operation reason = + let module D = Logseq_db_types.Failure_diagnostic in + List.exists (fun detail -> D.operation detail = operation && D.reason detail = reason) + (failure_details outputs) ;; let test_unlock_runner_logging () = @@ -89,7 +106,7 @@ let test_unlock_runner_logging () = let completions = ref [] in let runner = asset_io_runner ~env ~sw ~support:fixture.config.application_support_directory - ~post:(fun event -> completions := event :: !completions) in + ~post:(fun event -> completions := event :: !completions) () in let limits = Sync.limits ~maximum_response_bytes:1024 ~maximum_artifact_bytes:1024 ~submission_batch_size:1 |> Result.get_ok in let config = Sync.config ~managed_sync_origin:(Uri.of_string "https://example.invalid") @@ -599,10 +616,13 @@ let with_asset_bridge run = io_runner := Some (asset_io_runner + ~on_asset_diagnostic:(fun ~scope ~operation ~request_operation detail -> + Runner.report_asset_diagnostic (Option.get !active_runner) + ~scope ~operation ~request_operation detail) ~env ~sw ~support:fixture.config.application_support_directory - ~post:(fun event -> post (Core.Sync_event event))); + ~post:(fun event -> post (Core.Sync_event event)) ()); let sync_runner = Runner.sync_runner ~submit:(fun runnable -> Sync_runner.submit (Option.get !io_runner) runnable) @@ -851,7 +871,7 @@ let test_db_shutdown_accepts_late_staging_completion () = Db.post (Option.get !db) (Core.Sync_event event) | event -> Db.post (Option.get !db) (Core.Sync_event event) in - io_runner := Some (asset_io_runner ~env ~sw ~support ~post); + io_runner := Some (asset_io_runner ~env ~sw ~support ~post ()); let sync_runner = Runner.sync_runner ~submit:(function @@ -1002,6 +1022,7 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = |> Result.get_ok in let state, instructions = U.step (U.create ~scope ~available:true) (Start intent) in + let active_upload = ref state in let instruction = List.hd instructions in T.with_managed (fun fixture -> Eio_main.run (fun env -> @@ -1021,6 +1042,16 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = in let stage_calls = ref 0 in let active_runner = ref None in + let source_diagnostic ~scope ~operation ~request_operation detail = + let runner = Option.get !active_runner in + Runner.report_asset_diagnostic runner ~scope ~operation ~request_operation detail; + let wrong = Logseq_db_types.Failure_diagnostic.create ~operation ~reason:"unknown" () in + Runner.report_asset_diagnostic runner ~scope:{scope with graph_id=uuid 90} + ~operation ~request_operation wrong; + Runner.report_asset_diagnostic runner ~scope ~operation + ~request_operation:(request_operation ^ "-unrelated") wrong; + Runner.report_asset_diagnostic runner ~scope ~operation:"workerOperation" + ~request_operation wrong in let bridge_requests = ref 0 in let bridge_outputs = ref 0 in let io_runner = ref None in @@ -1053,10 +1084,12 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = io_runner := Some (asset_io_runner + ~on_asset_diagnostic:source_diagnostic + ~token_failure:(Printexc.to_string (Unix.Unix_error (Unix.EACCES,"open","PRIVATE_TOKEN_SOURCE"))) ~env ~sw ~support:fixture.config.application_support_directory - ~post:(fun event -> post (Core.Sync_event event))); + ~post:(fun event -> post (Core.Sync_event event)) ()); let sync_runner = Runner.sync_runner ~submit:(fun runner_effect -> @@ -1086,7 +1119,7 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = dependencies ~post ~recovery_current:(fun _ -> false) - ~upload_current:(fun ticket -> !current && U.ticket_current state ticket) + ~upload_current:(fun ticket -> !current && U.ticket_current !active_upload ticket) ~asset_current:(fun ticket -> !transfer_ticket = Some ticket) |> Result.get_ok in @@ -1276,6 +1309,91 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = "duplicate import never recopies picker source" 1 !stage_calls; + (* A real changed staged file fails in the sync interpreter before PUT; + the Worker must keep that reason while retaining Invalid_content. *) + let uploading = I.advance prepared Local_committed |> Result.get_ok + |> fun i -> I.advance i Uploading |> Result.get_ok in + let upload_state, observe = U.step (U.create ~scope ~available:true) (Restore uploading) in + let observe = match observe with [U.Inspect (ticket, _)] -> ticket + | _ -> Alcotest.fail "upload restore must inspect" in + let upload_state, put = U.step upload_state (Completed (observe, Inspected Local_present)) in + let put = match put with [U.Put _ as instruction] -> instruction + | _ -> Alcotest.fail "upload restore must resume PUT" in + active_upload := upload_state; + current := true; + Runner.submit runner (Core.Publish (Core.Sync_output (Sync.State_changed (Sync.state !sync_state)))); + published := []; posted := []; + let original_path = staged_path prepared.staged_file in + Out_channel.with_open_bin original_path (fun output -> output_string output "changed fixture bytes"); + Fun.protect + ~finally:(fun () -> Out_channel.with_open_bin original_path (fun output -> output_string output "file")) + (fun () -> + Runner.submit runner (Core.Run_upload (context, put)); + Alcotest.(check bool) "PUT failure keeps original result category" true + (List.exists (function Core.Upload_completed (_, U.Failed U.Invalid_content) -> true | _ -> false) !posted); + if not closed_stderr then Alcotest.(check bool) "failure detail waits for its owner notice" false + (List.exists (fun detail -> Logseq_db_types.Failure_diagnostic.operation detail = "assetUpload") + (failure_details !published)); + Runner.submit runner (Core.Publish (Core.Asset_notice (scope, Core.Upload_status + {operation=prepared.operation_id;asset=prepared.asset;target=prepared.target;title=prepared.title; + status=U.Failed_upload U.Invalid_content}))); + if not closed_stderr then Alcotest.(check bool) "PUT failure has a safe scoped reason" true + (List.exists (fun detail -> Logseq_db_types.Failure_diagnostic.operation detail = "assetUpload") + (failure_details !published))); + let put_ticket = match put with U.Put (ticket, _) -> ticket | _ -> assert false in + let retry_state, _ = U.step upload_state (Completed (put_ticket, Failed Invalid_content)) in + let retry_state, retried = U.step retry_state Retry in + let retried = match retried with [U.Put _ as instruction] -> instruction + | _ -> Alcotest.fail "PUT retry must remain PUT" in + active_upload := retry_state; + published := []; posted := []; + Runner.submit runner (Core.Run_upload (context, retried)); + Alcotest.(check bool) "token failure keeps authentication result" true + (List.exists (function Core.Upload_completed (_, U.Failed U.Authentication) -> true | _ -> false) !posted); + Runner.submit runner (Core.Publish (Core.Asset_notice (scope, Core.Upload_status + {operation=prepared.operation_id;asset=prepared.asset;target=prepared.target;title=prepared.title; + status=U.Failed_upload U.Authentication}))); + Alcotest.(check bool) "exact active request keeps safe token source class" true + (List.exists (fun detail -> Logseq_db_types.Failure_diagnostic.operation detail="assetUpload" + && Logseq_db_types.Failure_diagnostic.source_class detail="io-permission") (failure_details !published)); + (* Real transfer policy fans a terminal failure out to every consumer. + Every notice must be followed by the same current safe cause. *) + let remote_asset = uuid 71 in + let remote_version = Logseq_db_types.Asset_descriptor.version + ~checksum:(String.make 64 'b') ~file_type:"png" |> Result.get_ok in + let descriptor = Logseq_db_types.Asset_descriptor.create ~uuid:remote_asset + ~source:(Managed (Some remote_version)) ~current_checksum:None ~size:None ~dimensions:None + |> Result.get_ok in + let transfer = Transfer.create + (Transfer.config ~active:2 ~foreground_reserved:1 ~pending:3 ~retries:0 |> Result.get_ok) + ~scope ~online:true ~unlocked:true in + let transfer, work = Transfer.step transfer (Replace {consumer="first-view";priority=Foreground;assets=[descriptor]}) in + let transfer, _ = Transfer.step transfer (Replace {consumer="second-view";priority=Foreground;assets=[descriptor]}) in + let remote_lookup = List.find_map (function Transfer.Check_cache ticket -> Some ticket | _ -> None) work |> Option.get in + transfer_ticket := Some (Transfer.Lookup remote_lookup); posted := []; + Runner.submit runner (Core.Run_asset (context, Transfer.Check_cache remote_lookup)); + let completion = List.find_map (function Core.Asset_completed (_, event) -> Some event | _ -> None) !posted |> Option.get in + let transfer, work = Transfer.step transfer completion in + let fetch = List.find_map (function Transfer.Fetch ticket -> Some ticket | _ -> None) work |> Option.get in + transfer_ticket := Some (Transfer.Download fetch); posted := []; + Runner.submit runner (Core.Run_asset (context, Transfer.Fetch fetch)); + let completion = List.find_map (function Core.Asset_completed (_, event) -> Some event | _ -> None) !posted |> Option.get in + let _, work = Transfer.step transfer completion in + let failures = List.filter_map (function + | Transfer.Notify {consumer;asset;availability=Failed _ as availability} -> + Some (Core.Asset_availability {consumer;asset;availability}) + | _ -> None) work in + Alcotest.(check int) "real terminal failure notifies both consumers" 2 (List.length failures); + List.iter (fun notice -> + published := []; + Runner.submit runner (Core.Publish (Core.Asset_notice (scope, notice))); + let latest = match !published with output::_ -> failure_details [output] | [] -> [] in + Alcotest.(check bool) "each failed consumer notice keeps its exact safe cause last" true + (List.exists (fun detail -> Logseq_db_types.Failure_diagnostic.operation detail="assetDownload" + && Logseq_db_types.Failure_diagnostic.source_class detail="io-permission") latest)) failures; + transfer_ticket := None; + active_upload := state; + current := false; Alcotest.(check bool) "changed identity payload is rejected" true @@ -1368,10 +1486,13 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = io_runner := Some (asset_io_runner + ~on_asset_diagnostic:(fun ~scope ~operation ~request_operation detail -> + Runner.report_asset_diagnostic (Option.get !active_runner) + ~scope ~operation ~request_operation detail) ~env ~sw ~support:fixture.config.application_support_directory - ~post:(fun event -> post (Core.Sync_event event))); + ~post:(fun event -> post (Core.Sync_event event)) ()); let restarted = Runner.create ~sw @@ -1413,6 +1534,25 @@ let test_upload_checkpoint_io ?(closed_stderr = false) () = !bridge_requests !bridge_outputs; Alcotest.(check bool) "asset bridge exercised" true (!bridge_requests > 0); + (* Real malformed private checkpoint schema: optional preview stays None + while the current graph receives only a safe storage class. *) + let db = Sqlite3.db_open checkpoint_path in + Sqlite3.Rc.check (Sqlite3.exec db + "DROP TABLE asset_upload_intents; CREATE TABLE asset_upload_intents (PRIVATE_COLUMN TEXT)"); + ignore (Sqlite3.db_close db); + Runner.submit restarted (Core.Publish (Core.Sync_output (Sync.State_changed (Sync.state !sync_state)))); + published := []; + Alcotest.(check bool) "bad metadata keeps optional preview unavailable" true + (Runner.retain_imported_file restarted ~scope ~operation:source.operation = None); + Alcotest.(check bool) "bad metadata keeps its storage reason" true + (List.exists (fun detail -> + Logseq_db_types.Failure_diagnostic.operation detail = "assetMetadata" + && Logseq_db_types.Failure_diagnostic.source_class detail = "sqlite-schema") + (failure_details !published)); + List.iter (fun detail -> + Alcotest.(check bool) "metadata diagnostic excludes raw column/path" false + (let encoded = Logseq_db_types.Failure_diagnostic.encode detail in + String.contains encoded '/' || String.contains encoded 'P')) (failure_details !published); Runner.shutdown restarted))) ;; @@ -1590,6 +1730,8 @@ let deletion_runtime_fixture ?deletion_case ?(journal_failure = false) Alcotest.(check int) "journal failure never retires assets" 0 !destructive_requests; Alcotest.(check bool) "journal failure preserves target mirror" true (Sys.file_exists first_database_path); Alcotest.(check bool) "current graph Worker stays open" true ((Db.graph_state db).phase = Db.Graph_open); + Alcotest.(check bool) "journal IO failure has a safe deletion reason" true + (has_failure_reason !published "deleteMirror" "deleteFailed"); post (Sync.Catalog_refresh_requested (let f=(Db.view db).sync.snapshot.startup in {account_generation=f.account_generation;presentation_generation=f.presentation_generation})); @@ -1601,6 +1743,8 @@ let deletion_runtime_fixture ?deletion_case ?(journal_failure = false) Alcotest.(check bool) "busy mirror reports failure" true (Result.is_error (Db.delete_local_copy db ~account_generation:generation ~graph_id:first)); Alcotest.(check int) "busy mirror never starts destructive asset work" 0 !destructive_requests; + Alcotest.(check bool) "busy mirror retains safe ownership reason" true + (has_failure_reason !published "deleteMirror" "busy"); Alcotest.(check bool) "busy mirror remains usable" true (Sys.file_exists first_database_path))) else ( let result, resolve = Eio.Promise.create () in @@ -1768,6 +1912,121 @@ let test_authoritative_rejection_diagnostics kind () = |> Result.get_ok; Logseq_db_storage.Sync_outbox_store.initialize_database sqlite |> Result.get_ok; ignore (Sqlite3.db_close sqlite); + (match kind with + | `Integrity -> + let module Storage = Logseq_db_storage.Logseq_sqlite_storage in + let module Session = Logseq_db_storage.Storage_session in + let storage = + Storage.open_database (Filename.concat directory "db.sqlite") + |> Result.get_ok + in + let db = Storage.restore_database storage |> Result.get_ok in + let session = + Session.create + ~tail: + (Datascript.Storage.restore_tail_groups + (Storage.datascript_storage storage)) + ~callbacks:(Storage.connection_callbacks storage) + in + let block = Datascript.Temp_id "diagnostic-synthetic-block" in + let page = + Datascript.Lookup_ref + ("block/uuid", Datascript.Uuid "11111111-1111-4111-8111-111111111111") + in + let staged = + Session.stage_transact + session + ~authoritative_before:db + [ Datascript.Add + (block, "block/uuid", Uuid "22222222-2222-4222-8222-222222222224") + ; Add (block, "block/title", String "Synthetic initial block") + ; Add (block, "block/parent", Ref_to page) + ; Add (block, "block/page", Ref_to page) + ; Add (block, "block/order", String "a00000001") + ; Add (block, "block/created-at", Int64 1700000000000L) + ; Add (block, "block/updated-at", Int64 1700000000000L) + ] + |> Result.get_ok + in + Session.commit_staged session staged |> Result.get_ok; + Session.close session |> Result.get_ok; + let module O = Logseq_overlay_db.Types in + let module DB = Logseq_overlay_db.Database in + let inspection = + DB.inspect_mirror ~application_support_directory:root ~graph_id + |> Result.get_ok + in + let database = + DB.open_ ~sw fixture.overlay inspection ~graph_name:graph.name + |> Result.get_ok + in + Fun.protect + ~finally:(fun () -> ignore (DB.close database)) + (fun () -> + let block = + G.Uuid.of_string "22222222-2222-4222-8222-222222222224" |> Result.get_ok + in + let mutation_id = + G.Uuid.of_string "94000000-0000-4000-8000-000000000709" |> Result.get_ok + in + let snapshot = DB.current_snapshot database |> Result.get_ok in + let revision = + match DB.get_outline_blocks snapshot [ block ] |> Result.get_ok with + | [ O.Present_block { revision; _ } ] -> revision + | _ -> Alcotest.fail "synthetic block missing" + in + DB.release_snapshot snapshot; + let expected = + DB.write_precondition ~blocks:[ block, revision ] ~pages:[] ~scopes:[] + |> Result.get_ok + in + ignore + (DB.commit_local + database + ~expected + (O.Save_block + { mutation_id; block; title = "PRIVATE_SYNTHETIC_PAYLOAD" }) + |> Result.get_ok); + let sync = DB.inspect_sync database |> Result.get_ok in + let prepared, crypto = + DB.begin_outbox_transition + database + ~expected:(O.sync_view_token sync) + (O.Submit_group [ mutation_id ]) + |> Result.get_ok + in + let crypto = Option.get crypto in + let encrypted = + DB.protection_plaintexts crypto + |> List.map (fun (id, plain) -> id, "synthetic:" ^ plain) + in + let commit = + DB.apply_outbox_transition + database + prepared + ~encrypted:(Some (crypto, encrypted)) + |> Result.get_ok + in + let batch = Option.get commit.submission_batch in + let sync = DB.inspect_sync database |> Result.get_ok in + let barrier = + O. + { through = cursor 1L + ; checksum = + Checksum.of_string "checksum:v1:0123456789abcdef" |> Result.get_ok + } + in + let prepared, _ = + DB.begin_outbox_transition + database + ~expected:(O.sync_view_token sync) + (O.Accept_group { batch_id = O.submission_batch_id batch; barrier }) + |> Result.get_ok + in + ignore + (DB.apply_outbox_transition database prepared ~encrypted:None + |> Result.get_ok)) + | _ -> ()); let limits = Sync.limits ~maximum_response_bytes: @@ -1837,7 +2096,13 @@ let test_authoritative_rejection_diagnostics kind () = | _ -> ()); let transition = Core.step !state event in state := transition.next; - List.iter (Runner.submit runner) transition.effects + List.iter + (fun instruction -> + (match instruction with + | Core.Publish (Core.Reply _ as reply) -> emitted := reply :: !emitted + | _ -> ()); + Runner.submit runner instruction) + transition.effects done in Fun.protect @@ -1859,6 +2124,24 @@ let test_authoritative_rejection_diagnostics kind () = drive (Core.Sync_event (Sync.Websocket_opened connection)); let t, payload, code, category, message, checks = match kind with + | `Integrity -> + ( 1L + , "[]" + , Error.Ambiguous_sync_state + , "authoritativeIntegrityFailure" + , None + , [ "reason=payload-mismatch" + ; "checkpoint=0" + ; "through=1" + ; "expectedCursor=1" + ; "tBefore=0" + ; "ordinal=0" + ; "count=1" + ; "outboxState=accepted" + ; "mutation=save-block" + ; "expectedDatoms=" + ; "actualDatoms=" + ] ) | `Cursor -> ( 2L , "[[\"~:db/retractEntity\",99999]]" @@ -1905,121 +2188,212 @@ let test_authoritative_rejection_diagnostics kind () = (Core.Sync_event (Sync.Websocket_message (connection, Pull_ok { t = cursor t; checksum = None; txs }))); - let empty_success=match kind with `Detail (("empty"|"second-transaction"),_,_) -> true | _ -> false in - if empty_success then ( - Alcotest.(check bool) "legal empty authoritative batch succeeds" true (!failures=[]); - Alcotest.(check bool) "empty cursor reaches actual manager" true - ((Core.view !state).sync.snapshot.applied_server_t=Some(cursor t)); - Alcotest.(check (option string)) "successful empty pull has no Last error" None - (Core.view !state).sync.snapshot.last_error; - Alcotest.(check bool) "successful empty pull keeps graph open" true - ((Core.view !state).graph.phase=Graph_open)) else ( - let error = - match !failures with - | [ error ] -> error - | _ -> Alcotest.fail "expected actual authoritative failure" + let empty_success = + match kind with + | `Detail (("empty" | "second-transaction"), _, _) -> true + | _ -> false in - Alcotest.(check bool) - "failure code reflects actual category" - true - (Error.code error = code); - let message_text = Error.message error in - let includes text needle = - let rec loop i = - i + String.length needle <= String.length text - && (String.sub text i (String.length needle) = needle || loop (i + 1)) + if empty_success + then ( + Alcotest.(check bool) + "legal empty authoritative batch succeeds" + true + (!failures = []); + Alcotest.(check bool) + "empty cursor reaches actual manager" + true + ((Core.view !state).sync.snapshot.applied_server_t = Some (cursor t)); + Alcotest.(check (option string)) + "successful empty pull has no Last error" + None + (Core.view !state).sync.snapshot.last_error; + Alcotest.(check bool) + "successful empty pull keeps graph open" + true + ((Core.view !state).graph.phase = Graph_open)) + else ( + let error = + match !failures with + | [ error ] -> error + | _ -> Alcotest.fail "expected actual authoritative failure" in - loop 0 - in - Option.iter - (fun expected -> - Alcotest.(check string) - "producer retains a fixed safe reason" - expected - message_text) - message; - List.iter - (fun expected -> - let absent = String.starts_with ~prefix:"!" expected in - let part = - if absent - then String.sub expected 1 (String.length expected - 1) - else expected - in - Alcotest.(check bool) - ("safe detail " ^ expected) - (not absent) - (includes message_text part)) - checks; - Alcotest.(check (option string)) - "typed origin retains category" - (Some category) - (Error.trace error).origin.code; - Alcotest.(check (option string)) - "actual Last error retains category" - (Some message_text) - (Core.view !state).sync.snapshot.last_error; - Alcotest.(check bool) - "actual Worker diagnostic retains category" - true - (List.exists - (function - | Core.Diagnostic signal -> - String.starts_with - ~prefix:("worker.applyAuthoritative." ^ category) - signal - | _ -> false) - !emitted); - if checks <> [] - then Alcotest.(check bool) - "validated detail travels in actual diagnostic signal" + "failure code reflects actual category" + true + (Error.code error = code); + let message_text = Error.message error in + let includes text needle = + let rec loop i = + i + String.length needle <= String.length text + && (String.sub text i (String.length needle) = needle || loop (i + 1)) + in + loop 0 + in + Option.iter + (fun expected -> + Alcotest.(check string) + "producer retains a fixed safe reason" + expected + message_text) + message; + List.iter + (fun expected -> + let absent = String.starts_with ~prefix:"!" expected in + let part = + if absent + then String.sub expected 1 (String.length expected - 1) + else expected + in + Alcotest.(check bool) + ("safe detail " ^ expected) + (not absent) + (includes message_text part)) + checks; + Alcotest.(check (option string)) + "typed origin retains category" + (Some category) + (Error.trace error).origin.code; + Alcotest.(check (option string)) + "actual Last error retains category" + (Some message_text) + (Core.view !state).sync.snapshot.last_error; + Alcotest.(check bool) + "actual Worker diagnostic retains category" true (List.exists (function | Core.Diagnostic signal -> - includes signal "authoritative-decode:v1:" + String.starts_with + ~prefix:("worker.applyAuthoritative." ^ category) + signal | _ -> false) !emitted); - Alcotest.(check bool) - "failure precedes checkpoint advancement" - true - ((Core.view !state).sync.snapshot.applied_server_t = Some (cursor 0L)); - let encoded = - Yojson.Safe.to_string (Error.to_yojson error) - ^ "\n" - ^ String.concat - "\n" - (List.filter_map + if checks <> [] + then + Alcotest.(check bool) + "validated detail travels in actual diagnostic signal" + true + (List.exists (function - | Core.Diagnostic signal -> Some signal + | Core.Diagnostic signal -> + includes + signal + (match kind with + | `Integrity -> "authoritative-integrity:v1:" + | _ -> "authoritative-decode:v1:") + | _ -> false) + !emitted); + Alcotest.(check bool) + "failure precedes checkpoint advancement" + true + ((Core.view !state).sync.snapshot.applied_server_t = Some (cursor 0L)); + (match kind with + | `Integrity -> + let module P = Logseq_db_worker.Protocol in + drive + (Core.Graph_request + { id = Core.request_id_of_int64 3L + ; request = + { P.api_version = P.api_version + ; request_id = + G.Uuid.of_string "94000000-0000-4000-8000-000000000710" + |> Result.get_ok + ; command = P.V2_inspect_admission + } + }); + let active = + List.find_map + (function + | Core.Reply + ( _ + , P.V2_response + { outcome = V2_admission_outcome { active_records; _ } + ; _ + } ) -> Some active_records | _ -> None) - !emitted) - in - let contains needle = - let rec loop i = - i + String.length needle <= String.length encoded - && (String.sub encoded i (String.length needle) = needle || loop (i + 1)) + !emitted + |> Option.get + in + Alcotest.(check int) "failed validation retains durable outbox" 1 active; + Option.iter + (fun path -> + let signal = + List.find_map + (function + | Core.Diagnostic s + when String.starts_with + ~prefix: + "worker.applyAuthoritative.authoritativeIntegrityFailure:" + s -> Some s + | _ -> None) + !emitted + |> Option.get + in + let groups = + (Core.view !state).sync.diagnostics.groups + |> List.filter_map (fun (g : Sync.diagnostic_group) -> + if g.title = "Sync transactions" + then + Some + (`Assoc + [ "title", `String g.title + ; ( "entries" + , `List + (List.map + (fun (k, v) -> `List [ `String k; `String v ]) + g.entries) ) + ]) + else None) + in + Yojson.Safe.to_file + path + (`Assoc + [ "lastError", `String message_text + ; "signal", `String signal + ; "outboxRecords", `Int active + ; "groups", `List groups + ])) + (Sys.getenv_opt "JOURNAL_DIAGNOSTIC_FIXTURE_OUTPUT") + | _ -> ()); + let encoded = + Yojson.Safe.to_string (Error.to_yojson error) + ^ "\n" + ^ String.concat + "\n" + (List.filter_map + (function + | Core.Diagnostic signal -> Some signal + | _ -> None) + !emitted) in - loop 0 - in - List.iter - (fun secret -> - Alcotest.(check bool) "source content excluded" false (contains secret)) - ([ root - ; "PRIVATE_SYNTHETIC_PAYLOAD" - ; "99999999-9999-4999-8999-999999999999" - ; "Nothing found" - ; "Cannot store nil" - ] - @ if String.length payload > 30 then [ payload ] else [])))))) + let contains needle = + let rec loop i = + i + String.length needle <= String.length encoded + && (String.sub encoded i (String.length needle) = needle || loop (i + 1) + ) + in + loop 0 + in + List.iter + (fun secret -> + Alcotest.(check bool) + "source content excluded" + false + (contains secret)) + ([ root + ; "PRIVATE_SYNTHETIC_PAYLOAD" + ; "99999999-9999-4999-8999-999999999999" + ; "Nothing found" + ; "Cannot store nil" + ] + @ if String.length payload > 30 then [ payload ] else [])))))) ;; - let () = Alcotest.run "logseq db worker effect runner" [ ( "authoritative diagnostics", [ + Alcotest.test_case "real own echo mismatch retains safe integrity reason" `Quick (test_authoritative_rejection_diagnostics `Integrity); Alcotest.test_case "real cursor rejection reaches Diagnostics" `Quick (test_authoritative_rejection_diagnostics `Cursor); Alcotest.test_case "real format rejection reaches Diagnostics" `Quick (test_authoritative_rejection_diagnostics `Format); Alcotest.test_case "apply format failure excludes raw source" `Quick (test_authoritative_rejection_diagnostics `Apply_format) ] @ diff --git a/logseq_db_worker/test/test_lui_service.ml b/logseq_db_worker/test/test_lui_service.ml index 6031b1a3..6f64cc20 100644 --- a/logseq_db_worker/test/test_lui_service.ml +++ b/logseq_db_worker/test/test_lui_service.ml @@ -1372,6 +1372,112 @@ let test_authoritative_detail_rows () = T.require (not (contains legacy "PRIVATE_SYNTHETIC_PAYLOAD")) "legacy raw string leaked" ;; +(* Integrity text originates below the pure reducer; this public publisher boundary + owns validating and displaying its bounded carrier. *) +let test_asset_failure_rows () = + let module A=Logseq_sync_pure_reducer.Asset_transfer in + let module U=Logseq_db_worker_pure_reducer.Asset_upload in + let module D=Logseq_db_types.Failure_diagnostic in + let uuid=Logseq_db_types.Graph_types.Uuid.of_string "94000000-0000-4000-8000-000000000799" |> Result.get_ok in + let download ?(retry=false) failure = Service.Asset_availability + {consumer="PRIVATE_SYNTHETIC_PAYLOAD";asset=uuid; + availability=A.Failed{failure;attempts=2;retry_scheduled=retry}} in + let upload failure = Service.Upload_status{operation=uuid;asset=uuid;target=uuid; + title="PRIVATE_SYNTHETIC_PAYLOAD";status=U.Failed_upload failure} in + let cases=List.map(fun(failure,reason)->download failure,"assetDownload",reason) + A.[Network,"networkFailed";Not_found,"notFound";Checksum_mismatch,"checksumMismatch"; + Authentication,"authenticationFailed";Locked,"locked";Storage_full,"storageFull"; + Invalid_content "Failure(\"PRIVATE_SYNTHETIC_PAYLOAD\")","invalidContent"] + @ List.map(fun(failure,reason)->upload failure,"assetUpload",reason) + U.[Network,"networkFailed";Authentication,"authenticationFailed";Missing_source,"missingSource"; + Size_rejected,"sizeRejected";Revoked_access,"revokedAccess";Invalid_content,"invalidContent"; + Persistence_failed "Sys_error(\"/private/PRIVATE_SYNTHETIC_PAYLOAD\")","persistenceFailed"] in + List.iter(fun(notice,operation,reason)-> + let signal=Service.diagnostic_asset_notice notice |> Option.get in + let prefix=D.signal_prefix in + T.require(String.starts_with ~prefix signal) "asset typed failure was erased"; + let detail=D.decode(String.sub signal (String.length prefix) (String.length signal-String.length prefix)) |> Option.get in + T.require(D.operation detail=operation && D.reason detail=reason) "asset cause classification changed"; + let _,_,message=Service.diagnostic_report signal in + T.require(message=D.message detail) "asset failure did not reach ledger"; + T.require(not(String.contains message '/')) "asset source path leaked") cases; + T.require(Service.diagnostic_asset_notice(download ~retry:true A.Network)=None) "retry should stay quiet"; + T.require(Service.diagnostic_asset_notice(Service.Asset_capacity_available)=None) "capacity is not an error" +;; + +let test_safe_worker_failure_rows () = + let module D = Logseq_db_types.Failure_diagnostic in + let diagnostic = + D.of_source + ~operation:"applyOutbox" + ~reason:"transitionInvalid" + "covered acceptance has no durable origin evidence" + in + let op, phase, message = + Service.diagnostic_report (D.signal_prefix ^ D.encode diagnostic) + in + T.require (op = "applyOutbox" && phase = "sync") "worker detail retained its operation"; + T.require (message = D.message diagnostic) "worker detail was discarded"; + let _, _, unsafe = + Service.diagnostic_report + (D.signal_prefix ^ {|worker-failure:v1:{"operation":"PRIVATE_SYNTHETIC_PAYLOAD"}|}) + in + T.require (unsafe <> "PRIVATE_SYNTHETIC_PAYLOAD") "unknown worker detail leaked" +;; + +let test_authoritative_integrity_rows () = + let prefix = "worker.applyAuthoritative.authoritativeIntegrityFailure:" in + let carrier = "authoritative-integrity:v1:" in + let source = + carrier + ^ {|{"stage":"integrity","reason":"payload-mismatch","checkpoint":"9223372036854775806","through":"9223372036854775807","expectedCursor":"9223372036854775807","tBefore":"9223372036854775806","ordinal":0,"count":1,"expectedDatoms":9,"actualDatoms":8,"outboxState":"accepted","mutation":"insert-blocks"}|} + in + let _, phase, message = Service.diagnostic_report (prefix ^ source) in + let contains text needle = + let rec loop i = + i + String.length needle <= String.length text + && (String.sub text i (String.length needle) = needle || loop (i + 1)) + in + loop 0 + in + T.require (phase = "sync") "integrity retains sync phase"; + List.iter + (fun part -> T.require (contains message part) "integrity row omitted %s" part) + [ "reason=payload-mismatch" + ; "checkpoint=9223372036854775806" + ; "expectedCursor=9223372036854775807" + ; "tBefore=9223372036854775806" + ; "expectedDatoms=9" + ; "actualDatoms=8" + ; "outboxState=accepted" + ; "mutation=insert-blocks" + ]; + List.iter + (fun bad -> + let _, _, message = Service.diagnostic_report (prefix ^ carrier ^ bad) in + T.require + (not (contains message "PRIVATE_SYNTHETIC_PAYLOAD")) + "integrity leaked source"; + T.require + (not (contains message "reason=payload-mismatch")) + "invalid integrity partly accepted") + [ {|{"stage":"integrity","reason":"payload-mismatch","raw":"PRIVATE_SYNTHETIC_PAYLOAD"}|} + ; {|{"stage":"integrity","reason":"payload-mismatch","checkpoint":"-1"}|} + ; {|{"stage":"integrity","reason":"payload-mismatch","checkpoint":"9223372036854775808"}|} + ; {|{"stage":"integrity","reason":"payload-mismatch","checkpoint":1}|} + ; {|{"stage":"integrity","reason":"payload-mismatch","mutation":"PRIVATE_SYNTHETIC_PAYLOAD"}|} + ; {|{"stage":"decode","reason":"payload-mismatch"}|} + ; {|{"stage":"integrity","reason":"entity-shape"}|} + ; {|{"stage":"integrity","reason":"payload-mismatch","count":-1}|} + ; {|{"stage":"integrity","reason":"payload-mismatch","reason":"unknown"}|} + ; String.make 2048 'x' + ]; + let _, _, legacy = Service.diagnostic_report (prefix ^ "PRIVATE_SYNTHETIC_PAYLOAD") in + T.require + (not (contains legacy "PRIVATE_SYNTHETIC_PAYLOAD")) + "raw integrity source leaked" +;; + let test_authoritative_detail_budget () = let module D = Logseq_db_types.Authoritative_diagnostic in let detail = @@ -1406,7 +1512,10 @@ let test_authoritative_detail_budget () = let () = T.run "lui worker service" - [ T.case "authoritative detail byte budget" test_authoritative_detail_budget + [ T.case "typed asset failures reach ledger" test_asset_failure_rows + ; T.case "safe worker failure rows" test_safe_worker_failure_rows + ; T.case "authoritative integrity rows" test_authoritative_integrity_rows + ; T.case "authoritative detail byte budget" test_authoritative_detail_budget ; T.case "strict authoritative detail rows" test_authoritative_detail_rows ; T.case "authoritative category row allowlist" test_authoritative_diagnostic_rows ; T.case "ignored restore failure reaches scoped safe Diagnostics" test_restore_error_reaches_diagnostics diff --git a/logseq_db_worker/test/test_pure_reducer.ml b/logseq_db_worker/test/test_pure_reducer.ml index cd7194c4..048da7eb 100644 --- a/logseq_db_worker/test/test_pure_reducer.ml +++ b/logseq_db_worker/test/test_pure_reducer.ml @@ -957,13 +957,29 @@ let test_upload_recovery_invalid_page () = (function | Core.Publish (Diagnostic _) -> true | _ -> false) - result.effects) + result.effects); + let module D=Logseq_db_types.Failure_diagnostic in + let diagnostic=List.find_map(function + | Core.Publish(Diagnostic signal) when String.starts_with ~prefix:D.signal_prefix signal -> + D.decode(String.sub signal (String.length D.signal_prefix) (String.length signal-String.length D.signal_prefix)) + | _->None) result.effects in + Alcotest.(check (option string)) "invalid recovery page keeps its own reason" (Some "invalidContent") + (Option.map D.reason diagnostic) ;; let test_upload_recovery_failure () = let opened, _ = worker_open_graph_transition () in let ticket = Option.get (recovery_instruction opened) in - let failed = Core.step opened.next (Uploads_loaded (ticket, Error "unavailable")) in + let failed = Core.step opened.next (Uploads_loaded (ticket, Error "Failure(\"PRIVATE_SYNTHETIC_PAYLOAD\")")) in + let module D=Logseq_db_types.Failure_diagnostic in + let diagnostic=List.find_map(function + | Core.Publish(Diagnostic signal) when String.starts_with ~prefix:D.signal_prefix signal -> + D.decode(String.sub signal (String.length D.signal_prefix) (String.length signal-String.length D.signal_prefix)) + | _->None) failed.effects in + Alcotest.(check (option string)) "recovery backend failure keeps a separate reason" (Some "persistenceFailed") + (Option.map D.reason diagnostic); + Alcotest.(check (option string)) "backend exception class retained without arguments" (Some "exception-failure") + (Option.map D.source_class diagnostic); Alcotest.(check bool) "recovery failure observable" true @@ -1121,7 +1137,13 @@ let test_local_restore_worker_failures_are_terminal () = Alcotest.(check bool) "worker IO failure releases startup loading" true (not f.restoring_local && f.failure=Some Sync.During_local_restore); let duplicate = Core.step failed.next (Runner_completed (Sync_worker_effect_completed(ticket,Error error))) in - Alcotest.(check int) "duplicate failure is inert" 0 (List.length duplicate.effects) + Alcotest.(check int) "duplicate failure is inert" 0 (List.length duplicate.effects); + let retired=Core.step flight.next (Sync_event Sync.Graph_picker_requested) in + let late=Core.step retired.next (Runner_completed(Sync_worker_effect_completed(ticket,Error error))) in + Alcotest.(check bool) "retired local restore keeps sync state" true + ((Core.view late.next).sync=(Core.view retired.next).sync); + Alcotest.(check bool) "retired local restore cannot report into another graph" false + (List.exists(function Core.Publish(Diagnostic _)->true | _->false)late.effects) ) [false;true] ;; @@ -1156,7 +1178,13 @@ let test_authoritative_failure_categories_reach_diagnostics () = Alcotest.(check bool) "local graph remains open" true ((Core.view failed.next).graph.phase=Graph_open && graph.graph_generation=(Core.view failed.next).graph.generation); Alcotest.(check int) "duplicate completion is inert" 0 - (List.length (Core.step failed.next failure).effects)) + (List.length (Core.step failed.next failure).effects); + let retired=Core.step pending.next (Sync_event Sync.Graph_picker_requested) in + let late=Core.step retired.next failure in + Alcotest.(check bool) "retired authoritative completion keeps sync state" true + ((Core.view late.next).sync=(Core.view retired.next).sync); + Alcotest.(check bool) "retired authoritative completion cannot report into another graph" false + (List.exists(function Core.Publish(Diagnostic _)->true | _->false)late.effects)) [ Error.Closed_session, "authoritativeDatabaseClosed", "Authoritative database is closed." ; Conflict, "authoritativeSyncTokenConflict", "Authoritative sync version changed." ; Ambiguous_sync_state, "authoritativeCursorDiscontinuous", "Authoritative transaction cursors are not continuous." diff --git a/logseq_overlay_db/lib/database.ml b/logseq_overlay_db/lib/database.ml index dc9d9570..1f28ab1a 100644 --- a/logseq_overlay_db/lib/database.ml +++ b/logseq_overlay_db/lib/database.ml @@ -456,7 +456,7 @@ let prepare_snapshot_activation else ( let root = Filename.dirname active_directory in if not (ensure_directory root) - then Error Types.Snapshot_state_error + then Error (Types.Snapshot_state_error Types.Snapshot_root_unavailable) else ( match Mirror.stage @@ -514,9 +514,9 @@ let next_snapshot_unprotection_batch preparation = if preparation.snapshot_canceled then Error Types.Snapshot_preparation_canceled else if preparation.snapshot_committed || Option.is_some preparation.snapshot_persisted - then Error Types.Snapshot_state_error + then Error (Types.Snapshot_state_error Types.Snapshot_next_after_persist_or_commit) else if preparation.snapshot_awaiting_crypto - then Error Types.Snapshot_state_error + then Error (Types.Snapshot_state_error Types.Snapshot_crypto_batch_pending) else ( let maximum = preparation.snapshot_dependencies.limits.wire_batch_max_bytes in let rec take bytes reversed sequence = @@ -574,7 +574,7 @@ let supply_snapshot_unprotection_batch preparation ~request ~plaintexts = if preparation.snapshot_canceled then Error Types.Snapshot_preparation_canceled else if preparation.snapshot_committed || Option.is_some preparation.snapshot_persisted - then Error Types.Snapshot_state_error + then Error (Types.Snapshot_state_error Types.Snapshot_supply_after_persist_or_commit) else ( match request.unprotection_owner with | Snapshot_unprotection (owner, revision) @@ -627,7 +627,7 @@ let persist_snapshot_activation preparation = | Some persisted -> Ok persisted | None -> if preparation.snapshot_awaiting_crypto - then Error Types.Snapshot_state_error + then Error (Types.Snapshot_state_error Types.Snapshot_persist_awaiting_crypto) else if not preparation.snapshot_crypto_complete then Error (Types.Snapshot_crypto_error "snapshot crypto input is incomplete") else ( @@ -651,7 +651,12 @@ let persist_snapshot_activation preparation = ~schema:(Mirror.schema preparation.snapshot_staging) ~applied_server_t:preparation.snapshot_cursor ~checksum:digest - |> Result.map_error (fun _message -> Types.Snapshot_state_error) + |> Result.map_error (fun message -> + let reason = match message with + | "sync schema version must be non-negative" -> `Schema_negative + | "sync checksum must be 16 lowercase hexadecimal characters" -> `Checksum_invalid + | _ -> `Unknown in + Types.Snapshot_state_error (Types.Snapshot_checkpoint_invalid reason)) in Result.bind metadata (fun metadata -> match Mirror.persist preparation.snapshot_staging database metadata with @@ -2168,7 +2173,15 @@ let close database = let ownership_result = Ownership.release database.ownership in match storage_result, ownership_result with | Ok (), Ok () -> Ok () - | Error _, _ -> Error (Types.Close_failed "lower storage close failed") + | Error error, _ -> + let reason,source=match error with + | Logseq_db_storage.Storage_session.Closed -> "databaseClosed",None + | Fatal s -> "fatalState",Some s | Stage_failed s -> "storageWriteFailed",Some s + | Persistence_failed s -> "closeFailed",Some s | Already_consumed -> "preparationConsumed",None in + let module D=Logseq_db_types.Failure_diagnostic in + let diagnostic=match source with None->D.create ~operation:"closeDatabase" ~reason () + | Some source->D.of_source ~operation:"closeDatabase" ~reason source in + Error (Types.Close_failed(D.encode diagnostic)) | Ok (), Error message -> Error (Types.Close_failed message)) else Ok () ;; @@ -5697,7 +5710,7 @@ let open_owned ~sw dependencies inspection ~graph_name ownership = | Error message -> Error (Types.Corrupt_outbox message) | Ok () -> (match Storage.open_database path with - | Error _ -> Error (Types.Restore_failed "unable to open mirror") + | Error error -> Error (Types.Restore_failed (Storage.diagnostic_error ~operation:"attachGraph" error)) | Ok connection -> let callbacks = Storage.connection_callbacks connection in let transferred = ref false in @@ -5713,8 +5726,8 @@ let open_owned ~sw dependencies inspection ~graph_name ownership = ignore (Storage.close callbacks))) (fun () -> match Storage.restore_database connection with - | Error _ -> - Error (Types.Restore_failed "unable to restore authoritative database") + | Error error -> + Error (Types.Restore_failed (Storage.diagnostic_error ~operation:"attachGraph" error)) | Ok authoritative_database -> let authoritative_connection = Datascript.Conn.from_db @@ -7170,11 +7183,52 @@ let transaction_matches_record_at_cursor record cursor root_before transaction_d | Ok expected -> equal_transaction_data expected transaction_data) ;; -let transaction_data_shape transaction_data = - transaction_data - |> List.map (fun (datom : Datascript.datom) -> - (if datom.added then "add:" else "retract:") ^ datom.a) - |> String.concat "," +let integrity_record_context (record : outbox_record) = + let mutation = + match record.mutation with + | Types.Save_block _ -> "save-block" + | Insert_blocks _ -> "insert-blocks" + | Create_journal_page _ -> "create-journal-page" + | Delete_blocks _ -> "delete-blocks" + | Set_task_status _ -> "set-task-status" + | Clear_task_status _ -> "clear-task-status" + | Publish_asset _ -> "publish-asset" + | Set_asset_reference _ -> "set-asset-reference" + in + let state = + match record.transport_state with + | Types.Queued -> "queued" + | Submitted _ -> "submitted" + | Accepted_pending_authoritative _ -> "accepted" + | Stale_rejected_pending_authoritative _ -> "stale-rejected" + | Blocked -> "blocked" + in + [ "mutation", mutation; "outboxState", state ] + @ Option.to_list + (Option.map + (fun c -> "tBefore", Int64.to_string (Types.Server_cursor.to_int64 c)) + record.submission_t_before) + @ Option.to_list + (Option.map (fun n -> "ordinal", string_of_int n) record.submission_ordinal) + @ Option.to_list + (Option.map (fun n -> "count", string_of_int n) record.submission_count) +;; + +let integrity_source ?(context = []) ?record ~reason () = + let context = context @ Option.fold ~none:[] ~some:integrity_record_context record in + Authoritative_diagnostic.integrity ~reason ~context () + |> Authoritative_diagnostic.encode +;; + +let integrity_batch_source owner batch source = + Authoritative_diagnostic.integrity_of_source source + |> Authoritative_diagnostic.with_integrity_context + [ "checkpoint", Int64.to_string (Types.Server_cursor.to_int64 owner.checkpoint) + ; ( "through" + , Int64.to_string + (Types.Server_cursor.to_int64 (Types.authoritative_batch_through batch)) ) + ] + |> Authoritative_diagnostic.encode ;; let validate_active_transaction_origin owner cursor root_before transaction_data = @@ -7187,7 +7241,15 @@ let validate_active_transaction_origin owner cursor root_before transaction_data match matches with | [] -> Ok None | [ record ] -> Ok (Some (record.mutation_id, cursor)) - | _ -> Error "authoritative transaction ambiguously matches local submissions" + | _ -> + Error + (integrity_source + ~reason:"multiple-local-matches" + ~context: + [ "matchingSubmissions", string_of_int (List.length matches) + ; "expectedCursor", Int64.to_string (Types.Server_cursor.to_int64 cursor) + ] + ()) ;; let validate_active_transaction_origins owner batch transaction_data roots_after = @@ -7198,7 +7260,16 @@ let validate_active_transaction_origins owner batch transaction_data roots_after if List.length cursors <> List.length transaction_data || List.length transaction_data <> List.length roots_after - then Error "authoritative transaction origins do not match their cursors" + then + Error + (integrity_source + ~reason:"origin-context-length" + ~context: + [ "cursorCount", string_of_int (List.length cursors) + ; "transactionCount", string_of_int (List.length transaction_data) + ; "rootCount", string_of_int (List.length roots_after) + ] + ()) else ( let rec contexts previous cursors transaction_data roots_after reversed = match cursors, transaction_data, roots_after with @@ -7237,47 +7308,67 @@ let validate_active_transaction_origins owner batch transaction_data roots_after Result.bind result (fun () -> match record.transport_state with | Types.Accepted_pending_authoritative _ -> - Result.bind (expected_origin_cursor record) (fun expected -> - if Types.Server_cursor.compare expected previous_checkpoint <= 0 - then ( - match record.observed_origin_cursor with - | Some cursor when Types.Server_cursor.equal cursor expected -> Ok () - | None | Some _ -> - Error "accepted transaction has no durable origin evidence") - else if Types.Server_cursor.compare expected through <= 0 - then - if - List.exists - (fun (origin, cursor) -> - Graph.Uuid.equal origin record.mutation_id - && Types.Server_cursor.equal cursor expected) - validated - then Ok () - else ( - let actual = - contexts - |> List.find_opt (fun (cursor, _, _) -> - Types.Server_cursor.equal cursor expected) - |> Option.map (fun (_, data, _) -> transaction_data_shape data) - |> Option.value ~default:"missing-cursor" - in - let expected_shape = - contexts - |> List.find_opt (fun (cursor, _, _) -> - Types.Server_cursor.equal cursor expected) - |> fun context -> - Option.bind context (fun (_, _, root_before) -> - expected_record_transaction_data root_before record - |> Result.to_option) - |> Option.map transaction_data_shape - |> Option.value ~default:"simulation-failed" - in - Error - ("accepted transaction payload mismatch: expected=" - ^ expected_shape - ^ "; actual=" - ^ actual)) - else Ok ()) + Result.bind + (expected_origin_cursor record + |> Result.map_error (fun source -> + Authoritative_diagnostic.integrity_of_source source + |> Authoritative_diagnostic.with_integrity_context + (integrity_record_context record) + |> Authoritative_diagnostic.encode)) + (fun expected -> + if Types.Server_cursor.compare expected previous_checkpoint <= 0 + then ( + match record.observed_origin_cursor with + | Some cursor when Types.Server_cursor.equal cursor expected -> Ok () + | None | Some _ -> + Error + (integrity_source + ~reason:"missing-durable-origin" + ~record + ~context: + [ ( "expectedCursor" + , Int64.to_string (Types.Server_cursor.to_int64 expected) ) + ] + ())) + else if Types.Server_cursor.compare expected through <= 0 + then + if + List.exists + (fun (origin, cursor) -> + Graph.Uuid.equal origin record.mutation_id + && Types.Server_cursor.equal cursor expected) + validated + then Ok () + else ( + let context = + List.find_opt + (fun (cursor, _, _) -> Types.Server_cursor.equal cursor expected) + contexts + in + let reason, counts = + match context with + | None -> "expected-cursor-missing", [] + | Some (_, actual, root_before) -> + (match expected_record_transaction_data root_before record with + | Error _ -> + ( "expected-simulation-failed" + , [ "actualDatoms", string_of_int (List.length actual) ] ) + | Ok expected -> + ( "payload-mismatch" + , [ "expectedDatoms", string_of_int (List.length expected) + ; "actualDatoms", string_of_int (List.length actual) + ] )) + in + Error + (integrity_source + ~reason + ~record + ~context: + (( "expectedCursor" + , Int64.to_string (Types.Server_cursor.to_int64 expected) ) + :: counts) + ())) + else Ok ()) | Queued | Submitted _ | Stale_rejected_pending_authoritative _ | Blocked -> Ok ()) in @@ -7793,7 +7884,8 @@ let prepare_authoritative_candidate preparation ~decrypted = expected_tx_data roots_after with - | Error message -> Error (Types.Authoritative_integrity_failure message) + | Error message -> Error (Types.Authoritative_integrity_failure + (integrity_batch_source preparation.authoritative_owner preparation.authoritative_batch message)) | Ok validated_origins -> preparation.authoritative_state <- Authoritative_consumed; Option.iter @@ -7827,7 +7919,8 @@ let prepare_authoritative_candidate preparation ~decrypted = transactions roots_after with - | Error message -> Error (Types.Authoritative_integrity_failure message) + | Error message -> Error (Types.Authoritative_integrity_failure + (integrity_batch_source preparation.authoritative_owner preparation.authoritative_batch message)) | Ok (stale_updates, stale_remote_won, stale_no_change, stale_blocked) -> let rec transaction_contexts previous transactions transaction_data roots = match transactions, transaction_data, roots with @@ -7854,7 +7947,8 @@ let prepare_authoritative_candidate preparation ~decrypted = | None -> Error (Types.Authoritative_integrity_failure - "submitted delete omitted its durable t_before") + (integrity_source ~reason:"submitted-delete-missing-t-before" ~record () + |> integrity_batch_source preparation.authoritative_owner preparation.authoritative_batch)) | Some t_before -> (match List.find_opt diff --git a/logseq_overlay_db/lib/mirror.ml b/logseq_overlay_db/lib/mirror.ml index 03359d95..a651129b 100644 --- a/logseq_overlay_db/lib/mirror.ml +++ b/logseq_overlay_db/lib/mirror.ml @@ -107,7 +107,7 @@ let import_snapshot_rows ~snapshot_path ~expected_rows database_path = let inspect_database database_path graph_id = match Storage.open_database database_path with - | Error _ -> Error "snapshot SQLite storage cannot be opened" + | Error error -> Error (Storage.diagnostic_error ~operation:"activateSnapshot" error) | Ok connection -> let callbacks = Storage.connection_callbacks connection in (match Storage.startup_metadata connection, Storage.restore_database connection with @@ -131,9 +131,9 @@ let inspect_database database_path graph_id = | Ambiguous_sync_state -> "The downloaded graph has an invalid synchronization identity." | Unsupported_value -> "The downloaded graph contains unsupported values." | Corrupt_storage -> "The downloaded graph storage is corrupt.")) - | Error _, _ | _, Error _ -> + | Error error, _ | _, Error error -> ignore (Storage.close callbacks); - Error "snapshot SQLite storage is corrupt") + Error (Storage.diagnostic_error ~operation:"activateSnapshot" error)) ;; let stage ~root ~graph_id ~snapshot_path ~expected_rows = @@ -146,7 +146,9 @@ let stage ~root ~graph_id ~snapshot_path ~expected_rows = Error message in match import_snapshot_rows ~snapshot_path ~expected_rows database_path with - | Error _ -> cleanup "The downloaded graph snapshot could not be imported." + | Error source -> + cleanup (Logseq_db_types.Failure_diagnostic.of_source ~operation:"activateSnapshot" ~reason:"parseFailed" source + |> Logseq_db_types.Failure_diagnostic.encode) | Ok () -> (match inspect_database database_path graph_id with | Error message -> cleanup message @@ -326,10 +328,10 @@ let persist_database staged database metadata = Error message | Ok batch -> (match Storage.commit_batch callbacks batch with - | Error _ -> Error "snapshot database commit failed" + | Error error -> Error (Storage.diagnostic_error ~operation:"activateSnapshot" error) | Ok () -> (match Storage.commit_sync_metadata callbacks metadata with - | Error _ -> Error "snapshot metadata commit failed" + | Error error -> Error (Storage.diagnostic_error ~operation:"activateSnapshot" error) | Ok () -> Ok ())) with | exn -> diff --git a/logseq_overlay_db/lib/types.ml b/logseq_overlay_db/lib/types.ml index 7f19f314..69c9c39a 100644 --- a/logseq_overlay_db/lib/types.ml +++ b/logseq_overlay_db/lib/types.ml @@ -824,13 +824,21 @@ type mirror_error = | Mirror_location_unavailable of string | Mirror_inspection_failed of string +type snapshot_state_reason = + | Snapshot_root_unavailable + | Snapshot_next_after_persist_or_commit + | Snapshot_crypto_batch_pending + | Snapshot_supply_after_persist_or_commit + | Snapshot_persist_awaiting_crypto + | Snapshot_checkpoint_invalid of [ `Schema_negative | `Checksum_invalid | `Unknown ] + type snapshot_activation_error = | Invalid_snapshot_path | Invalid_expected_rows | Snapshot_input_too_large | Mirror_exists | Stale_snapshot_inspection - | Snapshot_state_error + | Snapshot_state_error of snapshot_state_reason | Snapshot_crypto_result_error of crypto_result_error | Snapshot_crypto_error of string | Snapshot_parse_error of string diff --git a/logseq_overlay_db/spec/types.mli b/logseq_overlay_db/spec/types.mli index 9794b025..6b70632f 100644 --- a/logseq_overlay_db/spec/types.mli +++ b/logseq_overlay_db/spec/types.mli @@ -651,13 +651,21 @@ type mirror_error = | Mirror_location_unavailable of string | Mirror_inspection_failed of string +type snapshot_state_reason = + | Snapshot_root_unavailable + | Snapshot_next_after_persist_or_commit + | Snapshot_crypto_batch_pending + | Snapshot_supply_after_persist_or_commit + | Snapshot_persist_awaiting_crypto + | Snapshot_checkpoint_invalid of [ `Schema_negative | `Checksum_invalid | `Unknown ] + type snapshot_activation_error = | Invalid_snapshot_path | Invalid_expected_rows | Snapshot_input_too_large | Mirror_exists | Stale_snapshot_inspection - | Snapshot_state_error + | Snapshot_state_error of snapshot_state_reason | Snapshot_crypto_result_error of crypto_result_error | Snapshot_crypto_error of string | Snapshot_parse_error of string diff --git a/logseq_overlay_db/test/test_overlay_storage.ml b/logseq_overlay_db/test/test_overlay_storage.ml index bc7145c4..7f67d205 100644 --- a/logseq_overlay_db/test/test_overlay_storage.ml +++ b/logseq_overlay_db/test/test_overlay_storage.ml @@ -405,8 +405,11 @@ let snapshot_import_error_does_not_disclose_payload () = match Database.prepare_snapshot_activation dependencies inspection ~path ~applied_server_cursor:(server_cursor 0) ~expected_checksum:None ~expected_rows:1 with | Error (Snapshot_parse_error message) -> - Alcotest.(check string) "raw parser payload is not user-facing" - "The downloaded graph snapshot could not be imported." message + let module D=Logseq_db_types.Failure_diagnostic in + let diagnostic=match D.decode message with Some d->d | None->Alcotest.fail "snapshot parser reason was discarded" in + Alcotest.(check string) "safe parser category survives" "parseFailed" (D.reason diagnostic); + Alcotest.(check bool) "raw parser body is excluded" false + (String.contains (D.message diagnostic) '{') | Error _ -> Alcotest.fail "malformed snapshot returned another category" | Ok prepared -> Database.cancel_snapshot_activation prepared; Alcotest.fail "malformed snapshot was admitted") @@ -481,6 +484,44 @@ let canceled_snapshot_activation_removes_temporary_artifacts () = | Available _ -> Alcotest.fail "canceled activation installed a mirror") ;; +(* The mutable snapshot preparation, not the pure reducer, owns these + protocol states. Drive its public calls with a synthetic on-disk snapshot. *) +let snapshot_state_errors_keep_their_reason () = + let behavior = "snapshot state failures retain finite reasons" in + let expect expected = function + | Error (Snapshot_state_error actual) when actual = expected -> () + | Error _ -> Alcotest.fail "snapshot state error lost its distinct reason" + | Ok _ -> Alcotest.fail "invalid snapshot operation was accepted" + in + T.with_temp_directory "overlay-snapshot-reasons-" (fun root -> + let support = Filename.concat root "target" in + Unix.mkdir support 0o700; + let _, _, prepared = prepare_snapshot ~behavior root support in + Fun.protect ~finally:(fun () -> Database.cancel_snapshot_activation prepared) (fun () -> + let request = Database.next_snapshot_unprotection_batch prepared + |> T.require_ok ~behavior |> Option.get in + expect Snapshot_crypto_batch_pending (Database.next_snapshot_unprotection_batch prepared); + expect Snapshot_persist_awaiting_crypto (Database.commit_snapshot_activation prepared); + let plaintexts = Database.unprotection_ciphertexts request in + Database.supply_snapshot_unprotection_batch prepared ~request ~plaintexts + |> T.require_ok ~behavior; + ignore (supply_snapshot_crypto ~behavior prepared 0); + ignore (Database.commit_snapshot_activation prepared |> T.require_ok ~behavior); + expect Snapshot_next_after_persist_or_commit (Database.next_snapshot_unprotection_batch prepared); + expect Snapshot_supply_after_persist_or_commit + (Database.supply_snapshot_unprotection_batch prepared ~request ~plaintexts))); + T.with_temp_directory "overlay-snapshot-root-reason-" (fun root -> + let support = Filename.concat root "target" in + Unix.mkdir support 0o700; + let dependencies, _, inspection = absent_inspection ~behavior support in + let path, applied_server_cursor, expected_checksum, expected_rows = snapshot_input_for root in + let blocked = Filename.concat support "logseq-db-worker" in + let channel = open_out_bin blocked in close_out channel; + expect Snapshot_root_unavailable + (Database.prepare_snapshot_activation dependencies inspection ~path ~applied_server_cursor + ~expected_checksum ~expected_rows)) +;; + let snapshot_crypto_results_are_validated_before_staging () = let behavior = "snapshot crypto results are validated before staging" in T.with_temp_directory "overlay-snapshot-crypto-validation-" (fun root -> @@ -3031,6 +3072,10 @@ let cases = "snapshot cancellation removes temporary artifacts" `Quick canceled_snapshot_activation_removes_temporary_artifacts + ; Alcotest.test_case + "snapshot state errors keep finite reasons" + `Quick + snapshot_state_errors_keep_their_reason ; Alcotest.test_case "snapshot crypto results are validated before staging" `Quick diff --git a/logseq_sync/lib/effect_runner/effect_runner.ml b/logseq_sync/lib/effect_runner/effect_runner.ml index af32ae8a..7686ddfc 100644 --- a/logseq_sync/lib/effect_runner/effect_runner.ml +++ b/logseq_sync/lib/effect_runner/effect_runner.ml @@ -1,6 +1,7 @@ module Core = Logseq_sync_pure_reducer.Core module Sync_protocol = Logseq_sync_pure_reducer.Sync_protocol module Asset_cache = Bootstrap.Asset_cache +module Failure_diagnostic = Logseq_db_types.Failure_diagnostic type unlock_failure_category = Token | Transport | Http_status | Decode | Crypto @@ -361,6 +362,8 @@ type t = { sw : Eio.Switch.t ; dependencies : dependencies ; post : Core.event -> unit + ; on_asset_diagnostic : scope:Core.graph_scope -> operation:string + -> request_operation:string -> Failure_diagnostic.t -> unit ; operations : (string, operation) Hashtbl.t ; submitted_operations : Submitted_effects.t ; keys : (string, key_entry) Hashtbl.t @@ -380,11 +383,13 @@ type t = ; mutable closed : bool } -let create ~sw dependencies ~post = +let create ?(on_asset_diagnostic=(fun ~scope:_ ~operation:_ ~request_operation:_ _ -> ())) + ~sw dependencies ~post = Ok { sw ; dependencies ; post + ; on_asset_diagnostic ; operations = Hashtbl.create 32 ; submitted_operations = Submitted_effects.create 32 ; keys = Hashtbl.create 8 @@ -476,9 +481,12 @@ let load_catalog local_store account = in Core.decode_catalog_cache source |> Result.map Option.some - |> Result.map_error (fun message -> Core.Effect_failed message) + |> Result.map_error (fun source -> Core.Effect_failed + Failure_diagnostic.(of_source ~operation:"loadCatalog" ~reason:"parseFailed" source |> encode)) with - | Sys_error _ -> Error (Core.Effect_failed "The local graph catalog could not be read.")) + | Sys_error _ as error -> Error (Core.Effect_failed + Failure_diagnostic.(of_source ~operation:"loadCatalog" ~reason:"resourceFailed" + (Printexc.to_string error) |> encode))) ;; let ensure_directory path = @@ -487,15 +495,17 @@ let ensure_directory path = then if Sys.is_directory path then Ok () - else Error (Core.Effect_failed (path ^ " is not a directory")) + else Error (Core.Effect_failed Failure_diagnostic.( + create ~operation:"prepareCatalog" ~reason:"invalidPath" + ~source:"catalog directory is not a directory" () |> encode)) else ( Unix.mkdir path 0o700; Ok ()) with - | Unix.Unix_error (error, operation, target) -> - Error - (Core.Effect_failed - (Printf.sprintf "%s(%s): %s" operation target (Unix.error_message error))) + | (Unix.Unix_error _ | Sys_error _) as error -> + Error (Core.Effect_failed Failure_diagnostic.( + of_source ~operation:"prepareCatalog" ~reason:"resourceFailed" + (Printexc.to_string error) |> encode)) ;; let save_catalog local_store account cache = @@ -518,7 +528,9 @@ let save_catalog local_store account cache = Ok () with exn -> close_out_noerr channel; raise exn) with - | Sys_error _ -> Error (Core.Effect_failed "The local graph catalog could not be saved.")))) + | Sys_error _ as error -> Error (Core.Effect_failed Failure_diagnostic.( + of_source ~operation:"saveCatalog" ~reason:"persistenceFailed" + (Printexc.to_string error) |> encode))))) ;; let effect_error message = Error (Core.Effect_failed message) @@ -1315,6 +1327,60 @@ let shutdown t = Hashtbl.clear t.operations) ;; +let asset_diagnostic_capture = Eio.Fiber.create_key () + +let report_asset_failure ~reason source = + Option.iter (fun report -> report ~reason source) + (Eio.Fiber.get asset_diagnostic_capture) +;; + +let asset_error_source = function + | Asset_cache.Invalid source | Io source -> source + | Full -> "Asset staging storage is full" + | Stale -> "Asset staging scope is stale" + | Checksum_mismatch -> "Asset staging checksum mismatch" +;; + +let asset_diagnostic_operation = function + | Core.Put_asset_file _ -> "assetUpload" + | Core.Fetch_asset _ -> "assetDownload" + | Core.Release_asset_file _ | Release_staged_file _ | Close_asset_scope + | Prepare_graph_asset_deletion _ | Commit_graph_asset_deletion _ + | Cancel_graph_asset_deletion _ | Delete_account_assets | Cancel_asset_operation _ -> "assetRelease" + | _ -> "assetPreview" +;; + +let with_asset_diagnostics t (request : Core.asset_io_request) ~current work = + let operation = asset_diagnostic_operation request.action in + let captured = ref None in + let report ~reason:reason_name source = + captured := Some (Failure_diagnostic.of_source ~operation ~reason:reason_name source) in + let result = Eio.Fiber.with_binding asset_diagnostic_capture report work in + (match result with + | Error Core.Asset_cancelled | Ok _ -> () + | Error failure when not t.closed && current () -> + let fallback = match failure with + | Core.Asset_network -> "networkFailed", "" + | Asset_not_found -> "notFound", "" + | Asset_checksum_mismatch -> "checksumMismatch", "" + | Asset_authentication -> "authenticationFailed", "" + | Asset_locked -> "locked", "" + | Asset_storage_full -> "storageFull", "" + | Asset_missing_source -> "missingSource", "" + | Asset_size_rejected -> "sizeRejected", "" + | Asset_revoked_access -> "revokedAccess", "" + | Asset_invalid_content source -> "invalidContent", source + | Asset_cancelled -> "cancelled", "" in + let reason_name, source = fallback in + let detail = Option.value !captured ~default:( + Failure_diagnostic.of_source ~operation ~reason:reason_name source) in + (* Diagnostics must never change an operation's completion or cleanup. *) + (try t.on_asset_diagnostic ~scope:request.context.scope ~operation + ~request_operation:request.operation detail with _ -> ()) + | Error _ -> ()); + result +;; + type asset_encryption = | Plaintext | Encrypted of Core.graph_key_handle option @@ -1326,23 +1392,39 @@ let asset_cache_failure = function | Invalid message | Io message -> Core.Asset_invalid_content message ;; -let asset_decode_failure = function +let asset_decode_failure error = + let reason_name, source = match error with + | Asset_codec.Invalid_limit -> "sizeRejected", "Invalid plaintext size limit" + | Wire_too_large -> "sizeRejected", "Asset exceeds wire size limit" + | Plaintext_too_large -> "sizeRejected", "Asset exceeds plaintext size limit" + | Malformed_envelope -> "invalidContent", "Invalid asset AES-GCM envelope" + | Malformed_transit _ -> "parseFailed", "Asset Transit payload is invalid" + | Decryption_failed source -> "cryptoInvalid", source + | Checksum_mismatch -> "checksumMismatch", "Asset checksum mismatch" in + report_asset_failure ~reason:reason_name source; + match error with | 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) + | _ -> Core.Asset_invalid_content Failure_diagnostic.( + of_source ~operation:"assetDownload" ~reason:reason_name source |> encode) ;; let asset_graph_key t scope = function | Plaintext -> Ok None - | Encrypted None -> Error Core.Asset_locked + | Encrypted None -> + report_asset_failure ~reason:"locked" "Asset graph key handle is missing"; + Error Core.Asset_locked | Encrypted (Some handle) -> if Core.graph_key_handle_scope handle <> scope - then Error Core.Asset_locked + then ( + report_asset_failure ~reason:"locked" "Asset graph key scope does not match"; + Error Core.Asset_locked) else key t handle |> Result.map Option.some - |> Result.map_error (fun _ -> Core.Asset_locked) + |> Result.map_error (fun error -> + let source = match error with Core.Effect_failed source | Crypto_failed (_, source) -> source in + report_asset_failure ~reason:"locked" source; + Core.Asset_locked) ;; let with_asset_slot slots work = @@ -1432,13 +1514,27 @@ let fetch_asset_admitted last_failure := Core.Asset_revoked_access; Error Forbidden | Ok response -> Ok response) - |> Result.map_error (fun _ -> !last_failure) + |> Result.map_error (fun cause -> + let reason = match !last_failure with + | Core.Asset_network -> "networkFailed" + | Asset_revoked_access -> "revokedAccess" + | _ -> "authenticationFailed" in + let source = match cause with + | "Authentication failed." -> "Asset HTTP status 401" + | "Authorization failed." -> "Asset HTTP status 403" + | source -> source in + report_asset_failure ~reason source; + !last_failure) in let* () = match response.status with | status when status >= 200 && status < 300 -> Ok () - | 404 -> Error Core.Asset_not_found - | 408 | 429 | 500 | 502 | 503 | 504 -> Error Core.Asset_network + | 404 -> + report_asset_failure ~reason:"notFound" "Asset HTTP status 404"; + Error Core.Asset_not_found + | (408 | 429 | 500 | 502 | 503 | 504) as status -> + report_asset_failure ~reason:"networkFailed" (Printf.sprintf "Asset HTTP status %d" status); + Error Core.Asset_network | status -> Error (Core.Asset_invalid_content (Printf.sprintf "Asset HTTP status %d" status)) in @@ -1626,18 +1722,24 @@ let read_upload_source ~maximum_plaintext_bytes source_file = (fun () -> let stat = Unix.fstat (Unix.descr_of_in_channel input) in if stat.st_kind <> Unix.S_REG - then Error (Core.Asset_invalid_content "Invalid upload content") + then Error (Core.Asset_invalid_content "Asset upload source is not a regular file") else if stat.st_size > maximum_plaintext_bytes then Error Core.Asset_size_rejected else ( let bytes = really_input_string input stat.st_size in match input_char input with - | _ -> Error (Core.Asset_invalid_content "Invalid upload content") + | _ -> Error (Core.Asset_invalid_content "Asset upload source grew during reading") | exception End_of_file -> Ok bytes)) with - | Sys_error _ | Unix.Unix_error (Unix.ENOENT, _, _) -> Error Core.Asset_missing_source - | End_of_file | Unix.Unix_error _ -> - Error (Core.Asset_invalid_content "Invalid upload content") + | (Sys_error _ | Unix.Unix_error (Unix.ENOENT, _, _)) as error -> + report_asset_failure ~reason:"missingSource" (Printexc.to_string error); + Error Core.Asset_missing_source + | End_of_file -> + Error (Core.Asset_invalid_content "Asset upload source ended during reading") + | Unix.Unix_error _ as error -> + Error (Core.Asset_invalid_content Failure_diagnostic.( + of_source ~operation:"assetUpload" ~reason:"invalidContent" + (Printexc.to_string error) |> encode)) ;; let put_asset_admitted @@ -1672,7 +1774,7 @@ let put_asset_admitted (String.equal (Asset_codec.checksum plaintext) version.Logseq_db_types.Asset_descriptor.checksum) - then Error (Core.Asset_invalid_content "Invalid upload content") + then Error (Core.Asset_invalid_content "Asset upload checksum mismatch") else ( let crypto : Asset_codec.crypto = { encrypt = t.dependencies.crypto.encrypt_aes_gcm @@ -1680,13 +1782,17 @@ let put_asset_admitted } in Asset_codec.encode ~maximum_plaintext_bytes ~crypto ~key:graph_key plaintext - |> Result.map_error (fun _ -> - Core.Asset_invalid_content "Invalid upload content"))) + |> Result.map_error (fun source -> + Core.Asset_invalid_content Failure_diagnostic.( + of_source ~operation:"assetUpload" ~reason:( + if source = "Asset exceeds plaintext size limit" then "sizeRejected" else "cryptoInvalid") + source |> encode)))) in let base_url = context.scope.account.managed_sync_origin in let* () = Http.validate_base_url base_url - |> Result.map_error (fun _ -> Core.Asset_invalid_content "Invalid upload content") + |> Result.map_error (fun _ -> + Core.Asset_invalid_content "Asset upload base URL is invalid") in let path = Printf.sprintf @@ -1730,16 +1836,31 @@ let put_asset_admitted failure := Core.Asset_revoked_access; Error Forbidden | Ok response -> Ok response)) - |> Result.map_error (fun _ -> !failure) + |> Result.map_error (fun cause -> + if !failure <> Core.Asset_cancelled then ( + let reason = match !failure with + | Core.Asset_network -> "networkFailed" + | Asset_revoked_access -> "revokedAccess" + | _ -> "authenticationFailed" in + let source = match cause with + | "Authentication failed." -> "Asset HTTP status 401" + | "Authorization failed." -> "Asset HTTP status 403" + | source -> source in + report_asset_failure ~reason source); + !failure) in if not (valid ()) then Error Core.Asset_cancelled else ( match response.status with | status when status >= 200 && status < 300 -> Ok () - | 413 -> Error Core.Asset_size_rejected - | 408 | 429 | 500 | 502 | 503 | 504 -> Error Core.Asset_network - | _ -> Error (Core.Asset_invalid_content "Invalid upload content"))) + | 413 -> + report_asset_failure ~reason:"sizeRejected" "Asset HTTP status 413"; + Error Core.Asset_size_rejected + | (408 | 429 | 500 | 502 | 503 | 504) as status -> + report_asset_failure ~reason:"networkFailed" (Printf.sprintf "Asset HTTP status %d" status); + Error Core.Asset_network + | status -> Error (Core.Asset_invalid_content (Printf.sprintf "Asset HTTP status %d" status)))) ;; let put_asset @@ -1774,16 +1895,32 @@ let staged_asset_path t ~scope ~file = then None else ( match scoped_asset_cache t scope with - | Error _ -> None + | Error error -> + report_asset_failure ~reason:"unavailable" (asset_error_source error); + None | Ok cache -> Asset_cache.staged_path cache ~file) ;; let release_staged_asset t ~scope ~file = match scoped_asset_cache t scope with - | Error _ -> Error "Asset staging is unavailable" + | Error error -> + let source = match error with + | Asset_cache.Invalid source | Io source -> source + | Full -> "Asset staging storage is full" + | Stale -> "Asset staging scope is stale" + | Checksum_mismatch -> "Asset staging checksum mismatch" in + Error Failure_diagnostic.(of_source ~operation:"assetRelease" + ~reason:"unavailable" source |> encode) | Ok cache -> Result.map_error - (fun _ -> "Unable to release staged asset") + (fun error -> + let source = match error with + | Asset_cache.Invalid source | Io source -> source + | Full -> "Asset staging storage is full" + | Stale -> "Asset staging scope is stale" + | Checksum_mismatch -> "Asset staging checksum mismatch" in + Failure_diagnostic.(of_source ~operation:"assetRelease" + ~reason:"cleanupFailed" source |> encode)) (Asset_cache.release_staged cache ~file) ;; @@ -1808,7 +1945,9 @@ let execute_asset ?except_id t (request : Core.asset_io_request) ~current = in let cache action = Result.bind - (scoped_asset_cache t scope |> Result.map_error asset_cache_failure) + (scoped_asset_cache t scope |> Result.map_error (fun error -> + report_asset_failure ~reason:"unavailable" (asset_error_source error); + asset_cache_failure error)) action in let permitted = match request.action with @@ -2020,7 +2159,8 @@ let submit_asset_io t instruction ticket (request : Core.asset_io_request) = Eio.Fiber.first (fun () -> if not (current ()) then raise Runner_cancelled; - execute_asset ~except_id:id t request ~current) + with_asset_diagnostics t request ~current (fun () -> + execute_asset ~except_id:id t request ~current)) (fun () -> Eio.Promise.await cancelled; raise Runner_cancelled) @@ -2127,7 +2267,9 @@ let submit t instruction = Fun.protect ~finally:(fun () -> if t.closed then close_asset_scope t request.context.scope) - (fun () -> execute_asset t request ~current:(fun () -> false)) + (fun () -> with_asset_diagnostics t request + ~current:(fun () -> not t.closed) + (fun () -> execute_asset t request ~current:(fun () -> false))) with | error -> Error (Core.Asset_invalid_content (Printexc.to_string error)) in diff --git a/logseq_sync/lib/effect_runner/eio/http_eio.ml b/logseq_sync/lib/effect_runner/eio/http_eio.ml index ce420535..0ff3efdd 100644 --- a/logseq_sync/lib/effect_runner/eio/http_eio.ml +++ b/logseq_sync/lib/effect_runner/eio/http_eio.ml @@ -102,9 +102,11 @@ let with_attempt ~sw ~authenticator ~network ~clock ~response_budget request ~co | Some host -> host | None -> fail "HTTPS host is missing" in + let dns_failure = ref None in let flow = match - Tls_client_eio.connect + Tls_client_eio.connect_diagnosed + ~on_diagnostic:(fun source -> dns_failure := Some source) ~sw:owner ~authenticator ~network @@ -113,7 +115,23 @@ let with_attempt ~sw ~authenticator ~network ~clock ~response_budget request ~co ~port:(port request.uri) with | Ok flow -> flow - | Error _ -> fail "TLS connection setup failed" + | Error Tls_client_eio.Invalid_dns_host -> + fail "TLS setup failed: invalid DNS host" + | Error Tls_client_eio.No_network_address -> + fail ("TLS setup failed: no network address" + ^ Option.fold ~none:"" ~some:(fun source -> " (" ^ source ^ ")") !dns_failure) + | Error Tls_client_eio.Authentication_failed -> + fail "TLS authentication failed" + | Error (Tls_client_eio.Setup_failed "invalid TLS configuration") -> + fail "TLS setup failed: invalid TLS configuration" + | Error (Tls_client_eio.Setup_failed "all resolved addresses failed") -> + fail "TLS setup failed: all resolved addresses failed" + | Error (Tls_client_eio.Setup_failed "DNS/TCP dial timed out") -> + fail "TLS setup failed: DNS/TCP dial timed out" + | Error (Tls_client_eio.Setup_failed source) -> + (match Tls_client_eio.safe_setup_detail source with + | Some detail -> fail ("TLS setup failed: " ^ detail) + | None -> fail "TLS connection setup failed") in let started = Eio.Time.now clock in Fun.protect diff --git a/logseq_sync/lib/effect_runner/eio/tls_client_eio.ml b/logseq_sync/lib/effect_runner/eio/tls_client_eio.ml index 07fbd7d3..e37d394e 100644 --- a/logseq_sync/lib/effect_runner/eio/tls_client_eio.ml +++ b/logseq_sync/lib/effect_runner/eio/tls_client_eio.ml @@ -35,7 +35,7 @@ let resolve ~clock ~deadline host port = ~finally:(fun () -> dns_close query) (fun () -> try - Eio.Time.with_timeout_exn + let addresses = Eio.Time.with_timeout_exn clock (max 0. (deadline -. Eio.Time.now clock)) (fun () -> @@ -43,14 +43,20 @@ let resolve ~clock ~deadline host port = Eio_unix.await_readable (dns_fd query); if dns_process query then dns_results query else receive () in - receive ()) + receive ()) in + addresses, None with | Eio.Cancel.Cancelled _ as cancelled -> raise cancelled - | _ -> []) + | Eio.Time.Timeout -> [], Some "DNS resolution timed out" + | Unix.Unix_error ((Unix.EACCES | Unix.EPERM), _, _) -> + [], Some "DNS resolver permission denied" + | Unix.Unix_error _ -> [], Some "DNS resolver I/O failed" + | _ -> [], Some "DNS resolver operation failed") in - let ipv6, ipv4 = Eio.Fiber.pair (fun () -> family true) (fun () -> family false) in - List.sort_uniq String.compare (ipv6 @ ipv4) - |> List.map (fun address -> `Tcp (Eio.Net.Ipaddr.of_raw address, port)) + let (ipv6, ipv6_error), (ipv4, ipv4_error) = Eio.Fiber.pair (fun () -> family true) (fun () -> family false) in + let addresses = List.sort_uniq String.compare (ipv6 @ ipv4) + |> List.map (fun address -> `Tcp (Eio.Net.Ipaddr.of_raw address, port)) in + addresses, (match ipv4_error with Some _ -> ipv4_error | None -> ipv6_error) ;; (* Eio may allocate a socket before connect raises. Keep each candidate in its @@ -78,7 +84,37 @@ let connect_address ~sw ~network ~clock ~deadline ~tcp_seconds config peer_name Eio.Promise.await result ;; -let connect ~sw ~authenticator ~network ~clock ~host ~port = +(* Never format exception payloads: transport context can contain addresses, + URLs and TLS peer metadata. This finite class is retained only for the final + failed address; trying subsequent addresses and their deadlines is unchanged. *) +let setup_class = function + | Unix.Unix_error (Unix.ECONNREFUSED, _, _) + | Eio.Io (Eio.Net.E (Eio.Net.Connection_failure (Eio.Net.Refused _)), _) -> + "connection refused" + | Eio.Time.Timeout + | Unix.Unix_error (Unix.ETIMEDOUT, _, _) + | Eio.Io (Eio.Net.E (Eio.Net.Connection_failure Eio.Net.Timeout), _) -> "dial timed out" + | Unix.Unix_error ((Unix.ENETUNREACH | Unix.EHOSTUNREACH), _, _) -> "network unreachable" + | Unix.Unix_error ((Unix.EACCES | Unix.EPERM), _, _) -> "permission denied" + | Unix.Unix_error ((Unix.ECONNRESET | Unix.EPIPE), _, _) + | Eio.Io (Eio.Net.E (Eio.Net.Connection_reset _), _) -> "connection reset" + | Tls_eio.Tls_alert _ -> "TLS peer alert" + | Tls_eio.Tls_failure _ -> "TLS handshake failed" + | Failure _ -> "operation raised Failure" + | Invalid_argument _ -> "operation raised Invalid_argument" + | _ -> "external setup failure" +;; + +let safe_setup_detail source = + let prefixes = ["all resolved addresses failed: "; "TLS connection setup failed: "] in + let classes = ["connection refused"; "dial timed out"; "network unreachable"; + "permission denied"; "connection reset"; "TLS peer alert"; "TLS handshake failed"; + "operation raised Failure"; "operation raised Invalid_argument"; "external setup failure"] in + if List.exists (fun prefix -> List.exists (fun value -> source = prefix ^ value) classes) + prefixes then Some source else None +;; + +let connect_diagnosed ~on_diagnostic ~sw ~authenticator ~network ~clock ~host ~port = match host_name host with | Error _ as error -> error | Ok peer_name -> @@ -89,9 +125,9 @@ let connect ~sw ~authenticator ~network ~clock ~host ~port = | Ok config -> let deadline = Eio.Time.now clock +. 30. in (try - let addresses = resolve ~clock ~deadline host port in - let rec attempt = function - | [] -> Error (Setup_failed "all resolved addresses failed") + let addresses, dns_failure = resolve ~clock ~deadline host port in + let rec attempt last_failure = function + | [] -> Error (Setup_failed ("all resolved addresses failed: " ^ last_failure)) | address :: rest -> let remaining = deadline -. Eio.Time.now clock in if remaining <= 0. @@ -112,10 +148,17 @@ let connect ~sw ~authenticator ~network ~clock ~host ~port = | Error (Eio.Cancel.Cancelled _ as cancelled) -> raise cancelled | Error (Tls_eio.Tls_failure (`Error (`AuthenticationFailure _))) -> Error Authentication_failed - | Error _ -> attempt rest) + | Error error -> attempt (setup_class error) rest) in - if addresses = [] then Error No_network_address else attempt addresses + if addresses = [] then ( + Option.iter (fun cause -> try on_diagnostic cause with _ -> ()) dns_failure; + Error No_network_address) + else attempt "external setup failure" addresses with | Eio.Cancel.Cancelled _ as cancelled -> raise cancelled - | _ -> Error (Setup_failed "TLS connection setup failed"))) + | error -> Error (Setup_failed ("TLS connection setup failed: " ^ setup_class error)))) +;; + +let connect ~sw ~authenticator ~network ~clock ~host ~port = + connect_diagnosed ~on_diagnostic:(fun _ -> ()) ~sw ~authenticator ~network ~clock ~host ~port ;; diff --git a/logseq_sync/lib/effect_runner/eio/tls_client_eio.mli b/logseq_sync/lib/effect_runner/eio/tls_client_eio.mli index 74cfbd8d..32b3ffec 100644 --- a/logseq_sync/lib/effect_runner/eio/tls_client_eio.mli +++ b/logseq_sync/lib/effect_runner/eio/tls_client_eio.mli @@ -14,3 +14,17 @@ val connect -> host:string -> port:int -> (Tls_eio.t, error) result + +val safe_setup_detail : string -> string option + +(** Reports a finite DNS cause only if every address family fails. Native empty + results without an exception have no invented cause; variants are unchanged. *) +val connect_diagnosed + : on_diagnostic:(string -> unit) + -> sw:Eio.Switch.t + -> authenticator:X509.Authenticator.t + -> network:_ Eio.Net.t + -> clock:_ Eio.Time.clock + -> host:string + -> port:int + -> (Tls_eio.t, error) result diff --git a/logseq_sync/lib/effect_runner/eio/websocket_eio.ml b/logseq_sync/lib/effect_runner/eio/websocket_eio.ml index 1899fc6a..cf3494ef 100644 --- a/logseq_sync/lib/effect_runner/eio/websocket_eio.ml +++ b/logseq_sync/lib/effect_runner/eio/websocket_eio.ml @@ -506,8 +506,10 @@ let connect in let setup () = let host = Option.get (Uri.host uri) in + let dns_failure = ref None in match - Tls_client_eio.connect + Tls_client_eio.connect_diagnosed + ~on_diagnostic:(fun source -> dns_failure := Some source) ~sw:owner ~authenticator ~network @@ -517,7 +519,26 @@ let connect with | Error Tls_client_eio.Authentication_failed -> Error (Authentication_failed "WebSocket TLS authentication failed") - | Error _ -> Error (Connection_failed "WebSocket TLS setup failed") + | Error Tls_client_eio.Invalid_dns_host -> + Error (Connection_failed "WebSocket TLS setup failed: invalid DNS host") + | Error Tls_client_eio.No_network_address -> + Error (Connection_failed ("WebSocket TLS setup failed: no network address" + ^ Option.fold ~none:"" ~some:(fun source -> " (" ^ source ^ ")") !dns_failure)) + | Error (Tls_client_eio.Setup_failed "invalid TLS configuration") -> + Error + (Connection_failed + "WebSocket TLS setup failed: invalid TLS configuration") + | Error (Tls_client_eio.Setup_failed "all resolved addresses failed") -> + Error + (Connection_failed + "WebSocket TLS setup failed: all resolved addresses failed") + | Error (Tls_client_eio.Setup_failed "DNS/TCP dial timed out") -> + Error + (Connection_failed "WebSocket TLS setup failed: DNS/TCP dial timed out") + | Error (Tls_client_eio.Setup_failed source) -> + Error (Connection_failed (match Tls_client_eio.safe_setup_detail source with + | Some detail -> "WebSocket TLS setup failed: " ^ detail + | None -> "WebSocket TLS setup failed")) | Ok flow -> Eio.Time.with_timeout_exn clock 30. (fun () -> let opened, resolve_opened = Eio.Promise.create () in @@ -534,7 +555,9 @@ let connect (match Httpun.Status.to_code response.Httpun.Response.status with | 401 -> Unauthorized | 403 -> Forbidden - | _ -> Connection_failed "WebSocket Upgrade rejected") + | status -> + Connection_failed + (Printf.sprintf "WebSocket Upgrade rejected (HTTP %d)" status)) | `Malformed_response _ | `Invalid_response_body_length _ | `Exn _ -> Protocol_failed "WebSocket protocol failed" in diff --git a/logseq_sync/lib/effect_runner/platform/platform_crypto.ml b/logseq_sync/lib/effect_runner/platform/platform_crypto.ml index 6ec70847..eedead6b 100644 --- a/logseq_sync/lib/effect_runner/platform/platform_crypto.ml +++ b/logseq_sync/lib/effect_runner/platform/platform_crypto.ml @@ -55,6 +55,8 @@ type request = type response = { ok : bool ; error : string option [@default None] + ; reason : string option [@default None] + ; cleanup_pending : bool option [@key "cleanupPending"] [@default None] ; value : string option [@default None] ; wrapped_keys_deleted : bool option [@key "wrappedKeysDeleted"] [@default None] ; private_key_deleted : bool option [@key "privateKeyDeleted"] [@default None] @@ -63,21 +65,68 @@ type response = } [@@deriving yojson] -let invoke request = +let crypto_error_codes = + [ "cryptoInvalidRequest"; "cryptoAuthenticationFailed"; "cryptoInvalidPrivateKey" + ; "cryptoRSADecryptionFailed"; "cryptoInvalidGraphKeyLength"; "cryptoKeyStoreLocked" + ; "cryptoKeyStorePermissionDenied"; "cryptoKeyStoreUnavailable"; "cryptoKeyStoreDecodeFailed" + ; "cryptoKeyStoreFailed"; "cryptoKeyStoreInvalidType"; "cryptoOperationFailed"; "wrappedGraphKeyUnavailable" + ; "cryptoInvalidAESInput"; "cryptoEnginePrivateKeyUnavailable"; "cryptoAllocationFailed" + ; "cryptoNativeUnavailable"; "cryptoNativeResponseUnavailable" + ; "platform crypto unavailable" ] +;; + +let diagnostic_message ~reason:reason_name source = + Logseq_db_types.Failure_diagnostic.( + of_source ~operation:"platformCrypto" ~reason:reason_name source |> message) +;; + +let response_context fields = + let reason = match fields.reason with + | Some ("wrapped-key-absent" | "wrapped-key-cache-invalid" as reason) -> Some reason + | Some value when List.exists (fun code -> + value = "wrapped-key-verification-failed:" ^ code) crypto_error_codes -> Some value + | _ -> None in + let context = Option.fold ~none:"" ~some:(fun value -> " Context: " ^ value ^ ".") reason in + context ^ if fields.cleanup_pending = Some true then " Wrapped-key cleanup remains pending." else "" +;; + +type invocation_failure = { recovery_code : string option; diagnostic : string } + +let failure ?recovery_code diagnostic = Error { recovery_code; diagnostic } + +let invoke_response request = try - match - let request = { request with diagnostic_operation = - (if Sys.getenv_opt "JOURNAL_UNLOCK_DIAGNOSTICS" = Some "1" - then Eio.Fiber.get diagnostic_operation else None) } in - call_raw (request_to_yojson request |> Yojson.Safe.to_string) - |> Yojson.Safe.from_string - |> Logseq_db_types.Json_codec.decode response_of_yojson - with - | Ok { ok = true; _ } as result -> result - | Ok { ok = false; error = Some error; _ } -> Error error - | Ok _ | Error _ -> Error "platform crypto operation failed" + let request = { request with diagnostic_operation = + (if Sys.getenv_opt "JOURNAL_UNLOCK_DIAGNOSTICS" = Some "1" + then Eio.Fiber.get diagnostic_operation else None) } in + let source = call_raw (request_to_yojson request |> Yojson.Safe.to_string) in + let decoded = try Ok (Yojson.Safe.from_string source) with + | Yojson.Json_error _ -> failure (diagnostic_message ~reason:"parseFailed" + "platform crypto response JSON is invalid") in + Result.bind decoded (fun json -> + match Logseq_db_types.Json_codec.decode response_of_yojson json with + | Ok { ok = true; _ } as result -> result + (* This exact error selects the existing local-key recovery branch. *) + | Ok ({ ok = false; error = Some "localPrivateKeyUnavailable"; _ } as fields) -> + failure ~recovery_code:"localPrivateKeyUnavailable" + ("local private key is unavailable" ^ response_context fields) + | Ok ({ ok = false; error = Some error; _ } as fields) -> + let source = if List.mem error crypto_error_codes then error + else "platform crypto provider failed" in + let reason = if List.mem error ["platform crypto unavailable"; "cryptoNativeUnavailable"; + "cryptoNativeResponseUnavailable"] then "unavailable" else "cryptoInvalid" in + failure (diagnostic_message ~reason source ^ response_context fields) + | Ok _ -> failure (diagnostic_message ~reason:"cryptoInvalid" + "platform crypto response error is missing") + | Error _ -> failure (diagnostic_message ~reason:"parseFailed" + "platform crypto response shape is invalid")) with - | _ -> Error "platform crypto is unavailable" + | error -> failure (diagnostic_message ~reason:"unavailable" (Printexc.to_string error)) +;; + +let invoke request = + invoke_response request |> Result.map_error (fun failure -> + Option.value failure.recovery_code ~default:failure.diagnostic) ;; let bind result f = @@ -176,7 +225,7 @@ type wrapped_key_load_failure = let load_wrapped_graph_key ~managed_sync_origin ~user_id ~graph_id = match - invoke + invoke_response (graph_identity_request "loadAndVerifyWrappedGraphKey" ~managed_sync_origin @@ -187,9 +236,9 @@ let load_wrapped_graph_key ~managed_sync_origin ~user_id ~graph_id = Result.map_error (fun diagnostic -> Wrapped_graph_key_unavailable diagnostic) (string_field fields) - | Error "localPrivateKeyUnavailable" -> - Error (Local_private_key_unavailable "local private key is unavailable") - | Error diagnostic -> Error (Wrapped_graph_key_unavailable diagnostic) + | Error { recovery_code = Some "localPrivateKeyUnavailable"; diagnostic } -> + Error (Local_private_key_unavailable diagnostic) + | Error failure -> Error (Wrapped_graph_key_unavailable failure.diagnostic) ;; let verify_and_save_wrapped_graph_key @@ -212,7 +261,16 @@ let verify_and_save_wrapped_graph_key ;; let delete_account_secrets ~managed_sync_origin ~user_id = - bind + let result = bind (invoke (identity_request "deleteAccountSecrets" ~managed_sync_origin ~user_id)) - (fun _ -> Ok ()) + (fun _ -> Ok ()) in + (match result with + | Ok () -> () + | Error diagnostic -> + (* [invoke] has already reduced native data to a bounded safe message. + Secondary account cleanup retains its original completion strategy. *) + (try Printf.eprintf + "journal: phase=cleanup operation=deleteAccountSecrets code=cleanupPending detail=%s\n%!" + diagnostic with _ -> ())); + result ;; diff --git a/logseq_sync/lib/effect_runner/platform/platform_crypto_stubs.c b/logseq_sync/lib/effect_runner/platform/platform_crypto_stubs.c index a069ee86..6fbaa9fc 100644 --- a/logseq_sync/lib/effect_runner/platform/platform_crypto_stubs.c +++ b/logseq_sync/lib/effect_runner/platform/platform_crypto_stubs.c @@ -2,6 +2,7 @@ # define _GNU_SOURCE #endif +#include #include #include #include @@ -19,7 +20,7 @@ CAMLprim value logseq_journal_crypto_call(value request) { char *request_copy = strdup(String_val(request)); char *response = NULL; if (request_copy == NULL) { - CAMLreturn(caml_copy_string("{\"ok\":false,\"error\":\"platform crypto unavailable\"}")); + CAMLreturn(caml_copy_string("{\"ok\":false,\"error\":\"cryptoAllocationFailed\"}")); } caml_enter_blocking_section(); logseq_journal_crypto_fn crypto = @@ -30,7 +31,9 @@ CAMLprim value logseq_journal_crypto_call(value request) { caml_leave_blocking_section(); free(request_copy); if (response == NULL) { - CAMLreturn(caml_copy_string("{\"ok\":false,\"error\":\"platform crypto unavailable\"}")); + CAMLreturn(caml_copy_string(crypto == NULL + ? "{\"ok\":false,\"error\":\"cryptoNativeUnavailable\"}" + : "{\"ok\":false,\"error\":\"cryptoNativeResponseUnavailable\"}")); } result = caml_copy_string(response); free(response); @@ -41,7 +44,7 @@ CAMLprim value logseq_journal_crypto_call(value request) { The Swift callee returns malloc-owned bytes; NULL denotes failure. */ typedef unsigned char *(*logseq_journal_binary_fn)( int, const unsigned char *, size_t, const unsigned char *, size_t, - const unsigned char *, size_t, size_t *); + const unsigned char *, size_t, intptr_t *); CAMLprim value logseq_journal_crypto_binary_call( value encrypt, value key, value iv, value input) { @@ -53,11 +56,13 @@ CAMLprim value logseq_journal_crypto_binary_call( const size_t iv_length = caml_string_length(iv); const size_t input_length = caml_string_length(input); unsigned char *response = NULL; - size_t response_length = 0; + intptr_t response_length = 0; + const char *failure = "platform binary crypto invalid request"; if (key_length == 32 && iv_length == (sealing ? 0 : 12) && input_length <= limit + (sealing ? 0 : 16) && (sealing || input_length >= 16)) { unsigned char *copy = malloc(key_length + iv_length + input_length); + failure = "platform binary crypto allocation failed"; if (copy != NULL) { memcpy(copy, String_val(key), key_length); memcpy(copy + key_length, String_val(iv), iv_length); @@ -65,23 +70,37 @@ CAMLprim value logseq_journal_crypto_binary_call( caml_enter_blocking_section(); logseq_journal_binary_fn crypto = (logseq_journal_binary_fn) dlsym(RTLD_DEFAULT, "logseq_journal_crypto_binary"); + failure = "platform binary crypto unavailable"; if (crypto != NULL) { response = crypto(sealing, copy, key_length, copy + key_length, iv_length, copy + key_length + iv_length, input_length, &response_length); + failure = "platform binary crypto operation failed"; + if (response == NULL) { + switch (response_length) { + case -1: failure = "platform binary crypto invalid request"; break; + case -2: failure = "platform binary crypto authentication failed"; break; + case -3: failure = "platform binary crypto allocation failed"; break; + default: break; /* Includes older native implementations returning zero. */ + } + } } caml_leave_blocking_section(); memset(copy, 0, key_length); free(copy); } } - const size_t expected = sealing ? input_length + 28 : input_length - 16; - if (response == NULL || response_length != expected) { - free(response); - payload = caml_copy_string("platform binary crypto failed"); + /* Check signed sentinels before converting lengths or touching native bytes. */ + if (response == NULL) { + payload = caml_copy_string(failure); result = caml_alloc(1, 1); /* Error */ + } else if (response_length < 0 || + (size_t)response_length != (sealing ? input_length + 28 : input_length - 16)) { + free(response); + payload = caml_copy_string("platform binary crypto invalid response"); + result = caml_alloc(1, 1); } else { - payload = caml_alloc_initialized_string(response_length, (const char *)response); + payload = caml_alloc_initialized_string((size_t)response_length, (const char *)response); free(response); result = caml_alloc(1, 0); /* Ok */ } diff --git a/logseq_sync/lib/effect_runner/protocol/e2ee.ml b/logseq_sync/lib/effect_runner/protocol/e2ee.ml index bd4da370..82750cd7 100644 --- a/logseq_sync/lib/effect_runner/protocol/e2ee.ml +++ b/logseq_sync/lib/effect_runner/protocol/e2ee.ml @@ -18,13 +18,20 @@ let bind result f = | Error _ as error -> error ;; -let protect operation f = +let protect parse_operation f = + let operation_name = match parse_operation with + | "decode protected value" | "decode decrypted value" -> "decryptValue" + | _ -> "platformCrypto" in + let parse_failure () = + Error (Logseq_db_types.Failure_diagnostic.( + create ~operation:operation_name ~reason:"parseFailed" + ~source:(parse_operation ^ " failed") () |> message)) in try Ok (f ()) with - | Transit.Decode_error message - | Yojson.Json_error message - | Failure message - | Invalid_argument message -> Error (operation ^ ": " ^ message) - | error -> Error (operation ^ ": " ^ Printexc.to_string error) + | Transit.Decode_error _ | Yojson.Json_error _ | Failure _ | Invalid_argument _ -> + parse_failure () + | error -> Error (Logseq_db_types.Failure_diagnostic.( + of_source ~operation:operation_name ~reason:"parseFailed" + (Printexc.to_string error) |> message)) ;; let bounded_string field value = @@ -55,7 +62,8 @@ let graph_key_response source = bind (Logseq_db_types.Json_codec.decode graph_key_response_dto_of_yojson - (Yojson.Safe.from_string source)) + (Yojson.Safe.from_string source) + |> Result.map_error (fun _ -> "E2EE graph key response shape is invalid")) (fun dto -> (* The optional compatibility field is validated by PPX but not consumed. *) let _ = dto.encrypted_aes_key_base64 in @@ -69,7 +77,8 @@ let user_keys_response source = bind (Logseq_db_types.Json_codec.decode user_keys_response_dto_of_yojson - (Yojson.Safe.from_string source)) + (Yojson.Safe.from_string source) + |> Result.map_error (fun _ -> "E2EE user keys response shape is invalid")) (fun dto -> bind (bounded_string "public-key" dto.public_key) (fun _ -> bounded_string "encrypted-private-key" dto.encrypted_private_key)) @@ -103,10 +112,15 @@ let decrypt_value ~crypto ~graph_key source = | Error _ -> Ok (Transit.String source) | Ok (Transit.Array [ Transit.Binary iv; Transit.Binary ciphertext ]) -> (match crypto.decrypt_aes_gcm ~key:graph_key ~iv ~ciphertext with - | Error _ -> Error "The graph value could not be decrypted." + | Error source -> + Error (Logseq_db_types.Failure_diagnostic.( + of_source ~operation:"decryptValue" ~reason:"cryptoInvalid" source |> encode)) | Ok plaintext -> (match protect "decode decrypted value" (fun () -> Codec.of_string plaintext) with | Ok value -> Ok value - | Error _ -> Error "The decrypted graph value is not valid Transit.")) + | Error _ -> + Error (Logseq_db_types.Failure_diagnostic.( + create ~operation:"decryptValue" ~reason:"parseFailed" + ~source:"decrypted Transit is invalid" () |> encode)))) | Ok plaintext -> Ok plaintext ;; diff --git a/logseq_sync/lib/effect_runner/storage/bootstrap.ml b/logseq_sync/lib/effect_runner/storage/bootstrap.ml index a3d6f483..58d4eabe 100644 --- a/logseq_sync/lib/effect_runner/storage/bootstrap.ml +++ b/logseq_sync/lib/effect_runner/storage/bootstrap.ml @@ -1,3 +1,9 @@ +module Failure_diagnostic = Logseq_db_types.Failure_diagnostic + +let failure_message ~reason:reason_name source = + Failure_diagnostic.(of_source ~operation:"decodeSnapshot" ~reason:reason_name source |> message) +;; + type progress = { received_bytes : int ; total_bytes : int option @@ -20,7 +26,15 @@ let artifact_row_count headers = let remove_if_exists path = try Sys.remove path with - | Sys_error _ -> () + | Sys_error _ as error -> + (* Missing artifacts are normal idempotent cleanup. Preserve the original + unit completion if an artifact remains, but retain a safe support cause. *) + if Sys.file_exists path then ( + let detail = Failure_diagnostic.of_source ~operation:"decodeSnapshot" + ~reason:"cleanupFailed" (Printexc.to_string error) in + try Printf.eprintf + "journal: phase=cleanup operation=removeSnapshotArtifact code=cleanupPending source=%s\n%!" + (Failure_diagnostic.source_class detail) with _ -> ()) ;; let cleanup = List.iter remove_if_exists @@ -38,7 +52,8 @@ let gzip_signature path = with | End_of_file -> Ok false) with - | Sys_error message -> Error message + | Sys_error _ as error -> + Error (failure_message ~reason:"resourceFailed" (Printexc.to_string error)) ;; let copy_file ~maximum_bytes source destination = @@ -71,12 +86,12 @@ let copy_file ~maximum_bytes source destination = loop ())); Ok () with - | Sys_error message -> + | Failure source when source = "snapshot artifact exceeds its decompressed bound" -> remove_if_exists destination; - Error message + Error "snapshot artifact exceeds its decompressed bound" | exception_ -> remove_if_exists destination; - Error (Printexc.to_string exception_) + Error (failure_message ~reason:"resourceFailed" (Printexc.to_string exception_)) ;; let nonempty path = @@ -91,9 +106,15 @@ let decompress ~decompress_gzip ~maximum_bytes source destination = | 0 -> remove_if_exists destination; Error "snapshot gzip layer is empty" - | _ -> + | status -> remove_if_exists destination; - Error "snapshot gzip layer is invalid" + Error (match status with + | 1 -> "snapshot gzip input could not be opened" + | 2 -> "snapshot gzip output could not be opened" + | 3 -> "snapshot gzip decode failed" + | 4 -> "snapshot gzip output write failed" + | 5 -> "snapshot gzip decompressed limit exceeded" + | _ -> "snapshot gzip layer is invalid") ;; let peel_gzip_layers ~decompress_gzip ~maximum_bytes ~source ~destination ~temporary_paths @@ -123,7 +144,8 @@ let peel_gzip_layers ~decompress_gzip ~maximum_bytes ~source ~destination ~tempo cleanup [ second ]; Ok () with - | Sys_error message -> fail message) + | Sys_error _ as error -> + fail (failure_message ~reason:"persistenceFailed" (Printexc.to_string error))) | Ok true -> (match decompress ~decompress_gzip ~maximum_bytes first second with | Error message -> fail message @@ -137,7 +159,8 @@ let peel_gzip_layers ~decompress_gzip ~maximum_bytes ~source ~destination ~tempo cleanup [ first ]; Ok () with - | Sys_error message -> fail message)))))) + | Sys_error _ as error -> + fail (failure_message ~reason:"persistenceFailed" (Printexc.to_string error)))))))) | [] | [ _ ] -> Error "two private gzip staging paths are required") ;; @@ -196,11 +219,21 @@ module Asset_cache = struct let protect f = try f () with - | Sys_error message | Failure message -> Error (Io message) - | Yojson.Json_error message -> Error (Invalid message) - | Yojson.Safe.Util.Type_error (message, _) -> Error (Invalid message) - | Unix.Unix_error (error, operation, _) -> - Error (Io (operation ^ ": " ^ Unix.error_message error)) + | Failure source as error -> + let diagnostic = Failure_diagnostic.of_source ~operation:"assetCache" + ~reason:"persistenceFailed" source in + let diagnostic = + if Failure_diagnostic.source_class diagnostic = "unknown" + && Option.is_none (Failure_diagnostic.decode source) + then Failure_diagnostic.of_source ~operation:"assetCache" ~reason:"persistenceFailed" + (Printexc.to_string error) + else diagnostic in + Error (Io (Failure_diagnostic.encode diagnostic)) + | (Sys_error _ | Unix.Unix_error _) as error -> + Error (Io Failure_diagnostic.(of_source ~operation:"assetCache" + ~reason:"persistenceFailed" (Printexc.to_string error) |> encode)) + | Yojson.Json_error _ | Yojson.Safe.Util.Type_error _ -> + Error (Invalid "Asset cache metadata shape is invalid") ;; let rec mkdir path = @@ -285,34 +318,30 @@ module Asset_cache = struct let read_manifest directory file = let path = Filename.concat directory file in - let invalid () = Error (Invalid "Unsupported or invalid asset cache manifest") in + let invalid message = Error (Invalid message) in try if (Unix.stat path).st_size > 1024 - then invalid () + then invalid "Asset cache manifest exceeds size limit" else ( match Logseq_db_types.Json_codec.decode manifest_of_yojson (Yojson.Safe.from_file path) with - | Error _ -> invalid () + | Error _ -> invalid "Asset cache manifest shape is invalid" | Ok manifest -> - (match - Asset.version ~checksum:manifest.checksum ~file_type:manifest.file_type - with - | Ok version - when manifest.size >= 0L && name manifest.asset version ^ ".json" = file -> - Ok - { name = name manifest.asset version - ; file_type = version.file_type - ; checksum = version.checksum - ; size = manifest.size - ; touched = (Unix.stat path).st_mtime - ; pins = 0 - } - | Ok _ | Error _ -> invalid ())) + (match Asset.version ~checksum:manifest.checksum ~file_type:manifest.file_type with + | Error _ -> invalid "Asset cache manifest asset version is invalid" + | Ok _ when manifest.size < 0L -> invalid "Asset cache manifest size is invalid" + | Ok version when name manifest.asset version ^ ".json" <> file -> + invalid "Asset cache manifest filename does not match its identity" + | Ok version -> + Ok { name = name manifest.asset version; file_type = version.file_type + ; checksum = version.checksum; size = manifest.size + ; touched = (Unix.stat path).st_mtime; pins = 0 })) with - | Yojson.Json_error _ | Failure _ | Invalid_argument _ -> invalid () + | Yojson.Json_error _ -> invalid "Asset cache manifest JSON is invalid" + | Failure _ | Invalid_argument _ -> invalid "Asset cache manifest shape is invalid" ;; type account_identity = string * string [@@deriving to_yojson] @@ -442,6 +471,22 @@ module Asset_cache = struct handle ;; + let report_cleanup ?(code="collectionPending") operation error = + let source = match error with + | Invalid source | Io source -> source + | Full -> "Asset staging storage is full" + | Stale -> "Asset staging scope is stale" + | Checksum_mismatch -> "Asset staging checksum mismatch" in + let diagnostic = Failure_diagnostic.of_source ~operation:"assetRelease" + ~reason:"cleanupFailed" source in + Printf.eprintf "journal: phase=cleanup operation=%s code=%s source=%s\n%!" + operation code (Failure_diagnostic.source_class diagnostic) + ;; + + let report_cleanup_exception ?code operation error = + report_cleanup ?code operation (Io (Printexc.to_string error)) + ;; + let clean_retired_staging t record = let result = if record.retired && record.staged_pins = 0 then @@ -457,7 +502,7 @@ module Asset_cache = struct else Ok () in (match result with | Ok () -> () - | Error _ -> Printf.eprintf "journal: phase=cleanup operation=releaseRetiredStaging code=cleanupPending\n%!"); + | Error error -> report_cleanup ~code:"cleanupPending" "releaseRetiredStaging" error); result ;; @@ -599,7 +644,9 @@ module Asset_cache = struct if not (Sys.file_exists canonical) || identity canonical <> captured then failwith "canonical asset identity changed"; let error = rename_exclusive canonical data in - if error <> 0 then failwith ("asset retirement rename failed: " ^ string_of_int error); + if error <> 0 then ( + Printf.eprintf "journal: phase=cleanup operation=prepareAssetRetirement code=renameFailed errno=%d\n%!" error; + failwith "asset retirement rename failed"); sync_path (Filename.dirname canonical); sync_path (Filename.dirname data)); write_retirement manifest (retirement_json captured "prepared"); Ok ())) @@ -621,7 +668,7 @@ module Asset_cache = struct if state = "account-deleted" then ( (match remove_tree data with | Ok () -> () - | Error _ -> Printf.eprintf "journal: phase=cleanup operation=collectDeletedAccountAssets code=collectionPending\n%!"); Ok ()) + | Error error -> report_cleanup "collectDeletedAccountAssets" error); Ok ()) else if state = "aborted" || state = "complete" then Ok () else if commit then ( if Sys.file_exists data && identity data <> captured then failwith "retired asset identity changed"; @@ -629,11 +676,11 @@ module Asset_cache = struct (* The producer owns deferred collection. A purge failure must not block a replacement cache or undo the durable commit decision. *) (match remove_tree data with - | Error _ -> Printf.eprintf "journal: phase=cleanup operation=collectRetiredAssets code=collectionPending\n%!" + | Error error -> report_cleanup "collectRetiredAssets" error | Ok () -> (try sync_path (Filename.dirname data); - write_retirement manifest (retirement_json captured "complete") with _ -> - Printf.eprintf "journal: phase=cleanup operation=completeRetirement code=cleanupPending\n%!")); + write_retirement manifest (retirement_json captured "complete") with error -> + report_cleanup_exception ~code:"cleanupPending" "completeRetirement" error)); Ok ()) else if state = "committed" then Ok () else ( @@ -641,7 +688,9 @@ module Asset_cache = struct if identity data <> captured then failwith "retired asset identity changed"; mkdir (Filename.dirname canonical); let error = rename_exclusive data canonical in - if error <> 0 then failwith ("asset restore conflict; retirement retained: " ^ string_of_int error); + if error <> 0 then ( + Printf.eprintf "journal: phase=cleanup operation=restoreRetiredAssets code=renameFailed errno=%d\n%!" error; + failwith "asset restore conflict; retirement retained"); sync_path (Filename.dirname canonical); sync_path (Filename.dirname data)) else if captured <> "absent" && (not (Sys.file_exists canonical) || identity canonical <> captured) @@ -667,8 +716,8 @@ module Asset_cache = struct (match finish_graph_deletion ~root ~deletion:(Core.asset_deletion_reference ~scope ~retirement) ~commit:true with | Ok () -> () - | Error _ -> Printf.eprintf "journal: phase=cleanup operation=finishRetiredAssets code=collectionPending\n%!") - with _ -> Printf.eprintf "journal: phase=cleanup operation=collectRetiredAssets code=collectionPending\n%!") (Sys.readdir pending) + | Error error -> report_cleanup "finishRetiredAssets" error) + with error -> report_cleanup_exception "collectRetiredAssets" error) (Sys.readdir pending) ;; let delete_account ~root ~account = @@ -688,13 +737,20 @@ module Asset_cache = struct let captured, _ = retirement_state manifest in write_retirement manifest (retirement_json captured "account-deleted"))) (Sys.readdir states); match remove_tree (Filename.concat folder "pending") with - | Ok () -> () | Error _ -> failwith "retired account assets remain") (Sys.readdir retired); + | Ok () -> () | Error error -> + report_cleanup "collectDeletedAccountAssets" error; + let source = match error with Invalid source | Io source -> source + | Full -> "Asset staging storage is full" + | Stale -> "Asset staging scope is stale" + | Checksum_mismatch -> "Asset staging checksum mismatch" in + failwith Failure_diagnostic.(of_source ~operation:"assetRelease" + ~reason:"cleanupFailed" source |> encode)) (Sys.readdir retired); remove_tree directory) ;; let create ~root ~(scope : Core.graph_scope) ~budget_bytes ~maximum_file_bytes = - (try collect_retired_assets ~root ~scope with _ -> - Printf.eprintf "journal: phase=cleanup operation=collectRetiredAssets code=collectionPending\n%!"); + (try collect_retired_assets ~root ~scope with error -> + report_cleanup_exception "collectRetiredAssets" error); protect (fun () -> if budget_bytes < 0L diff --git a/logseq_sync/lib/pure_reducer/core.ml b/logseq_sync/lib/pure_reducer/core.ml index 726566d4..b13f3cd7 100644 --- a/logseq_sync/lib/pure_reducer/core.ml +++ b/logseq_sync/lib/pure_reducer/core.ml @@ -891,6 +891,7 @@ type t = ; submission_owner : submission_owner option ; pull_owner : pull_owner option ; pull_wanted : (graph_scope * Server_cursor.t) option + ; diagnostic_remote_cursor : (graph_scope * Server_cursor.t) option ; pull_retry_delay : float ; active_authoritative_batch : authoritative_batch option ; queued_authoritative_batch : authoritative_batch option @@ -967,6 +968,7 @@ let initial config = ; pending_outbox_transition = None ; pull_owner = None ; pull_wanted = None + ; diagnostic_remote_cursor = None ; pull_retry_delay = 1. ; submission_owner = None ; active_authoritative_batch = None @@ -1018,7 +1020,13 @@ let set_snapshot core (snapshot : snapshot) = then { groups = [] } else core.public_state.diagnostics in - { core with public_state = { snapshot; diagnostics } } + let diagnostic_remote_cursor = + if previous.account_generation <> snapshot.startup.account_generation + || previous.graph_generation <> snapshot.startup.graph_generation + then None + else core.diagnostic_remote_cursor + in + { core with public_state = { snapshot; diagnostics }; diagnostic_remote_cursor } ;; let network_allowed core = @@ -1792,7 +1800,20 @@ let plan_submission core = | _ -> unchanged core ;; +(* Retained observation only; synchronization still uses its existing owners and targets. *) +let note_remote_cursor core graph through = + let diagnostic_remote_cursor = + match core.diagnostic_remote_cursor, through with + | Some (scope, old), Some cursor when scope = graph -> + Some (graph, if Server_cursor.compare old cursor >= 0 then old else cursor) + | _, Some cursor -> Some (graph, cursor) + | previous, None -> previous + in + { core with diagnostic_remote_cursor } +;; + let note_pull_target core graph through = + let core = note_remote_cursor core graph through in let previous = match core.pull_wanted with | Some (scope, target) when scope = graph -> Some target @@ -2088,6 +2109,7 @@ let apply_accept core owner t checksum_value = (match checksum checksum_value with | Error _ -> fail core During_catalog "invalid acceptance checksum" | Ok checksum -> + let core = note_remote_cursor core owner.connection.graph (Some t) in let request = { expected = Overlay.sync_view_token sync ; key = core.graph_key @@ -2177,6 +2199,7 @@ let apply_rejection core owner rejection = (match resolution with | Error message -> fail core During_catalog message | Ok resolution -> + let core = note_remote_cursor core owner.connection.graph rejection.t in let request = { expected = Overlay.sync_view_token sync ; key = core.graph_key @@ -2651,6 +2674,7 @@ let complete_runner : type a. t -> a effect_ticket -> a -> transition = | Fetch_snapshot_metadata_kind -> (match decode_snapshot_uri value, core.bootstrap with | Ok (uri, metadata_cursor), Awaiting_snapshot_metadata (scope, attempt_id) -> + let core = note_remote_cursor core scope (Some metadata_cursor) in let request = { scope; uri; metadata_cursor; attempt_id; download_id = core.next_effect_id ; expected_bytes = None @@ -2737,34 +2761,34 @@ let consume_completion core = function (match result with | Ok value -> complete_runner core ticket value | Error (Effect_failed message | Crypto_failed (_, message)) -> + let source = message in + let message = match Logseq_db_types.Failure_diagnostic.decode source with + | Some diagnostic -> Logseq_db_types.Failure_diagnostic.message diagnostic + | None -> message in (match ticket.kind with | Save_catalog_kind -> + (* Keep the existing advisory/deletion outcome. Only the safe + diagnostics projection retains the producer's specific cause. *) + let diagnostic = Logseq_db_types.Failure_diagnostic.of_source + ~operation:"saveCatalog" ~reason:"persistenceFailed" source in + let group = + { title = "Sync errors" + ; entries = + [ "saveCatalog", + "saveCatalog · phase=catalog · code=catalog.save.failed · " ^ + "Local graph selection could not be saved. Retry by " ^ + "refreshing or selecting the graph again. " ^ + Logseq_db_types.Failure_diagnostic.message diagnostic + ] + } in + let next = { core with public_state = + { core.public_state with diagnostics = + { groups = group :: List.filter + (fun g -> g.title <> group.title) core.public_state.diagnostics.groups } } } in (match core.public_state.snapshot.local_deletion with | Some (Deletion_in_progress Clearing_selection) -> - deletion_failed core Clearing_selection - | None | Some _ -> - (* Advisory failure does not invalidate the open graph. Retain a - safe report until this scope ends; never expose an IO path. *) - let group = - { title = "Sync errors" - ; entries = - [ "saveCatalog", - "saveCatalog · phase=catalog · code=catalog.save.failed · \ - Local graph selection could not be saved. Retry by \ - refreshing or selecting the graph again." - ] - } - in - let next = - { core with public_state = - { core.public_state with diagnostics = - { groups = group :: List.filter - (fun g -> g.title <> group.title) - core.public_state.diagnostics.groups } - } - } - in - { next; effects = [ publish next ] }) + deletion_failed next Clearing_selection + | None | Some _ -> { next; effects = [ publish next ] }) | Load_catalog_kind -> fail { core with @@ -3928,3 +3952,48 @@ let step core event = else unchanged next | _ -> enforce_websocket_owner core (step_business core event) ;; + +let with_sync_cursor_diagnostics core (state : state) = + let title = "Sync transactions" in + let other_groups = List.filter (fun group -> group.title <> title) state.diagnostics.groups in + let groups = + match state.snapshot.selected_graph with + | None -> other_groups + | Some graph_id -> + let remote = + match core.diagnostic_remote_cursor with + | Some (scope, cursor) + when scope.graph_id = graph_id + && scope.account.account_generation = state.snapshot.startup.account_generation + && scope.graph_generation = state.snapshot.startup.graph_generation -> Some cursor + | _ -> None + in + let format = Option.fold ~none:"Not available" + ~some:(fun cursor -> Int64.to_string (Server_cursor.to_int64 cursor)) in + { title + ; entries = + [ "Local tx", format state.snapshot.applied_server_t + ; "Remote tx", format remote + ] + } :: other_groups + in + { state with diagnostics = { groups } } +;; + +let step_sync = step + +let step core event = + let transition = step_sync core event in + let public_state = with_sync_cursor_diagnostics transition.next transition.next.public_state in + let next = { transition.next with public_state } in + let effects = List.map (function + | Publish (State_changed state) -> Publish (State_changed (with_sync_cursor_diagnostics next state)) + | instruction -> instruction) transition.effects in + let effects = + if public_state.diagnostics <> core.public_state.diagnostics + && not (List.exists (function Publish (State_changed _) -> true | _ -> false) effects) + then effects @ [ publish next ] + else effects + in + { next; effects } +;; diff --git a/logseq_sync/spec/effect_runner/effect_runner.mli b/logseq_sync/spec/effect_runner/effect_runner.mli index 13b280eb..b0889ed1 100644 --- a/logseq_sync/spec/effect_runner/effect_runner.mli +++ b/logseq_sync/spec/effect_runner/effect_runner.mli @@ -112,7 +112,12 @@ val dependencies -> (dependencies, dependency_error) result val create - : sw:Eio.Switch.t + : ?on_asset_diagnostic:(scope:Logseq_sync_pure_reducer.Core.graph_scope + -> operation:string + -> request_operation:string + -> Logseq_db_types.Failure_diagnostic.t + -> unit) + -> sw:Eio.Switch.t -> dependencies -> post:(Logseq_sync_pure_reducer.Core.event -> unit) -> (t, create_error) result diff --git a/logseq_sync/test/core_contract.ml b/logseq_sync/test/core_contract.ml index 4834adfc..1f49f5d2 100644 --- a/logseq_sync/test/core_contract.ml +++ b/logseq_sync/test/core_contract.ml @@ -1588,6 +1588,10 @@ let whole_batch_invalid_tx_partitions_every_member () = ;; let canonical_overlay_happy_path () = + let cursor_diagnostics local remote : Core.diagnostics = + { groups = [ { Core.title = "Sync transactions" + ; entries = [ "Local tx", local; "Remote tx", remote ] } ] } + in let origin = initial () in let initial_state = Core.state origin in let authenticated_startup = @@ -1682,8 +1686,8 @@ let canonical_overlay_happy_path () = } in let selected_state = - { catalogued_state with - snapshot = + { Core.diagnostics = cursor_diagnostics "Not available" "Not available" + ; snapshot = { catalogued_state.snapshot with selected_graph = Some graph_id ; startup = selected_startup @@ -1739,8 +1743,8 @@ let canonical_overlay_happy_path () = { selected_startup with restoring_local = false; bootstrapping = false; local_access=Core.Local_available } in let attached_state = - { selected_state with - snapshot = + { Core.diagnostics = cursor_diagnostics "0" "Not available" + ; snapshot = { selected_state.snapshot with sync_phase = Core.Current ; applied_server_t = Some (server_cursor_of_int 0) @@ -1814,10 +1818,15 @@ let canonical_overlay_happy_path () = in let hp09_event = Core.Websocket_message (connection, opening_message) in let hp09_preview = preview_step "HP09" hp08.next hp09_event in + let pulling_remote1_state = + { pulling0_state with diagnostics = cursor_diagnostics "0" "1" } + in let opening_batch = match hp09_preview.effects with - | [ Core.Delegate (Core.Apply_authoritative_batch request) ] + | [ Core.Delegate (Core.Apply_authoritative_batch request) + ; Core.Publish (Core.State_changed state) ] when request.scope = connection + && state = pulling_remote1_state && request.presentation_generation = 2 && request.lifecycle_generation = 0L && request.key = None @@ -1833,8 +1842,9 @@ let canonical_overlay_happy_path () = "HP09" hp08.next hp09_event - pulling0_observed - [ Core.Delegate (Core.Apply_authoritative_batch opening_batch) ] + { pulling0_observed with state = pulling_remote1_state } + [ Core.Delegate (Core.Apply_authoritative_batch opening_batch) + ; Core.Publish (Core.State_changed pulling_remote1_state) ] in let generation1 = token Overlay.Generation.of_string "generation:v1:hp-1" in let projection1 = token Overlay.Projection_revision.of_string "projection:v1:1" in @@ -1854,8 +1864,8 @@ let canonical_overlay_happy_path () = } in let current1_state = - { pulling0_state with - snapshot = + { Core.diagnostics = cursor_diagnostics "1" "1" + ; snapshot = { pulling0_state.snapshot with sync_phase = Core.Current ; applied_server_t = Some (server_cursor_of_int 1) @@ -2009,10 +2019,15 @@ let canonical_overlay_happy_path () = { t = server_cursor_of_int 2; checksum = Some "fedcba9876543210" } ) in let hp14_preview = preview_step "HP14" hp13.next hp14_event in + let acknowledged_state = + { submitting_state with diagnostics = cursor_diagnostics "1" "2" } + in let accept_request = match hp14_preview.effects with - | [ Core.Delegate (Core.Apply_outbox_transition request) ] + | [ Core.Delegate (Core.Apply_outbox_transition request) + ; Core.Publish (Core.State_changed state) ] when request.scope = graph_scope + && state = acknowledged_state && request.key = None && Overlay.sync_token_equal request.expected sync_token2 && request.transition @@ -2031,8 +2046,9 @@ let canonical_overlay_happy_path () = "HP14" hp13.next hp14_event - submitting_observed - [ Core.Delegate (Core.Apply_outbox_transition accept_request) ] + { submitting_observed with state = acknowledged_state } + [ Core.Delegate (Core.Apply_outbox_transition accept_request) + ; Core.Publish (Core.State_changed acknowledged_state) ] in let sync_token3 = token Overlay.sync_token_of_string "sync-token:v1:hp-3" in let accepted_descriptor = @@ -2056,8 +2072,8 @@ let canonical_overlay_happy_path () = } in let pulling1_state = - { submitting_state with - snapshot = { submitting_state.snapshot with sync_phase = Core.Pulling } + { Core.diagnostics = cursor_diagnostics "1" "2" + ; snapshot = { submitting_state.snapshot with sync_phase = Core.Pulling } } in let pulling1_observed = @@ -2133,8 +2149,8 @@ let canonical_overlay_happy_path () = } in let current2_state = - { pulling1_state with - snapshot = + { Core.diagnostics = cursor_diagnostics "2" "2" + ; snapshot = { pulling1_state.snapshot with sync_phase = Core.Current ; applied_server_t = Some (server_cursor_of_int 2) @@ -5963,3 +5979,150 @@ let scenarios = scenarios @ [ Alcotest.test_case "late activation success and failure cannot reopen failed bootstrap" `Quick failed_snapshot_activation_receipts_are_inert ] ;; + +let sync_cursor_diagnostic core label = + (Core.state core).diagnostics.groups + |> List.concat_map (fun (group : Core.diagnostic_group) -> group.entries) + |> List.assoc_opt label +;; + +let diagnostics_observe_remote_cursor_without_advancing_local () = + let selected, _ = selected_graph graph in + List.iter (fun label -> + Alcotest.(check (option string)) (label ^ " starts unknown") + (Some "Not available") (sync_cursor_diagnostic selected.next label)) + ["Local tx"; "Remote tx"]; + let opened, connection, _ = pull_fixture () in + let largest = Logseq_db_types.Server_cursor.of_int64 Int64.max_int |> Result.get_ok in + let observed = pull_message opened.next connection + (Protocol.Server.Hello { t = largest; checksum = None }) in + Alcotest.(check (option string)) "remote cursor retains all int64 digits" + (Some "9223372036854775807") (sync_cursor_diagnostic observed.next "Remote tx"); + Alcotest.(check (option string)) "server notification is not a local commit" + (Some "0") (sync_cursor_diagnostic observed.next "Local tx"); + Alcotest.(check bool) "new cursor is published while an existing pull is occupied" true + (List.exists (function + | Core.Publish (Core.State_changed state) -> + List.exists (fun (group : Core.diagnostic_group) -> + List.assoc_opt "Remote tx" group.entries = Some "9223372036854775807") state.diagnostics.groups + | _ -> false) observed.effects); + let lower = pull_message observed.next connection + (Protocol.Server.Changed { t = server_cursor_of_int 1 }) in + Alcotest.(check (option string)) "older notifications cannot lower known remote maximum" + (Some "9223372036854775807") (sync_cursor_diagnostic lower.next "Remote tx"); + let failed = pull_message lower.next connection + (Protocol.Server.Error { message = "synthetic server failure" }) in + Alcotest.(check (option string)) "failure retains the last server observation" + (Some "9223372036854775807") (sync_cursor_diagnostic failed.next "Remote tx"); + let catalog = refresh_catalog lower.next [graph; other_graph] in + let switched = Core.step catalog.next (Core.Graph_selected other_graph.graph_id) in + List.iter (fun label -> + Alcotest.(check (option string)) (label ^ " clears on graph switch") + (Some "Not available") (sync_cursor_diagnostic switched.next label)) + ["Local tx"; "Remote tx"]; + let stale = pull_message switched.next connection + (Protocol.Server.Changed { t = largest }) in + Alcotest.(check (option string)) "old graph messages cannot repopulate remote cursor" + (Some "Not available") (sync_cursor_diagnostic stale.next "Remote tx"); + let account = Core.step observed.next (Core.Account_authenticated {user_id=Some "other-user"}) in + Alcotest.(check (option string)) "account switch removes old graph cursor rows" + None (sync_cursor_diagnostic account.next "Remote tx") +;; + +let diagnostics_local_cursor_tracks_authoritative_commit () = + let opened, connection, _ = pull_fixture () in + let received = pull_response opened.next connection ~after:0 1 in + Alcotest.(check (option string)) "received authoritative data is not committed yet" + (Some "0") (sync_cursor_diagnostic received.next "Local tx"); + Alcotest.(check (option string)) "pull response observes the remote cursor" + (Some "1") (sync_cursor_diagnostic received.next "Remote tx"); + let sync = Overlay.sync_view + ~token:(token Overlay.sync_token_of_string "sync-token:v1:diagnostic-commit") + ~checkpoint:(server_cursor_of_int 1) ~submissions:[] in + let committed = recovery_apply received.next connection sync in + Alcotest.(check (option string)) "only authoritative completion advances local cursor" + (Some "1") (sync_cursor_diagnostic committed.next "Local tx"); + Alcotest.(check (option string)) "completed catchup retains remote cursor" + (Some "1") (sync_cursor_diagnostic committed.next "Remote tx"); + let selected, scope = selected_graph graph in + let inspected = Core.step selected.next + (Core.Mirror_inspected (Core.Mirror_available {graph;scope})) in + let largest = Logseq_db_types.Server_cursor.of_int64 Int64.max_int |> Result.get_ok in + let sync = Overlay.sync_view ~token:(Overlay.sync_view_token sync) + ~checkpoint:largest ~submissions:[] in + let attached = Core.step inspected.next (Core.Graph_attached {scope;sync}) in + Alcotest.(check (option string)) "restored checkpoint retains all int64 digits" + (Some "9223372036854775807") (sync_cursor_diagnostic attached.next "Local tx"); + Alcotest.(check (option string)) "local checkpoint does not invent server evidence" + (Some "Not available") (sync_cursor_diagnostic attached.next "Remote tx") +;; + +let scenarios = scenarios @ + [ Alcotest.test_case "diagnostics observes scoped remote cursor" `Quick + diagnostics_observe_remote_cursor_without_advancing_local + ; Alcotest.test_case "diagnostics local cursor follows durable commit" `Quick + diagnostics_local_cursor_tracks_authoritative_commit + ] +;; + +let diagnostics_snapshot_metadata_is_remote_evidence () = + let activating = snapshot_activation_fixture () in + Alcotest.(check (option string)) "snapshot metadata observes remote progress before activation" + (Some "42") (sync_cursor_diagnostic activating.next "Remote tx"); + Alcotest.(check (option string)) "download cannot publish an uncommitted local checkpoint" + (Some "Not available") (sync_cursor_diagnostic activating.next "Local tx") +;; + +let scenarios = scenarios @ [ Alcotest.test_case + "diagnostics snapshot metadata is remote evidence" `Quick + diagnostics_snapshot_metadata_is_remote_evidence ] +;; + +let diagnostics_rejection_observes_remote_cursor () = + let core, connection = submitted_core () in + let rejection : Protocol.rejection = + { reason = Stale; t = Some (server_cursor_of_int 7); checksum = None + ; success_tx_ids = []; failed_tx_id = None; missing_block_uuids = [] + ; error_detail = None; data = None } in + let rejected = pull_message core connection (Protocol.Server.Tx_reject rejection) in + Alcotest.(check (option string)) "valid stale rejection observes server progress" + (Some "7") (sync_cursor_diagnostic rejected.next "Remote tx"); + Alcotest.(check (option string)) "rejection does not commit authoritative changes" + (Some "0") (sync_cursor_diagnostic rejected.next "Local tx") +;; + +let scenarios = scenarios @ [ Alcotest.test_case + "diagnostics rejection observes remote cursor" `Quick + diagnostics_rejection_observes_remote_cursor ] +;; + +(* Catalog advisory diagnostics are owned by this public reducer. A valid + producer error is not an injected incorrect external result. *) +let catalog_save_keeps_the_safe_producer_cause () = + let module D = Logseq_db_types.Failure_diagnostic in + let selected, _ = selected_graph graph in + let event = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Save_catalog _)) -> + let diagnostic = D.create ~operation:"saveCatalog" ~reason:"persistenceFailed" + ~source:"Unix.Unix_error(Unix.ENOSPC,PRIVATE_PATH,PRIVATE_PATH)" () in + Some (Core.Runner_completed (Core.Completion (ticket, Error (Core.Effect_failed (D.encode diagnostic))))) + | _ -> None) selected.effects |> Option.get in + let transition = Core.step selected.next event in + ignore (check_replay "catalog-safe-error" selected.next event transition); + let before = Core.state selected.next and after = Core.state transition.next in + Alcotest.(check bool) "advisory failure preserves graph and startup state" true + (before.snapshot = after.snapshot); + let text = after.diagnostics.groups |> List.concat_map (fun (g:Core.diagnostic_group) -> List.map snd g.entries) + |> String.concat "\n" in + let contains text part = + let rec loop n = n + String.length part <= String.length text && + (String.sub text n (String.length part)=part || loop(n+1)) in loop 0 in + Alcotest.(check bool) "catalog advisory preserves original safe reason" true + (contains text "Persistence failed" && contains text "Storage has no free space"); + Alcotest.(check bool) "catalog advisory excludes raw path" false (contains text "PRIVATE_PATH"); + Alcotest.(check bool) "catalog advisory displays message, not a wire carrier" false + (contains text "journal-failure:v1:") +;; +let scenarios = scenarios @ [Alcotest.test_case "catalog save retains safe producer cause" `Quick + catalog_save_keeps_the_safe_producer_cause] +;; diff --git a/logseq_sync/test/pure_reducer_bad_case_06.ml b/logseq_sync/test/pure_reducer_bad_case_06.ml index e24adc53..c4b3be4f 100644 --- a/logseq_sync/test/pure_reducer_bad_case_06.ml +++ b/logseq_sync/test/pure_reducer_bad_case_06.ml @@ -42,7 +42,10 @@ let run () = in let requested = Core.step rejected.next request_event in (match requested.effects with - | [ Core.Delegate (Core.Apply_authoritative_batch _) ] -> () + | [ Core.Delegate (Core.Apply_authoritative_batch _) + ; Core.Publish (Core.State_changed state) ] + when List.exists (fun (group : Core.diagnostic_group) -> + List.assoc_opt "Remote tx" group.entries = Some "1") state.diagnostics.groups -> () | _ -> Alcotest.fail "BC06 did not create a valid authoritative owner"); let accepted = Core.step requested.next completion in match accepted.effects with diff --git a/logseq_sync/test/runner_contract.ml b/logseq_sync/test/runner_contract.ml index 66252bf2..a3cd0bf9 100644 --- a/logseq_sync/test/runner_contract.ml +++ b/logseq_sync/test/runner_contract.ml @@ -528,10 +528,25 @@ let test_cached_wrapped_key_is_unlocked_before_protected_value_decryption (Core.Decrypt_value (handle, ciphertext)) in if decryption_failure then Alcotest.(check bool) "crypto error excludes sensitive detail" true - (result = Error (Core.Effect_failed "The graph value could not be decrypted.")) + (match result with + | Error (Core.Effect_failed message) -> + let module D = Logseq_db_types.Failure_diagnostic in + (match D.decode message with + | Some detail -> D.operation detail = "decryptValue" + && D.reason detail = "cryptoInvalid" + && not (String.contains (D.message detail) 'S') + | None -> false) + | _ -> false) else if malformed_plaintext then Alcotest.(check bool) "decrypted parser error excludes plaintext" true - (result = Error (Core.Effect_failed "The decrypted graph value is not valid Transit.")) + (match result with + | Error (Core.Effect_failed message) -> + let module D = Logseq_db_types.Failure_diagnostic in + (match D.decode message with + | Some detail -> D.reason detail = "parseFailed" + && String.ends_with ~suffix:"decrypted Transit is invalid." (D.message detail) + | None -> false) + | _ -> false) else Alcotest.(check bool) "returned handle decrypts with the raw graph key" true (result = Ok (Core.Decrypted_value "plaintext"))))) @@ -1374,7 +1389,8 @@ let scenarios = ;; (* Migrated asset cache public runner scenarios. *) -let with_local_asset_cache ?(budget = 8L) ?(pending_budget = 8L) test = +let with_local_asset_cache ?(budget = 8L) ?(pending_budget = 8L) + ?on_asset_diagnostic ?(acquire = fun _ -> Ok "token") test = with_support (fun support -> Eio_main.run (fun environment -> Eio.Switch.run (fun sw -> @@ -1412,12 +1428,12 @@ let with_local_asset_cache ?(budget = 8L) ?(pending_budget = 8L) test = ~crypto:(crypto ()) ~id_token_provider: (Runner.id_token_provider - ~acquire:(fun _ -> Ok "token") + ~acquire ~invalidate:(fun _ ~token:_ -> ())) |> Result.get_ok in let runner = - Runner.create ~sw deps ~post:(fun event -> Queue.add event posted) + Runner.create ?on_asset_diagnostic ~sw deps ~post:(fun event -> Queue.add event posted) |> Result.get_ok in Fun.protect @@ -2201,8 +2217,9 @@ let test_legacy_manifest_is_rejected_without_cleanup () = "unsupported manifest fails cache open" true (match request (Core.Check_asset_cache (graph_id (), version)) with - | Error _ -> true - | Ok _ -> false); + | Error (Core.Asset_invalid_content message) -> + message = "Asset cache manifest shape is invalid" + | _ -> false); cache_bool "unsupported manifest is retained" true (Sys.file_exists manifest); Alcotest.(check string) "unsupported bytes are not rewritten" @@ -2321,3 +2338,120 @@ let test_staged_preview_cache_failure_is_not_missing_file () = let scenarios = scenarios @ [Alcotest.test_case "staged preview cache IO failure stays an error" `Quick test_staged_preview_cache_failure_is_not_missing_file] ;; + +(* The runner owns checksum verification after loading the staged source. A pure + completion would already contain the lost diagnostic rather than cause it. *) +let test_upload_checksum_cause_is_preserved () = + with_local_asset_cache (fun support request -> + let source = Filename.concat support "SENSITIVE-fixture.bin" in + Out_channel.with_open_bin source (fun out -> output_string out "file"); + let file, _, _ = cache_stage request source in + let version = Logseq_db_types.Asset_descriptor.version + ~checksum:(String.make 64 'a') ~file_type:"bin" |> Result.get_ok in + match request (Core.Put_asset_file + {asset=graph_id (); version; file; maximum_plaintext_bytes=4}) with + | Error (Core.Asset_invalid_content message) -> + Alcotest.(check string) "checksum stage survives" + "Asset upload checksum mismatch" message + | _ -> fail "checksum failure must retain invalid-content category") +;; +let scenarios = scenarios @ [Alcotest.test_case + "upload checksum cause is preserved" `Quick test_upload_checksum_cause_is_preserved] +;; + +let test_catalog_io_causes_exclude_paths () = + let execute ~prepare ~instruction ~expected = + with_support (fun support -> + prepare support; + Eio_main.run (fun environment -> Eio.Switch.run (fun sw -> + let tasks = ref [] and posted = ref [] in + let deps = dependencies ~environment ~support + ~fork:(fun ~sw:_ task -> tasks := task :: !tasks) () in + let runner = Runner.create ~sw deps + ~post:(fun event -> posted := event :: !posted) |> Result.get_ok in + Runner.submit runner (instruction ()); + List.iter (fun task -> task ()) (List.rev !tasks); + match !posted with + | [Core.Runner_completed (Core.Completion (_, Error (Core.Effect_failed message)))] -> + let message = match Logseq_db_types.Failure_diagnostic.decode message with + | Some detail -> Logseq_db_types.Failure_diagnostic.message detail + | None -> fail "catalog cause must be a safe forwarding carrier" in + Alcotest.(check bool) "safe operation and reason" true + (String.starts_with ~prefix:expected message); + Alcotest.(check bool) "no synthetic private path" false + (String.contains message '/'); + Runner.shutdown runner + | _ -> fail "expected one catalog failure"))) in + execute + ~prepare:(fun support -> + let worker = Filename.concat support "logseq-db-worker" in Unix.mkdir worker 0o700; + let catalogs = Filename.concat worker "sync-catalogs" in Unix.mkdir catalogs 0o700; + Unix.mkdir (Filename.concat catalogs + "748ac0b0c274b30bc6fc1da756958deab4ebdef06a2d6373a377e2b4d8cec6df.json") 0o700) + ~instruction:load_catalog_effect ~expected:"loadCatalog: Resource could not be opened"; + execute + ~prepare:(fun support -> Out_channel.with_open_bin + (Filename.concat support "logseq-db-worker") (fun out -> output_string out "private")) + ~instruction:(fun () -> + let authenticated = Core.step (core ()) (Core.Account_authenticated {user_id=Some "user-1"}) in + let catalog = List.find_map (function + | Core.Run (Core.Request (ticket, Core.Fetch_catalog _)) -> + Some (Core.step authenticated.next + (Core.Runner_completed (Core.Completion (ticket, Ok [encrypted_graph ()])))) + | _ -> None) authenticated.effects |> Option.get in + let selected = Core.step catalog.next (Core.Graph_selected (graph_id ())) in + List.find_map (function + | Core.Run (Core.Request (_, Core.Save_catalog _) as action) -> Some action + | _ -> None) selected.effects |> Option.get) + ~expected:"prepareCatalog: Storage path is invalid" +;; +let scenarios = scenarios @ [Alcotest.test_case + "catalog IO causes exclude paths" `Quick test_catalog_io_causes_exclude_paths] +;; + +let test_platform_crypto_unavailable_has_safe_cause () = + with_support (fun support -> Eio_main.run (fun environment -> Eio.Switch.run (fun sw -> + let tasks = ref [] and posted = ref [] in + let deps = dependencies ~secrets_dependency:(Runner.apple_secrets () |> Result.get_ok) + ~environment ~support ~fork:(fun ~sw:_ task -> tasks := task :: !tasks) () in + let runner = Runner.create ~sw deps ~post:(fun event -> posted := event :: !posted) + |> Result.get_ok in + let _, instruction = cached_key_effect () in + Runner.submit runner instruction; + List.iter (fun task -> task ()) (List.rev !tasks); + (match !posted with + | [Core.Runner_completed (Core.Completion (_, Error (Core.Effect_failed message)))] -> + Alcotest.(check bool) "provider finite cause survives public runner" true + (String.starts_with ~prefix:"platformCrypto: Resource is unavailable" message) + | _ -> fail "expected unavailable host crypto failure"); + Runner.shutdown runner))) +;; +let scenarios = scenarios @ [Alcotest.test_case + "platform crypto unavailable has safe cause" `Quick test_platform_crypto_unavailable_has_safe_cause] +;; + +let test_asset_authentication_cause_is_scoped () = + let reports = ref [] in + let on_asset_diagnostic ~scope ~operation ~request_operation detail = + reports := (scope, operation, request_operation, detail) :: !reports in + with_local_asset_cache ~on_asset_diagnostic + ~acquire:(fun _ -> Error "SENSITIVE-fixture-token-error") + (fun _ request -> + let version = Logseq_db_types.Asset_descriptor.version + ~checksum:(String.make 64 'a') ~file_type:"bin" |> Result.get_ok in + Alcotest.(check bool) "original authentication category" true + (request (Core.Fetch_asset {asset=graph_id (); version; maximum_plaintext_bytes=4}) + = Error Core.Asset_authentication); + match !reports with + | [(scope, operation, request_operation, detail)] -> + let module D = Logseq_db_types.Failure_diagnostic in + Alcotest.(check bool) "exact graph identity" true (scope.graph_id = graph_id ()); + Alcotest.(check string) "finite operation" "assetDownload" operation; + Alcotest.(check string) "internal request identity" "cache-1" request_operation; + Alcotest.(check string) "source category" "authenticationFailed" (D.reason detail); + Alcotest.(check bool) "no raw auth text" false (String.contains (D.message detail) 'S') + | _ -> fail "expected one scoped authentication cause") +;; +let scenarios = scenarios @ [Alcotest.test_case + "asset authentication cause is scoped" `Quick test_asset_authentication_cause_is_scoped] +;; diff --git a/logseq_sync/test/transport_contract.ml b/logseq_sync/test/transport_contract.ml index 5aea69ce..6b39a4ad 100644 --- a/logseq_sync/test/transport_contract.ml +++ b/logseq_sync/test/transport_contract.ml @@ -472,11 +472,13 @@ let runner ?(websocket_liveness = Runner.Disabled) ?network ?clock + ?(tls_authenticator = Runner.tls_authenticator (fun ?ip:_ ~host:_ _ -> Ok None)) ?secrets_dependency ?crypto_dependency ?(acquire = fun _ -> Ok "fixture-token") ?(on_invalidate = fun _ -> ()) ?(on_post = fun _ -> ()) + ?on_asset_diagnostic ?asset_cache_budget_bytes ?asset_maximum_file_bytes ?asset_pending_budget_bytes @@ -490,7 +492,7 @@ let runner let transport = Runner.transport ~websocket_liveness - ~tls_authenticator:(Runner.tls_authenticator (fun ?ip:_ ~host:_ _ -> Ok None)) + ~tls_authenticator ~network:(Option.value network ~default:(Eio.Stdenv.net environment)) ~clock:(Option.value clock ~default:(Eio.Stdenv.clock environment)) |> Result.get_ok @@ -522,7 +524,7 @@ let runner on_invalidate token)) |> Result.get_ok in - Runner.create ~sw dependencies ~post:(fun event -> + Runner.create ?on_asset_diagnostic ~sw dependencies ~post:(fun event -> on_post event; posted := !posted @ [ event ]) |> Result.get_ok @@ -594,6 +596,7 @@ let http_test ?retained_download ?(segment = 16384) ?(eof = false) + ?failure_message wire () = @@ -613,6 +616,10 @@ let http_test prerr_endline message | Ok () -> ()); Alcotest.(check bool) "response outcome" success (Result.is_ok result); + Option.iter (fun expected -> match result with + | Error (Core.Effect_failed actual) -> + Alcotest.(check string) "finite decoder cause" expected actual + | _ -> Alcotest.fail "expected decoder failure") failure_message; if download then ( let staging = Filename.concat support "staging" in @@ -1753,6 +1760,8 @@ let scenarios = ~download:true ~success ~payload + ?failure_message:(if name = "truncated gzip rejected" then + Some "snapshot gzip decode failed" else None) ?retained_download:(if success then None else Some body) (response ~headers body))) gzip_downloads @@ -1905,7 +1914,10 @@ let asset_upload ~status ~refresh () = with_peer cases (fun ~environment ~sw ~support ~origin -> let posted = ref [] and invalidations = ref 0 in - let runner = runner ~environment ~sw ~support ~posted ~invalidations () in + let diagnostics = ref [] in + let on_asset_diagnostic ~scope:_ ~operation ~request_operation detail = + diagnostics := (operation, request_operation, detail) :: !diagnostics in + let runner = runner ~on_asset_diagnostic ~environment ~sw ~support ~posted ~invalidations () in let selected, scope = bootstrap ~mirror_absent:false origin in let source_file = Filename.concat support "staged.bin" in Out_channel.with_open_bin source_file (fun out -> output_string out bytes); @@ -1930,6 +1942,18 @@ let asset_upload ~status ~refresh () = | _ -> Error Core.Asset_network in Alcotest.(check bool) "explicit upload result" true (result = expected); + (match status, !diagnostics with + | 200, [] -> () + | 200, _ -> Alcotest.fail "successful retry must not emit a failure diagnostic" + | _, [(operation, request_operation, detail)] -> + Alcotest.(check string) "finite diagnostic operation" "assetUpload" operation; + Alcotest.(check string) "exact request identity" "upload" request_operation; + let module D = Logseq_db_types.Failure_diagnostic in + Alcotest.(check bool) "safe numeric HTTP status survives" true + (String.ends_with ~suffix:(Printf.sprintf "(http=%d)." status) (D.message detail)); + Alcotest.(check bool) "source status preserved by strict carrier" true + (D.decode (D.encode detail) |> Option.map D.message = Some (D.message detail)) + | _ -> Alcotest.fail "one terminal failure diagnostic expected"); Alcotest.(check int) "upload token refresh" (if refresh then 1 else 0) @@ -4281,3 +4305,225 @@ let scenarios = @ [ Alcotest.test_case "HTTP default connection retires between requests" `Quick test_default_connection_sequence ] ;; + + +(* Core sees opaque transport completion text; its public reducer cannot execute + DNS validation, socket setup, TLS authentication, or Upgrade parsing. These + regressions enter through the real public Runner, with no fabricated result. *) +let transport_failure_message ~websocket posted = + if websocket + then + List.find_map + (function + | Core.Websocket_closed (_, Core.Authentication_failure message) -> + Some (`Authentication, message) + | Core.Websocket_closed (_, Core.Recoverable_close (Some message)) -> + Some (`Recoverable, message) + | _ -> None) + posted + else ( + match completed posted with + | Some (Error (Core.Effect_failed message)) -> Some (`Recoverable, message) + | _ -> None) +;; + +let require_transport_failure ~websocket ~authentication ~expected posted invalidations = + match transport_failure_message ~websocket posted with + | None -> Alcotest.fail "transport did not return its existing typed failure" + | Some (kind, message) -> + Alcotest.(check string) "fixed safe transport reason" expected message; + Alcotest.(check bool) + "existing authentication/retry classification retained" + (websocket && authentication) + (kind = `Authentication); + Alcotest.(check int) + "TLS or non-401 Upgrade failure does not refresh tokens" + 0 + invalidations +;; + +let test_tls_setup_reason ~websocket reason () = + with_support (fun support -> + Eio_main.run (fun environment -> + Eio.Switch.run (fun sw -> + let real_clock = Eio.Stdenv.clock environment in + let clock = + if reason = `No_address then scaled_clock real_clock 30. else real_clock + in + let attempts = ref 0 in + let network = + delayed_network (Eio.Stdenv.net environment) (fun () -> + incr attempts; + if reason = `Dial_refused then + raise (Unix.Unix_error (Unix.ECONNREFUSED, "LOG_SECRET_SOCKET", "LOG_SECRET_PATH")); + if reason = `Dial_timeout then raise Eio.Time.Timeout; + failwith + "LOG_SECRET_SOCKET https://LOG_SECRET_HOST/LOG_SECRET_PATH \ + token=LOG_SECRET_TOKEN") + in + let host, detail = + match reason with + | `Invalid_host -> + String.make 64 'x' ^ ".LOG_SECRET_HOST.invalid", "invalid DNS host" + | `No_address -> + ( Printf.sprintf "log-secret-dns-%d-%d.local" (Unix.getpid ()) (Random.bits ()) + , "no network address (DNS resolution timed out)" ) + | `Dial_failed -> "localhost", "all resolved addresses failed: operation raised Failure" + | `Dial_refused -> "localhost", "all resolved addresses failed: connection refused" + | `Dial_timeout -> "localhost", "all resolved addresses failed: dial timed out" + in + let origin = Uri.of_string ("https://" ^ host ^ ":1") in + let posted = ref [] + and invalidations = ref 0 in + let t = + runner + ~clock + ~network + ~acquire:(fun _ -> Ok "LOG_SECRET_TOKEN") + ~environment + ~sw + ~support + ~posted + ~invalidations + () + in + Fun.protect + ~finally:(fun () -> Runner.shutdown t) + (fun () -> + Runner.submit + t + (if websocket + then websocket_start origin + else operation ~download:false origin); + wait real_clock (fun () -> + Option.is_some (transport_failure_message ~websocket !posted)); + let prefix = + if websocket then "WebSocket TLS setup failed: " else "TLS setup failed: " + in + require_transport_failure + ~websocket + ~authentication:false + ~expected:(prefix ^ detail) + !posted + !invalidations; + if List.mem reason [`Dial_failed; `Dial_refused; `Dial_timeout] + then + Alcotest.(check bool) + "real address attempts executed the failing network capability" + true + (!attempts > 0) + else + Alcotest.(check int) "invalid or unresolved host never dials" 0 !attempts)))) +;; + +let test_tls_authentication_reason ~websocket () = + let report = + with_peer + [ case (response "x") ] + (fun ~environment ~sw ~support ~origin -> + let posted = ref [] + and invalidations = ref 0 in + let t = + runner + ~tls_authenticator:(Runner.system_tls_authenticator () |> Result.get_ok) + ~acquire:(fun _ -> Ok "LOG_SECRET_TOKEN") + ~environment + ~sw + ~support + ~posted + ~invalidations + () + in + Fun.protect + ~finally:(fun () -> Runner.shutdown t) + (fun () -> + Runner.submit + t + (if websocket + then websocket_start origin + else operation ~download:false origin); + wait (Eio.Stdenv.clock environment) (fun () -> + Option.is_some (transport_failure_message ~websocket !posted)); + require_transport_failure + ~websocket + ~authentication:true + ~expected: + (if websocket + then "WebSocket TLS authentication failed" + else "TLS authentication failed") + !posted + !invalidations)) + in + check_retired report +;; + +let test_upgrade_rejection_reason () = + let report = + with_peer + [ case (response ~status:503 "LOG_SECRET_RESPONSE_BODY") ] + (fun ~environment ~sw ~support ~origin -> + let posted = ref [] + and invalidations = ref 0 in + let t = + runner + ~acquire:(fun _ -> Ok "LOG_SECRET_TOKEN") + ~environment + ~sw + ~support + ~posted + ~invalidations + () + in + Fun.protect + ~finally:(fun () -> Runner.shutdown t) + (fun () -> + Runner.submit t (websocket_start origin); + wait (Eio.Stdenv.clock environment) (fun () -> + Option.is_some (transport_failure_message ~websocket:true !posted)); + require_transport_failure + ~websocket:true + ~authentication:false + ~expected:"WebSocket Upgrade rejected (HTTP 503)" + !posted + !invalidations)) + in + check_retired report +;; + +let scenarios = + scenarios + @ List.concat_map + (fun websocket -> + let prefix = if websocket then "WebSocket " else "HTTP " in + [ Alcotest.test_case + (prefix ^ "TLS diagnostic invalid host") + `Quick + (test_tls_setup_reason ~websocket `Invalid_host) + ; Alcotest.test_case + (prefix ^ "TLS diagnostic no address") + `Quick + (test_tls_setup_reason ~websocket `No_address) + ; Alcotest.test_case + (prefix ^ "TLS diagnostic failed dial") + `Quick + (test_tls_setup_reason ~websocket `Dial_failed) + ; Alcotest.test_case + (prefix ^ "TLS diagnostic refused errno") + `Quick + (test_tls_setup_reason ~websocket `Dial_refused) + ; Alcotest.test_case + (prefix ^ "TLS diagnostic dial timeout") + `Quick + (test_tls_setup_reason ~websocket `Dial_timeout) + ; Alcotest.test_case + (prefix ^ "TLS diagnostic authentication") + `Quick + (test_tls_authentication_reason ~websocket) + ]) + [ false; true ] + @ [ Alcotest.test_case + "WebSocket Upgrade diagnostic status" + `Quick + test_upgrade_rejection_reason + ] +;; diff --git a/swift/JournalApplicationPlatform.swift b/swift/JournalApplicationPlatform.swift index 0db84870..af983437 100644 --- a/swift/JournalApplicationPlatform.swift +++ b/swift/JournalApplicationPlatform.swift @@ -119,23 +119,31 @@ import AppKit return exchange } - /// Answers one LJP2 request envelope; `nil` suppresses the response (the - /// old bridge surfaced these as request failures). + /// Answers a request with LJP2 success or a bounded LJF1 failure. Only retired + /// or suppressed work returns nil; current cancellation still completes. func request(_ bytes: Data) async -> Data? { let identity = connection + guard connected else { return nil } + var failureScope = startupScope + func failed(_ reason:JournalPlatformWire.RequestFailure) -> Data? { + guard connected, connection == identity else { return nil } + return JournalPlatformWire.requestFailure(bytes,reason:reason,scope:failureScope) + } let request: JournalPlatformWire.Request do { request = try JournalPlatformWire.decodeRequest(bytes) } catch { FileHandle.standardError.write( Data("logseq_journal: decodeRequest failed bytes=\(bytes.count)\n".utf8)) - return nil + return failed(.requestDecode) } if [.timelinePresented,.onlineRequested,.onlineCancelled].contains(request) { guard let scope=try? JournalPlatformWire.startupScope(bytes), scope>=startupScope else {return nil} if request != .onlineCancelled, !pending.acceptsOnlinePermission(scope) { return nil } if scope != startupScope { services.cancelAuthenticationPreparation(); startupScope=scope } } + let requestScope = startupScope + failureScope = requestScope requestObserver?(request) if request == .signOut { refresh?.cancel() @@ -150,17 +158,18 @@ import AppKit UIPasteboard.general.string = text #elseif os(macOS) NSPasteboard.general.clearContents() - guard NSPasteboard.general.setString(text, forType: .string) else { return nil } + guard NSPasteboard.general.setString(text, forType: .string) else { return failed(.clipboardUnavailable) } #else - return nil + return failed(.clipboardUnavailable) #endif - return try? JournalPlatformWire.encodeResponse(.textCopied) + do { return try JournalPlatformWire.encodeResponse(.textCopied) } + catch { return failed(.responseEncode) } default: break } do { let response = try await services.response(for: request) - guard connected, connection == identity else { return nil } + guard connected, connection == identity, startupScope == requestScope else { return nil } accountIssue = services.accountIssue switch response { case .authenticatedUser(let user): authenticationRequired = user == nil @@ -171,7 +180,9 @@ import AppKit case .noLocalAccount: localAccountAvailable = false; authenticationRequired=true default: break } - let encoded = try JournalPlatformWire.encodeResponse(response) + let encoded:Data + do { encoded = try JournalPlatformWire.encodeResponse(response) } + catch { return failed(.responseEncode) } if request == .onlineCancelled { refresh?.cancel() onlineWaiter?.resume(throwing:CancellationError());onlineWaiter=nil @@ -184,12 +195,12 @@ import AppKit if request == .terminationReady { shutdown?.complete() } return encoded } catch JournalPlatformServices.Failure.authenticationRequired { - guard connected, connection == identity else { return nil } + guard connected, connection == identity, startupScope == requestScope else { return nil } authenticationRequired = true accountIssue = services.accountIssue - return nil + return failed(.authenticationRequired) } catch { - guard connected, connection == identity else { return nil } + guard connected, connection == identity, startupScope == requestScope else { return nil } if !(error is CancellationError) { switch request { case .localAccount, .authenticatedUser, .idToken: @@ -197,7 +208,16 @@ import AppKit default: break } } - return nil + let reason:JournalPlatformWire.RequestFailure + switch error { + case is CancellationError: reason = .cancelled + case JournalPlatformServices.Failure.unavailable: reason = .serviceUnavailable + case JournalPlatformServices.Failure.invalidBinding: reason = .invalidBinding + case JournalPlatformWire.Failure.invalidPacket: reason = .invalidPacket + case is URLError: reason = .network + default: reason = (error as NSError).domain == NSOSStatusErrorDomain ? .secureStorage : .serviceFailed + } + return failed(reason) } } @@ -269,9 +289,19 @@ import AppKit } func retryOnline() { + let identity = connection, scope = startupScope Task { [weak self] in - guard let self else {return} - do {try await beginOnline(); refreshAuthentication()} catch {} + guard let self, connected, connection == identity, startupScope == scope else { return } + do { + try await beginOnline() + guard connected, connection == identity, startupScope == scope else { return } + refreshAuthentication() + } catch { + if !(error is CancellationError), !Task.isCancelled, + connected, connection == identity, startupScope == scope { + accountIssue = .classify(error, stage: .identityLookup) + } + } } } diff --git a/swift/JournalAssetDiagnostic.swift b/swift/JournalAssetDiagnostic.swift new file mode 100644 index 00000000..d44785c3 --- /dev/null +++ b/swift/JournalAssetDiagnostic.swift @@ -0,0 +1,74 @@ +import Foundation + +/// Only finite operation-owned vocabulary crosses attachment feedback boundaries. +struct JournalAssetDiagnostic: Error, Equatable, Sendable, CustomStringConvertible { + enum Stage: String, Sendable { + case fileSelection = "file-selection", fileCopy = "file-copy", photoLoad = "photo-load" + case stageWrite = "stage-write", photoStage = "photo-stage", photoWrite = "photo-write" + case cameraEncode = "camera-encode", selection, cleanup + } + enum Category: String, Sendable { + case permissionDenied = "permission-denied", sourceUnavailable = "source-unavailable" + case noSpace = "no-space", cancelled, providerUnavailable = "provider-unavailable" + case dataUnavailable = "data-unavailable", selectionLimit = "selection-limit", failed + case libraryUnavailable = "library-unavailable", networkRequired = "network-required" + case networkFailed = "network-failed", unsupported, interrupted, restricted + case invalidResource = "invalid-resource", ambiguousSource = "ambiguous-source" + case historyUnavailable = "history-unavailable", internalFailure = "internal-failure" + } + let stage: Stage + let category: Category + var description: String { "\(stage.rawValue).\(category.rawValue)" } + + static func classify(_ error: any Error, stage: Stage) -> Self { + if let diagnostic = error as? Self { return diagnostic } + let native = error as NSError + let category: Category + if native.domain == NSCocoaErrorDomain { + switch native.code { + case NSUserCancelledError: category = .cancelled + case CocoaError.fileReadNoPermission.rawValue, CocoaError.fileWriteNoPermission.rawValue: + category = .permissionDenied + case CocoaError.fileReadNoSuchFile.rawValue, CocoaError.fileNoSuchFile.rawValue: + category = .sourceUnavailable + case CocoaError.fileWriteOutOfSpace.rawValue: category = .noSpace + default: category = stage == .photoLoad ? .providerUnavailable : .failed + } + } else if native.domain == NSPOSIXErrorDomain { + switch native.code { + case Int(POSIXError.EACCES.rawValue), Int(POSIXError.EPERM.rawValue): category = .permissionDenied + case Int(POSIXError.ENOENT.rawValue): category = .sourceUnavailable + case Int(POSIXError.ENOSPC.rawValue): category = .noSpace + default: category = .failed + } + } else if native.domain == "PHPhotosErrorDomain" { + // Finite SDK PHPhotosError codes only; no Photos identifiers/userInfo escape. + switch native.code { + case -1: category = .internalFailure + case 3072: category = .cancelled + case 3114, 3142, 3143: category = .libraryUnavailable + case 3164: category = .networkRequired + case 3169: category = .networkFailed + case 3201, 3303: category = .sourceUnavailable + case 3202: category = .ambiguousSource + case 3300, 3306, 5423: category = .unsupported + case 3301: category = .interrupted + case 3302: category = .invalidResource + case 3305: category = .noSpace + case 3307: category = .selectionLimit + case 3310: category = .restricted + case 3311: category = .permissionDenied + case 3105, 3210: category = .historyUnavailable + default: category = .failed + } + } else { category = stage == .photoLoad ? .providerUnavailable : .failed } + return Self(stage: stage, category: category) + } + + static func summary(_ failures: [Self]) -> String? { + guard !failures.isEmpty else { return nil } + let counts = Dictionary(grouping: failures, by: \.description) + .map { "\($0.key)=\($0.value.count)" }.sorted().joined(separator: ", ") + return "\(failures.count) selected item(s) could not be added. Your other attachments are kept. (\(counts))" + } +} diff --git a/swift/JournalAssetImport.swift b/swift/JournalAssetImport.swift index 6e8854e8..ded5e986 100644 --- a/swift/JournalAssetImport.swift +++ b/swift/JournalAssetImport.swift @@ -9,35 +9,6 @@ import UniformTypeIdentifiers import UIKit #endif -private actor JournalAssetStaging { - static let shared = JournalAssetStaging() - - nonisolated static func discard(_ file: URL) { - do { try FileManager.default.removeItem(at: file) } - catch let error as CocoaError where error.code == .fileNoSuchFile { } // Idempotent cleanup. - catch { - Logger(subsystem: "com.logseq.journal", category: "cleanup") - .error("Temporary attachment cleanup could not finish (assetImport.cleanup.failed)") - } - } - - func copy(_ source: URL) -> URL? { - let scoped = source.startAccessingSecurityScopedResource() - defer { if scoped { source.stopAccessingSecurityScopedResource() } } - let ext = source.pathExtension.lowercased() - let name = "journal-import-" + UUID().uuidString.lowercased() + (ext.isEmpty ? "" : "." + ext) - let dest = FileManager.default.temporaryDirectory.appendingPathComponent(name) - do { try FileManager.default.copyItem(at: source, to: dest); return dest } - catch { Self.discard(dest); return nil } - } - - func write(_ data: Data, ext: String) -> URL? { - let dest = FileManager.default.temporaryDirectory.appendingPathComponent("journal-import-" + UUID().uuidString.lowercased() + "." + ext) - do { try data.write(to: dest); return dest } - catch { Self.discard(dest); return nil } - } -} - @MainActor enum JournalAssetImport { struct Request: Codable, Equatable { let id: Int @@ -195,11 +166,11 @@ private actor JournalAssetStaging { for item in items { JournalAssetStaging.discard(URL(fileURLWithPath: item.path)) } } - private func emitBatch(_ items: [Pick], failures: Int, request: Request, epoch: Int) { + private func emitBatch(_ items: [Pick], failures: [JournalAssetDiagnostic], request: Request, epoch: Int) { guard !Task.isCancelled, isCurrent(request, epoch: epoch) else { discard(items); return } handled = true let payload = Event(action: "picked-batch", items: items, request: request, - error: failures > 0 ? "\(failures) selected item(s) could not be added. Your other attachments are kept." : nil) + error: JournalAssetDiagnostic.summary(failures)) if !emit(payload) { discard(items) error = "The destination is no longer available. Select the files again." @@ -220,14 +191,18 @@ private actor JournalAssetStaging { } var items: [Pick] = [] var seen = Set((properties?.pending ?? []).compactMap(\.sourceIdentity)) - var failures = 0 + var failures: [JournalAssetDiagnostic] = [] let remaining = max(0, request.maxSelections ?? sources.count) for source in sources { let identity = "files:" + source.standardizedFileURL.absoluteString guard seen.insert(identity).inserted else { continue } - guard items.count < remaining else { failures += 1; continue } + guard items.count < remaining else { failures.append(.init(stage: .selection, category: .selectionLimit)); continue } guard !Task.isCancelled, isCurrent(request, epoch: epoch) else { discard(items); return } - guard let copy = await JournalAssetStaging.shared.copy(source) else { failures += 1; continue } + let copy: URL + switch await JournalAssetStaging.shared.copy(source) { + case .success(let staged): copy = staged + case .failure(let reason): failures.append(reason); continue + } guard !Task.isCancelled, isCurrent(request, epoch: epoch) else { JournalAssetStaging.discard(copy); discard(items); return } @@ -237,27 +212,40 @@ private actor JournalAssetStaging { emitBatch(items, failures: failures, request: request, epoch: epoch) } - private func stageData(_ data: Data, ext: String, title: String, identity: String) async -> Pick? { - guard let dest = await JournalAssetStaging.shared.write(data, ext: ext) else { return nil } - return pick(path: dest.path(percentEncoded: false), title: title, type: ext, identity: identity) + private func stageData(_ data: Data, ext: String, title: String, identity: String) async -> Result { + switch await JournalAssetStaging.shared.write(data, ext: ext) { + case .success(let dest): + return .success(pick(path: dest.path(percentEncoded: false), title: title, type: ext, identity: identity)) + case .failure(let reason): return .failure(reason) + } } private func importPhotos(_ selected: [PhotosPickerItem], request: Request, epoch: Int) async { var items: [Pick] = [] var seen = Set((properties?.pending ?? []).compactMap(\.sourceIdentity)) - var failures = 0 + var failures: [JournalAssetDiagnostic] = [] let remaining = max(0, request.maxSelections ?? selected.count) for item in selected { guard !Task.isCancelled, isCurrent(request, epoch: epoch) else { discard(items); return } if let identifier = item.itemIdentifier, seen.contains("photos:" + identifier) { continue } - guard items.count < remaining else { failures += 1; continue } - guard let data = try? await item.loadTransferable(type: Data.self) else { failures += 1; continue } + guard items.count < remaining else { failures.append(.init(stage: .selection, category: .selectionLimit)); continue } + let data: Data + do { + guard let loaded = try await item.loadTransferable(type: Data.self) else { + failures.append(.init(stage: .photoLoad, category: .dataUnavailable)); continue + } + data = loaded + } catch { + failures.append(.classify(error, stage: .photoLoad)); continue + } guard !Task.isCancelled, isCurrent(request, epoch: epoch) else { discard(items); return } let identity = "photos:" + (item.itemIdentifier ?? SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()) guard seen.insert(identity).inserted else { continue } let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg" - if let staged = await stageData(data, ext: ext, title: "photo." + ext, identity: identity) { items.append(staged) } - else { failures += 1 } + switch await stageData(data, ext: ext, title: "photo." + ext, identity: identity) { + case .success(let staged): items.append(staged) + case .failure(let reason): failures.append(reason) + } } emitBatch(items, failures: failures, request: request, epoch: epoch) } @@ -284,7 +272,7 @@ private actor JournalAssetStaging { case .failure(let failure): selection.release() if (failure as NSError).code == NSUserCancelledError { emitDismissed() } - else if isCurrent(request, epoch: epoch) { emitUnavailable("Unable to access the selected files. Please try again.", request: request) } + else if isCurrent(request, epoch: epoch) { emitUnavailable("Unable to access the selected files. Please try again. (\(JournalAssetDiagnostic.classify(failure, stage: .fileSelection)))", request: request) } } } .photosPicker(isPresented: $photosPresented, selection: $photoItems, @@ -297,11 +285,14 @@ private actor JournalAssetStaging { let epoch = self.epoch importTask?.cancel() importTask = Task { - let item: Pick? + let result: Result if let data = image.jpegData(compressionQuality: 0.9) { - item = await stageData(data, ext: "jpg", title: "camera.jpg", identity: "camera:" + UUID().uuidString.lowercased()) - } else { item = nil } - emitBatch(item.map { [$0] } ?? [], failures: item == nil ? 1 : 0, request: request, epoch: epoch) + result = await stageData(data, ext: "jpg", title: "camera.jpg", identity: "camera:" + UUID().uuidString.lowercased()) + } else { result = .failure(.init(stage: .cameraEncode, category: .dataUnavailable)) } + switch result { + case .success(let item): emitBatch([item], failures: [], request: request, epoch: epoch) + case .failure(let reason): emitBatch([], failures: [reason], request: request, epoch: epoch) + } } }, onCancel: emitDismissed) #else diff --git a/swift/JournalAssetStaging.swift b/swift/JournalAssetStaging.swift new file mode 100644 index 00000000..42add989 --- /dev/null +++ b/swift/JournalAssetStaging.swift @@ -0,0 +1,32 @@ +import Foundation +import OSLog + +actor JournalAssetStaging { + static let shared = JournalAssetStaging() + + nonisolated static func discard(_ file: URL) { + do { try FileManager.default.removeItem(at: file) } + catch let error as CocoaError where error.code == .fileNoSuchFile { } // Idempotent cleanup. + catch { + let reason = JournalAssetDiagnostic.classify(error, stage: .cleanup) + Logger(subsystem: "com.logseq.journal", category: "cleanup") + .error("Temporary attachment cleanup could not finish (assetImport.cleanup.failed; \(reason.description, privacy: .public))") + } + } + + func copy(_ source: URL) -> Result { + let scoped = source.startAccessingSecurityScopedResource() + defer { if scoped { source.stopAccessingSecurityScopedResource() } } + let ext = source.pathExtension.lowercased() + let name = "journal-import-" + UUID().uuidString.lowercased() + (ext.isEmpty ? "" : "." + ext) + let dest = FileManager.default.temporaryDirectory.appendingPathComponent(name) + do { try FileManager.default.copyItem(at: source, to: dest); return .success(dest) } + catch { Self.discard(dest); return .failure(.classify(error, stage: .fileCopy)) } + } + + func write(_ data: Data, ext: String) -> Result { + let dest = FileManager.default.temporaryDirectory.appendingPathComponent("journal-import-" + UUID().uuidString.lowercased() + "." + ext) + do { try data.write(to: dest); return .success(dest) } + catch { Self.discard(dest); return .failure(.classify(error, stage: .stageWrite)) } + } +} diff --git a/swift/JournalAuthentication.swift b/swift/JournalAuthentication.swift index 6bbaf799..a4b1f8c7 100644 --- a/swift/JournalAuthentication.swift +++ b/swift/JournalAuthentication.swift @@ -52,7 +52,14 @@ extension JournalAuthenticationAPI { let fallback = error is URLError ? "Unable to connect to sign-in. Please try again." : "Unable to complete your account connection. Please try again." - message = preparing ? (api.accountPreparationMessage ?? fallback) : fallback + let knownMessage: String? + switch error { + case let known as JournalCognitoFailure: knownMessage = known.errorDescription + case let known as JournalCognitoError: knownMessage = known.errorDescription + case let known as JournalAccountIssue: knownMessage = known.errorDescription + default: knownMessage = nil + } + message = knownMessage ?? (preparing ? (api.accountPreparationMessage ?? fallback) : fallback) } } } diff --git a/swift/JournalCognitoNative.swift b/swift/JournalCognitoNative.swift index 3a20c806..ca22051b 100644 --- a/swift/JournalCognitoNative.swift +++ b/swift/JournalCognitoNative.swift @@ -26,7 +26,9 @@ private final class JournalCognitoKeychain: Sendable { kSecAttrService: "com.amplify.awsCognitoAuthPlugin", kSecAttrAccount: "amplify.us-east-1_dtagLnju8.session", kSecUseDataProtectionKeychain: true] let status = SecItemDelete(legacy as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { throw JournalCognitoError.storage } + guard status == errSecSuccess || status == errSecItemNotFound else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .legacyClear, reason: .status, osStatus: Int(status))) + } } func load() async throws -> JournalCognitoTokens? { try await perform { @@ -35,27 +37,46 @@ private final class JournalCognitoKeychain: Sendable { var item: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = item as? Data, data.count <= 131072 - else { throw JournalCognitoError.storage } - return try JSONDecoder().decode(JournalCognitoTokens.self, from: data) + guard status == errSecSuccess else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainLoad, reason: .status, osStatus: Int(status))) + } + guard let data = item as? Data else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainLoad, reason: .wrongType)) + } + guard data.count <= 131072 else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainLoad, reason: .responseTooLarge)) + } + do { return try JSONDecoder().decode(JournalCognitoTokens.self, from: data) } + catch { throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainLoad, reason: .invalidRecord)) } } } func save(_ tokens: JournalCognitoTokens) async throws { try await perform { - let data = try JSONEncoder().encode(tokens) - guard data.count <= 131072 else { throw JournalCognitoError.storage } + let data: Data + do { data = try JSONEncoder().encode(tokens) } + catch { throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainSave, reason: .invalidRecord)) } + guard data.count <= 131072 else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainSave, reason: .responseTooLarge)) + } let status = SecItemUpdate(Self.query as CFDictionary, [kSecValueData: data] as CFDictionary) if status == errSecItemNotFound { var query = Self.query query[kSecValueData] = data; query[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly - guard SecItemAdd(query as CFDictionary, nil) == errSecSuccess else { throw JournalCognitoError.storage } - } else if status != errSecSuccess { throw JournalCognitoError.storage } + let added = SecItemAdd(query as CFDictionary, nil) + guard added == errSecSuccess else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainAdd, reason: .status, osStatus: Int(added))) + } + } else if status != errSecSuccess { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainSave, reason: .status, osStatus: Int(status))) + } } } func clear() async throws { try await perform { let status = SecItemDelete(Self.query as CFDictionary) - guard status == errSecSuccess || status == errSecItemNotFound else { throw JournalCognitoError.storage } + guard status == errSecSuccess || status == errSecItemNotFound else { + throw JournalCognitoFailure(category: .storage, detail: .init(source: .keychainClear, reason: .status, osStatus: Int(status))) + } } } func retireLegacy() async throws { try await perform { try Self.clearLegacySession() } } @@ -69,14 +90,18 @@ private final class JournalCognitoKeychain: Sendable { private var timer: Task? private var anchor: ASPresentationAnchor? func authenticate(_ url: URL, scheme: String) async throws -> URL { - guard operation == nil else { throw JournalCognitoError.unavailable } + guard operation == nil else { + throw JournalCognitoFailure(category: .unavailable, detail: .init(source: .browser, reason: .operationInProgress)) + } #if os(iOS) anchor = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene } .filter { $0.activationState == .foregroundActive }.flatMap(\.windows).first(where: \.isKeyWindow) #else anchor = NSApplication.shared.keyWindow ?? NSApplication.shared.mainWindow #endif - guard anchor != nil else { throw JournalCognitoError.unavailable } + guard anchor != nil else { + throw JournalCognitoFailure(category: .unavailable, detail: .init(source: .browser, reason: .missingAnchor)) + } let id = UUID(); operation = id defer { if operation == id { finish(id, .failure(CancellationError()), cancel: true) } } return try await withTaskCancellationHandler { @@ -88,7 +113,10 @@ private final class JournalCognitoKeychain: Sendable { if let callback { self?.finish(id, .success(callback)) } else if (error as? ASWebAuthenticationSessionError)?.code == .canceledLogin { self?.finish(id, .failure(CancellationError())) - } else { self?.finish(id, .failure(JournalCognitoError.unavailable)) } + } else { + self?.finish(id, .failure(JournalCognitoFailure(category: .unavailable, + detail: .init(source: .browser, reason: .browserFailed)))) + } } } self.session = session @@ -98,7 +126,10 @@ private final class JournalCognitoKeychain: Sendable { do { try await Task.sleep(for: .seconds(300)) } catch { return } self?.finish(id, .failure(JournalCognitoError.expired), cancel: true) } - if !session.start() { finish(id, .failure(JournalCognitoError.unavailable), cancel: true) } + if !session.start() { + finish(id, .failure(JournalCognitoFailure(category: .unavailable, + detail: .init(source: .browser, reason: .startRejected))), cancel: true) + } } } onCancel: { Task { @MainActor [weak self] in self?.finish(id, .failure(CancellationError()), cancel: true) } } } @@ -135,7 +166,9 @@ private final class JournalOAuthRedirectPolicy: NSObject, URLSessionTaskDelegate browser: JournalCognitoBrowser(authenticate: { try await browser.authenticate($0, scheme: $1) }, cancel: { browser.cancel() }), transport: { request in let (data, response) = try await http.data(for: request) - guard let response = response as? HTTPURLResponse else { throw JournalCognitoError.request } + guard let response = response as? HTTPURLResponse else { + throw JournalCognitoFailure(category: .request, detail: .init(source: .network, reason: .responseShape)) + } return (data, response) }) }() diff --git a/swift/JournalCognitoOAuth.swift b/swift/JournalCognitoOAuth.swift index c098069b..c2c9fab6 100644 --- a/swift/JournalCognitoOAuth.swift +++ b/swift/JournalCognitoOAuth.swift @@ -14,22 +14,6 @@ struct JournalCognitoConfiguration: Sendable { region: "us-east-1", redirectURI: "logseqjournal://auth/callback") } -enum JournalCognitoError: Error, LocalizedError { - case configuration, callback, expired, invalidToken, storage, unavailable, request, invalidGrant - var errorDescription: String? { - switch self { - case .configuration: "Web sign-in is not configured." - case .callback: "The sign-in response could not be verified. Please try again." - case .expired: "Sign-in timed out. Please try again." - case .invalidToken: "The account session could not be verified. Please sign in again." - case .storage: "Unable to save the account session securely. Please try again." - case .unavailable: "Unable to open the sign-in page. Please try again." - case .request: "Unable to connect to sign-in. Please try again." - case .invalidGrant: "Your account session expired. Please sign in again." - } - } -} - enum JournalCognitoOAuth { static func url64(_ data: Data) -> String { data.base64EncodedString().replacingOccurrences(of: "+", with: "-") diff --git a/swift/JournalCognitoSession.swift b/swift/JournalCognitoSession.swift index 2b10e55c..ba7ab7a0 100644 --- a/swift/JournalCognitoSession.swift +++ b/swift/JournalCognitoSession.swift @@ -81,7 +81,7 @@ import OSLog try check(version, network: network) tokens = updated return updated - } catch JournalCognitoError.invalidGrant { + } catch where JournalCognitoFailure.category(of: error) == .invalidGrant { try check(version, network: network) tokens = nil; loaded = true try await storage.clear() @@ -138,8 +138,9 @@ import OSLog signInID = nil do { try await storage.retireLegacy() } catch { + let reason = JournalAccountIssue.classify(error, stage: .bindingWrite).diagnostic Logger(subsystem: "com.logseq.journal", category: "account") - .error("Legacy credential cleanup is pending (account.retireLegacy.failed)") + .error("Legacy credential cleanup is pending (account.retireLegacy.failed): \(reason, privacy: .public)") } try check(savingVersion, network: network) NotificationCenter.default.post(name: Self.changed, object: self) @@ -164,8 +165,9 @@ import OSLog if previous == nil { do { previous = try await storage.load() } catch { + let reason = JournalAccountIssue.classify(error, stage: .bindingRead).diagnostic Logger(subsystem: "com.logseq.journal", category: "account") - .error("Remote sign-out credentials unavailable (account.loadForRevocation.failed)") + .error("Remote sign-out credentials unavailable (account.loadForRevocation.failed): \(reason, privacy: .public)") } } tokens = nil; loaded = true @@ -175,23 +177,44 @@ import OSLog if let previous { // Local cleanup succeeds offline. Revocation is bounded and best effort. do { - _ = try await performTransport(request(path: "/oauth2/revoke", values: ["client_id": configuration.clientID, - "token": previous.refreshToken])) + let revocation = request(path: "/oauth2/revoke", values: ["client_id": configuration.clientID, + "token": previous.refreshToken]) + let (_, response) = try await performTransport(revocation, source: .revoke) + let reason: JournalAccountDiagnostic? + if response.url != revocation.url { + reason = .init(source: .revoke, reason: .redirect, httpStatus: response.statusCode) + } else if !(200...299).contains(response.statusCode) { + reason = .init(source: .revoke, reason: .httpStatus, httpStatus: response.statusCode) + } else { reason = nil } + if let reason { + Logger(subsystem: "com.logseq.journal", category: "account") + .error("Remote sign-out response was rejected (account.revoke.failed): \(reason.diagnostic, privacy: .public)") + } } catch { + let reason = JournalAccountIssue.classify(error, stage: .identityLookup).diagnostic Logger(subsystem: "com.logseq.journal", category: "account") - .error("Remote sign-out could not finish (account.revoke.failed)") + .error("Remote sign-out could not finish (account.revoke.failed): \(reason, privacy: .public)") } } } - private func performTransport(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { + private func performTransport(_ request: URLRequest, source: JournalAccountDiagnostic.Source) async throws -> (Data, HTTPURLResponse) { try Task.checkCancellation() let id = UUID() let task = Task { try await transport(request) } networkRequests[id] = task defer { networkRequests[id] = nil } - return try await withTaskCancellationHandler { - try await task.value - } onCancel: { task.cancel() } + do { + return try await withTaskCancellationHandler { + try await task.value + } onCancel: { task.cancel() } + } catch is CancellationError { throw CancellationError() } + catch { + if JournalCognitoFailure.category(of: error) != nil { throw error } + let prior = JournalAccountIssue.classify(error, stage: .identityLookup) + throw JournalCognitoFailure(category: .request, detail: .init(source: source, + reason: .transport, urlCode: prior.nativeDetail?.urlCode, osStatus: prior.nativeDetail?.osStatus), + accountKind: prior.kind) + } } private func request(path: String, values: [String: String]) -> URLRequest { var request = URLRequest(url: URL(string: "https://\(configuration.domain)\(path)")!) @@ -204,22 +227,47 @@ import OSLog private func requestTokens(_ values: [String: String]) async throws -> JournalCognitoTokenResponse { let request = request(path: "/oauth2/token", values: values) - let (data, response) = try await performTransport(request) - guard response.url == request.url, data.count <= 131072 else { throw JournalCognitoError.request } + let (data, response) = try await performTransport(request, source: .token) + guard response.url == request.url else { + throw JournalCognitoFailure(category: .request, detail: .init(source: .token, reason: .redirect, httpStatus: response.statusCode)) + } + guard data.count <= 131072 else { + throw JournalCognitoFailure(category: .request, detail: .init(source: .token, reason: .responseTooLarge, httpStatus: response.statusCode)) + } guard response.statusCode == 200 else { let error = (try? JSONDecoder().decode(TokenFailure.self, from: data))?.error - if response.statusCode == 400, error == "invalid_grant" { throw JournalCognitoError.invalidGrant } - throw JournalCognitoError.request + let category: JournalCognitoError = response.statusCode == 400 && error == "invalid_grant" ? .invalidGrant : .request + throw JournalCognitoFailure(category: category, detail: .init(source: .token, reason: .httpStatus, + httpStatus: response.statusCode, oauth: error.map { .init(safeCode: $0) })) } do { return try JSONDecoder().decode(JournalCognitoTokenResponse.self, from: data) } - catch { throw JournalCognitoError.invalidToken } + catch { + throw JournalCognitoFailure(category: .invalidToken, detail: .init(source: .token, reason: .responseShape, httpStatus: response.statusCode)) + } } private func validate(_ response: JournalCognitoTokenResponse, nonce: String?, previous: JournalCognitoTokens?) async throws -> JournalCognitoTokens { var request = URLRequest(url: URL(string: configuration.issuer + "/.well-known/jwks.json")!) request.timeoutInterval = 15 - let (data, http) = try await performTransport(request) - guard http.statusCode == 200, http.url == request.url else { throw JournalCognitoError.request } - return try JournalCognitoTokenValidator(jwks: data).validate(response, configuration: configuration, - nonce: nonce, previous: previous, now: now()) + let (data, http) = try await performTransport(request, source: .jwks) + guard http.statusCode == 200 else { + throw JournalCognitoFailure(category: .request, detail: .init(source: .jwks, reason: .httpStatus, httpStatus: http.statusCode)) + } + guard http.url == request.url else { + throw JournalCognitoFailure(category: .request, detail: .init(source: .jwks, reason: .redirect, httpStatus: http.statusCode)) + } + guard data.count <= 262144 else { + throw JournalCognitoFailure(category: .invalidToken, detail: .init(source: .jwks, reason: .responseTooLarge, httpStatus: http.statusCode)) + } + let validator: JournalCognitoTokenValidator + do { validator = try JournalCognitoTokenValidator(jwks: data) } + catch { + throw JournalCognitoFailure(category: .invalidToken, detail: .init(source: .jwks, reason: .responseShape, httpStatus: http.statusCode)) + } + do { + return try validator.validate(response, configuration: configuration, + nonce: nonce, previous: previous, now: now()) + } catch { + throw JournalCognitoFailure(category: .invalidToken, detail: .init(source: .tokenValidation, reason: .verificationFailed)) + } } } diff --git a/swift/JournalE2EECrypto.swift b/swift/JournalE2EECrypto.swift index 65b4da1e..b2666944 100644 --- a/swift/JournalE2EECrypto.swift +++ b/swift/JournalE2EECrypto.swift @@ -16,6 +16,8 @@ private final class JournalUnlockTrace: NSObject { let request: String let began = ProcessInfo.processInfo.systemUptime var stage = "decode-request" + var failureReason: String? + var cleanupPending = false static var current: JournalUnlockTrace? { get { Thread.current.threadDictionary[threadKey] as? JournalUnlockTrace } @@ -95,6 +97,8 @@ struct JournalCryptoRequest: Codable { struct JournalCryptoResponse: Codable { var ok: Bool var error: String? = nil + var reason: String? = nil + var cleanupPending: Bool? = nil var value: String? = nil var wrappedKeysDeleted: Bool? = nil var privateKeyDeleted: Bool? = nil @@ -105,6 +109,13 @@ struct JournalCryptoResponse: Codable { enum JournalE2EECryptoError: Error { case invalidRequest case operationFailed + case keyStoreFailure(OSStatus) + case keyStoreInvalidType + case invalidPrivateKey + case rsaDecryptionFailed + case invalidGraphKeyLength + case invalidAESInput + case enginePrivateKeyUnavailable case wrappedGraphKeyUnavailable case localPrivateKeyUnavailable } @@ -381,7 +392,7 @@ enum JournalE2EECrypto { private static func aesOpen(key: Data, iv: Data, ciphertextAndTag: Data) throws -> Data { guard key.count == 32, iv.count == 12, ciphertextAndTag.count >= 16 else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.invalidAESInput } let box = try AES.GCM.SealedBox( nonce: AES.GCM.Nonce(data: iv), @@ -410,10 +421,12 @@ enum JournalE2EECrypto { var key = SecKeyCreateWithData(privateKeyData as CFData, attributes as CFDictionary, &error) if key == nil { error = nil - let pkcs1 = try unwrapPKCS8(privateKeyData) + let pkcs1: Data + do { pkcs1 = try unwrapPKCS8(privateKeyData) } + catch { throw JournalE2EECryptoError.invalidPrivateKey } key = SecKeyCreateWithData(pkcs1 as CFData, attributes as CFDictionary, &error) } - guard let key else { throw JournalE2EECryptoError.operationFailed } + guard let key else { throw JournalE2EECryptoError.invalidPrivateKey } return key } @@ -426,7 +439,7 @@ enum JournalE2EECrypto { .rsaEncryptionOAEPSHA256, ciphertext as CFData, &error - ) else { throw JournalE2EECryptoError.operationFailed } + ) else { throw JournalE2EECryptoError.rsaDecryptionFailed } return plaintext as Data } @@ -549,7 +562,7 @@ enum JournalE2EECrypto { ) } guard status == errSecSuccess, let keychain else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(status) } testFileKeychains[path] = keychain return keychain @@ -635,11 +648,23 @@ enum JournalE2EECrypto { let addStatus = SecItemAdd(query as CFDictionary, nil) JournalUnlockTrace.keychain("private-key-add", addStatus) guard addStatus == errSecSuccess else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(addStatus) } } else if status != errSecSuccess { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(status) + } + } + + // Keychain query result decoding owns no storage operation and never logs its value. + static func decodeKeyStoreData(status: OSStatus, result: CFTypeRef?) throws -> Data? { + if status == errSecItemNotFound { return nil } + guard status == errSecSuccess else { + throw JournalE2EECryptoError.keyStoreFailure(status) + } + guard let data = result as? Data else { + throw JournalE2EECryptoError.keyStoreInvalidType } + return data } static func loadPrivateKey( @@ -676,11 +701,7 @@ enum JournalE2EECrypto { var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) JournalUnlockTrace.keychain("private-key-copy", status) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw JournalE2EECryptoError.operationFailed - } - return data + return try decodeKeyStoreData(status: status, result: result) } private static func saveWrappedGraphKey( @@ -732,10 +753,10 @@ enum JournalE2EECrypto { let addStatus = SecItemAdd(query as CFDictionary, nil) JournalUnlockTrace.keychain("wrapped-key-add", addStatus) guard addStatus == errSecSuccess else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(addStatus) } } else if status != errSecSuccess { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(status) } } @@ -771,7 +792,7 @@ enum JournalE2EECrypto { try searchQuery(&query, storage: storage) let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(status) } } @@ -798,11 +819,7 @@ enum JournalE2EECrypto { var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) JournalUnlockTrace.keychain("wrapped-key-copy", status) - if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = result as? Data else { - throw JournalE2EECryptoError.operationFailed - } - return data + return try decodeKeyStoreData(status: status, result: result) } let encoded: Data? switch storage { @@ -825,7 +842,12 @@ enum JournalE2EECrypto { guard decoded.identity == identity else { throw JournalE2EECryptoError.invalidRequest } return decoded.encryptedGraphKey } catch { - try? deleteWrappedGraphKey(identity: identity, environment: environment) + JournalUnlockTrace.current?.failureReason = "wrapped-key-cache-invalid" + do { try deleteWrappedGraphKey(identity: identity, environment: environment) } + catch { + JournalUnlockTrace.current?.cleanupPending = true + JournalUnlockTrace.mark("wrapped-key-cleanup-failed") + } throw JournalE2EECryptoError.operationFailed } } @@ -844,7 +866,7 @@ enum JournalE2EECrypto { try searchQuery(&query, storage: storage) let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(status) } } let wrappedStorage = try secretStorage( @@ -873,7 +895,7 @@ enum JournalE2EECrypto { try searchQuery(&query, storage: storage) let status = SecItemDelete(query as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.keyStoreFailure(status) } } let privateStorage = try secretStorage( @@ -930,14 +952,14 @@ enum JournalE2EECrypto { userID: identity.userID ) else { - throw JournalE2EECryptoError.operationFailed + throw JournalE2EECryptoError.enginePrivateKeyUnavailable } JournalUnlockTrace.mark("unwrap-graph-key") let graphKey = try rsaOAEPDecrypt( privateKeyData: privateKey, ciphertext: try hexData(request.ciphertext) ) - guard graphKey.count == 32 else { throw JournalE2EECryptoError.operationFailed } + guard graphKey.count == 32 else { throw JournalE2EECryptoError.invalidGraphKeyLength } return graphKey } @@ -959,7 +981,7 @@ enum JournalE2EECrypto { privateKeyData: privateKey, ciphertext: try wrappedCiphertext(encryptedGraphKey) ) - guard graphKey.count == 32 else { throw JournalE2EECryptoError.operationFailed } + guard graphKey.count == 32 else { throw JournalE2EECryptoError.invalidGraphKeyLength } } static func handle(_ request: JournalCryptoRequest) throws -> JournalCryptoResponse { @@ -1048,20 +1070,28 @@ enum JournalE2EECrypto { identity: identity, environment: ProcessInfo.processInfo.environment ) - else { throw JournalE2EECryptoError.wrappedGraphKeyUnavailable } + else { + JournalUnlockTrace.current?.failureReason = "wrapped-key-absent" + throw JournalE2EECryptoError.wrappedGraphKeyUnavailable + } do { try verifyWrappedGraphKey(identity: identity, encryptedGraphKey: encryptedGraphKey) } catch JournalE2EECryptoError.localPrivateKeyUnavailable { - try? deleteWrappedGraphKey( - identity: identity, - environment: ProcessInfo.processInfo.environment - ) + do { + try deleteWrappedGraphKey(identity: identity, environment: ProcessInfo.processInfo.environment) + } catch { + JournalUnlockTrace.current?.cleanupPending = true + JournalUnlockTrace.mark("wrapped-key-cleanup-failed") + } throw JournalE2EECryptoError.localPrivateKeyUnavailable } catch { - try? deleteWrappedGraphKey( - identity: identity, - environment: ProcessInfo.processInfo.environment - ) + JournalUnlockTrace.current?.failureReason = "wrapped-key-verification-failed:" + journalCryptoFailureCode(error) + do { + try deleteWrappedGraphKey(identity: identity, environment: ProcessInfo.processInfo.environment) + } catch { + JournalUnlockTrace.current?.cleanupPending = true + JournalUnlockTrace.mark("wrapped-key-cleanup-failed") + } throw JournalE2EECryptoError.wrappedGraphKeyUnavailable } return JournalCryptoResponse(ok: true, value: encryptedGraphKey) @@ -1104,6 +1134,32 @@ enum JournalE2EECrypto { } } +// Never serialize descriptions/userInfo: crypto errors can contain key material. +func journalCryptoFailureCode(_ error: any Error) -> String { + if error is DecodingError { return "cryptoInvalidRequest" } + if let crypto = error as? CryptoKitError, case .authenticationFailure = crypto { + return "cryptoAuthenticationFailed" + } + switch error as? JournalE2EECryptoError { + case .invalidRequest: return "cryptoInvalidRequest" + case .invalidPrivateKey: return "cryptoInvalidPrivateKey" + case .rsaDecryptionFailed: return "cryptoRSADecryptionFailed" + case .invalidGraphKeyLength: return "cryptoInvalidGraphKeyLength" + case .invalidAESInput: return "cryptoInvalidAESInput" + case .enginePrivateKeyUnavailable: return "cryptoEnginePrivateKeyUnavailable" + case .keyStoreInvalidType: return "cryptoKeyStoreInvalidType" + case .keyStoreFailure(let status): + switch status { + case errSecInteractionNotAllowed: return "cryptoKeyStoreLocked" + case errSecAuthFailed, errSecMissingEntitlement: return "cryptoKeyStorePermissionDenied" + case errSecNotAvailable: return "cryptoKeyStoreUnavailable" + case errSecDecode: return "cryptoKeyStoreDecodeFailed" + default: return "cryptoKeyStoreFailed" + } + default: return "cryptoOperationFailed" + } +} + @_cdecl("logseq_journal_crypto_json") public func logseqJournalCryptoJSON( _ requestPointer: UnsafePointer? @@ -1120,21 +1176,19 @@ public func logseqJournalCryptoJSON( let data = String(cString: requestPointer).data(using: .utf8) else { throw JournalE2EECryptoError.invalidRequest } let request = try JSONDecoder().decode(JournalCryptoRequest.self, from: data) - if ProcessInfo.processInfo.environment["JOURNAL_UNLOCK_DIAGNOSTICS"] == "1" { - trace = JournalUnlockTrace(request) - JournalUnlockTrace.current = trace - } + trace = JournalUnlockTrace(request) + JournalUnlockTrace.current = trace response = try JournalE2EECrypto.handle(request) } catch JournalE2EECryptoError.localPrivateKeyUnavailable { diagnosticResult = "local-private-key-unavailable" - response = JournalCryptoResponse(ok: false, error: "localPrivateKeyUnavailable") + response = JournalCryptoResponse(ok: false, error: "localPrivateKeyUnavailable", cleanupPending: trace?.cleanupPending == true ? true : nil) } catch JournalE2EECryptoError.wrappedGraphKeyUnavailable { diagnosticResult = "wrapped-key-unavailable" - response = JournalCryptoResponse(ok: false, error: "wrappedGraphKeyUnavailable") + response = JournalCryptoResponse(ok: false, error: "wrappedGraphKeyUnavailable", reason: trace?.failureReason, cleanupPending: trace?.cleanupPending == true ? true : nil) } catch { - diagnosticResult = (error as? JournalE2EECryptoError) == .invalidRequest - ? "invalid-request" : "crypto-failed" - response = JournalCryptoResponse(ok: false, error: "cryptoOperationFailed") + let reason = journalCryptoFailureCode(error) + diagnosticResult = reason + response = JournalCryptoResponse(ok: false, error: reason, reason: trace?.failureReason, cleanupPending: trace?.cleanupPending == true ? true : nil) } if ProcessInfo.processInfo.environment["JOURNAL_UNLOCK_DIAGNOSTICS"] == "1" { if trace == nil { trace = JournalUnlockTrace(nil) } @@ -1159,13 +1213,15 @@ public func logseqJournalCryptoBinary( _ outputLength: UnsafeMutablePointer? ) -> UnsafeMutablePointer? { autoreleasepool { + // Negative lengths are finite failure sentinels consumed only when returning nil. + outputLength?.pointee = -1 guard let key, let input, let outputLength, keyLength == 32, inputLength >= 0, (sealing == 1 && ivLength == 0 && inputLength <= 8 * 1024 * 1024) || (sealing == 0 && ivLength == 12 && inputLength >= 16 && inputLength <= 8 * 1024 * 1024 + 16) else { return nil } - outputLength.pointee = 0 + outputLength.pointee = -4 do { let symmetricKey = SymmetricKey(data: Data(bytes: key, count: keyLength)) let data = Data(bytes: input, count: inputLength) @@ -1175,17 +1231,25 @@ public func logseqJournalCryptoBinary( else { return nil } result = combined } else { - guard let iv else { return nil } + guard let iv else { outputLength.pointee = -1; return nil } let box = try AES.GCM.SealedBox( nonce: AES.GCM.Nonce(data: Data(bytes: iv, count: ivLength)), ciphertext: data.dropLast(16), tag: data.suffix(16)) result = try AES.GCM.open(box, using: symmetricKey) } - guard let allocation = malloc(max(1, result.count)) else { return nil } + guard let allocation = malloc(max(1, result.count)) else { + outputLength.pointee = -3 + return nil + } let output = allocation.assumingMemoryBound(to: UInt8.self) result.copyBytes(to: output, count: result.count) outputLength.pointee = result.count return output - } catch { return nil } + } catch { + if let crypto = error as? CryptoKitError, case .authenticationFailure = crypto { + outputLength.pointee = -2 + } + return nil + } } } diff --git a/swift/JournalExtensions.swift b/swift/JournalExtensions.swift index c7149a09..1bc0fb0b 100644 --- a/swift/JournalExtensions.swift +++ b/swift/JournalExtensions.swift @@ -99,13 +99,16 @@ enum JournalExtensionFingerprint { static func decode( _ type: Properties.Type, context: LUIAppleExtensionViewContext ) -> Properties? { - guard case let .string(json) = context.property("payload"), - let value = try? JSONDecoder().decode(type, from: Data(json.utf8)) - else { - report(context: context, code: "diagnostic.nativePropsDecode") + let property = context.property("payload") + guard case let .string(json) = property else { + report(context:context,code:"diagnostic.nativePropsDecode." + (property == nil ? "missingPayload" : "typeMismatch")) + return nil + } + do { return try JournalSafeDiagnostic.decode(type,from:Data(json.utf8)) } + catch { + report(context:context,code:"diagnostic.nativePropsDecode." + JournalSafeDiagnostic.classify(error).rawValue) return nil } - return value } /// Reuse the registered event envelope for fixed diagnostic codes. The OCaml @@ -141,7 +144,10 @@ enum JournalExtensionFingerprint { ]) return true } catch { - report(context: context, code: "diagnostic.nativeEventDelivery") + let reason:JournalSafeDiagnostic + if case let LUIBackendError.invalidBatch(source) = error { reason = .backendBatch(source) } + else { reason = .classify(error) } + report(context: context, code: "diagnostic.nativeEventDelivery." + reason.rawValue) return false } } diff --git a/swift/JournalImagePreview.swift b/swift/JournalImagePreview.swift index ea9d037b..354ee6c5 100644 --- a/swift/JournalImagePreview.swift +++ b/swift/JournalImagePreview.swift @@ -338,9 +338,9 @@ import UIKit case .notImage: title = Self.text("Cannot Save Image", "无法保存图片") message = Self.text("This file is not a supported image.", "此文件不是支持的图片格式。") - case .failed: + case .failed(let reason): title = Self.text("Save Failed", "保存失败") - message = Self.text("The image could not be saved. Please try again.", "未能保存图片,请重试。") + message = Self.text("The image could not be saved. Please try again.", "未能保存图片,请重试。") + " (\(reason.description))" } let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) alert.addAction(UIAlertAction(title: Self.text("OK", "好"), style: .default)) diff --git a/swift/JournalList.swift b/swift/JournalList.swift index bea940cd..0698d400 100644 --- a/swift/JournalList.swift +++ b/swift/JournalList.swift @@ -234,7 +234,7 @@ import UIKit payload = nextPayload snapshot = nextPayload.flatMap { Snapshot($0) } if snapshot == nil { - JournalExtensions.report(context: context, code: "diagnostic.nativePropsDecode") + JournalExtensions.report(context: context, code: "diagnostic.nativePropsDecode.invalidPayload") } let positions = snapshot?.positions ?? [:] rowIncarnations = positions.reduce(into: [:]) { result, entry in @@ -270,16 +270,19 @@ import UIKit } } let next: String? - if case let .string(json) = value { next = json } else { next = nil } + if case let .string(json) = value { next = json } else { + next = nil + if value != nil { JournalExtensions.report(context:context,code:"diagnostic.nativeScrollDecode.typeMismatch") } + } if next != commandPayload { commandPayload = next do { scrollRequest = try next.flatMap { - try JSONDecoder().decode(ScrollRequest?.self, from: Data($0.utf8)) + try JournalSafeDiagnostic.decode(ScrollRequest?.self, from: Data($0.utf8)) } } catch { scrollRequest = nil - JournalExtensions.report(context: context, code: "diagnostic.nativePropsDecode") + JournalExtensions.report(context: context, code: "diagnostic.nativeScrollDecode." + JournalSafeDiagnostic.classify(error).rawValue) } commandNeedsDelivery = true } diff --git a/swift/JournalLocalAccountBindingStore.swift b/swift/JournalLocalAccountBindingStore.swift index efbb606f..e147af6a 100644 --- a/swift/JournalLocalAccountBindingStore.swift +++ b/swift/JournalLocalAccountBindingStore.swift @@ -72,24 +72,30 @@ enum JournalLocalAccountBindingStore { value[kSecAttrAccount] = "pending-account-proposal" return value } - static func loadRecoveryData() throws -> Data? { try loadData(query: recoveryQuery()) } - static func writeRecoveryData(_ data: Data) throws { try writeData(data, query: recoveryQuery()) } + static func loadRecoveryData() throws -> Data? { try loadData(query: recoveryQuery(), source: .recoveryLoad) } + static func writeRecoveryData(_ data: Data) throws { try writeData(data, query: recoveryQuery(), source: .recoveryWrite, addition: .recoveryAdd) } static func clearRecoveryData() throws { let status = SecItemDelete(recoveryQuery() as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingWrite) + throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingWrite, + nativeDetail: .init(source: .recoveryClear, reason: .status, osStatus: Int(status))) } } - private static func loadData(query: [CFString: Any]) throws -> Data? { + private static func loadData(query: [CFString: Any], source: JournalAccountDiagnostic.Source = .bindingLoad) throws -> Data? { var lookup = query lookup[kSecReturnData] = kCFBooleanTrue lookup[kSecMatchLimit] = kSecMatchLimitOne var item: CFTypeRef? let status = SecItemCopyMatching(lookup as CFDictionary, &item) if status == errSecItemNotFound { return nil } - guard status == errSecSuccess, let data = item as? Data else { - throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingRead) + guard status == errSecSuccess else { + throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingRead, + nativeDetail: .init(source: source, reason: .status, osStatus: Int(status))) + } + guard let data = item as? Data else { + throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingRead, + nativeDetail: .init(source: source, reason: .wrongType)) } return data } @@ -110,26 +116,32 @@ enum JournalLocalAccountBindingStore { try writeData(data, query: query()) } - private static func writeData(_ data: Data, query: [CFString: Any]) throws { + private static func writeData(_ data: Data, query: [CFString: Any], + source: JournalAccountDiagnostic.Source = .bindingWrite, + addition additionSource: JournalAccountDiagnostic.Source = .bindingAdd) throws { // Keep the existing record if a replacement cannot be saved securely. var status = SecItemUpdate(query as CFDictionary, [kSecValueData: data] as CFDictionary) + var operation = source if status == errSecItemNotFound { var addition = query addition[kSecValueData] = data status = SecItemAdd(addition as CFDictionary, nil) + operation = additionSource } guard status == errSecSuccess else { - throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingWrite) + throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingWrite, + nativeDetail: .init(source: operation, reason: .status, osStatus: Int(status))) } } static func restoreData(_ data: Data?) throws { - if let data { try writeData(data) } + if let data { try writeData(data, query: query(), source: .bindingRestore, addition: .bindingRestore) } else { // Remove only the newly proposed item when no item existed beforehand. let status = SecItemDelete(query() as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingWrite) + throw JournalAccountIssue(kind: .secureStorageUnavailable, stage: .bindingWrite, + nativeDetail: .init(source: .bindingRestore, reason: .status, osStatus: Int(status))) } } } @@ -137,7 +149,9 @@ enum JournalLocalAccountBindingStore { static func clear() throws { let status = SecItemDelete(query() as CFDictionary) guard status == errSecSuccess || status == errSecItemNotFound else { - throw CocoaError(.fileWriteUnknown) + // Keep the prior generic/unavailable recovery policy of this failure. + throw JournalAccountIssue(kind: .unavailable, stage: .bindingWrite, + nativeDetail: .init(source: .bindingClear, reason: .status, osStatus: Int(status))) } } } diff --git a/swift/JournalNativeServices.swift b/swift/JournalNativeServices.swift index cb783711..4a74425b 100644 --- a/swift/JournalNativeServices.swift +++ b/swift/JournalNativeServices.swift @@ -21,8 +21,16 @@ import OSLog in: .userDomainMask, appropriateFor: nil, create: true) // The existing Apple host uses this directory directly, without a suffix. let canonical = support.resolvingSymlinksInPath().path - return try JournalStartupConfiguration.encode( - applicationSupportPath: canonical, managedSyncOrigin: managedSyncOrigin) + return try startupPayload(applicationSupportPath:canonical,managedSyncOrigin:managedSyncOrigin) + } + + static func startupPayload(applicationSupportPath:String,managedSyncOrigin:String) throws -> Data { + do { + return try JournalStartupConfiguration.encode(applicationSupportPath:applicationSupportPath, + managedSyncOrigin:managedSyncOrigin) + } catch JournalStartupConfiguration.Failure.invalidConfiguration { + throw JournalSafeDiagnostic.invalidStartupConfiguration + } } } @@ -87,8 +95,9 @@ private final class JournalNativeAccountStorage: Sendable { if Self.acceptedRecovery == recovery.id { do { try await self.repairIfNeeded() } catch { + let reason = JournalAccountIssue.classify(error, stage: .bindingWrite).diagnostic Logger(subsystem: "com.logseq.journal", category: "account") - .error("Account binding cleanup is pending (account.bindingCleanup.failed)") + .error("Account binding cleanup is pending (account.bindingCleanup.failed): \(reason, privacy: .public)") } } } @@ -110,9 +119,13 @@ private final class JournalNativeAccountStorage: Sendable { let recovery: Recovery do { recovery = try JSONDecoder().decode(Recovery.self, from: bytes) - guard recovery.version == 1 else { throw JournalPlatformServices.Failure.unavailable } } catch { - throw JournalAccountIssue(kind: .unavailable, stage: .bindingWrite) + throw JournalAccountIssue(kind: .unavailable, stage: .bindingWrite, + nativeDetail: .init(source: .recovery, reason: .invalidRecord)) + } + guard recovery.version == 1 else { + throw JournalAccountIssue(kind: .unavailable, stage: .bindingWrite, formatVersion: recovery.version, + nativeDetail: .init(source: .recovery, reason: .unsupportedFormat)) } let accepted = Self.acceptedRecovery == recovery.id try await perform { diff --git a/swift/JournalPhotoSave.swift b/swift/JournalPhotoSave.swift index 069f436b..7bd79ef0 100644 --- a/swift/JournalPhotoSave.swift +++ b/swift/JournalPhotoSave.swift @@ -3,7 +3,7 @@ import Foundation /// Owns one add-only save operation independently of the preview/graph lifetime. @MainActor final class JournalPhotoSave { enum Authorization { case notDetermined, authorized, denied, restricted } - enum Feedback: Equatable { case saved, denied, restricted, notImage, failed } + enum Feedback: Equatable { case saved, denied, restricted, notImage; case failed(JournalAssetDiagnostic) } enum StageError: Error { case notImage } final class FileLease { @@ -22,7 +22,7 @@ import Foundation let authorization: () -> Authorization let requestAuthorization: (@escaping @MainActor (Authorization) -> Void) -> Void let stage: (URL) throws -> FileLease - let write: (URL, @escaping @MainActor (Bool) -> Void) -> Void + let write: (URL, @escaping @MainActor (Result) -> Void) -> Void let feedback: (Feedback) -> Void } @@ -51,7 +51,7 @@ import Foundation let file: FileLease do { file = try dependencies.stage(selectedURL) } catch StageError.notImage { dependencies.feedback(.notImage); return } - catch { dependencies.feedback(.failed); return } + catch { dependencies.feedback(.failed(.classify(error, stage: .photoStage))); return } let current = Operation(file: file) operation = current onBusyChanged?(true) @@ -70,9 +70,12 @@ import Foundation case .authorized: current.writing = true // Completion retains this owner and file even if the UI disappears. - dependencies.write(current.file.url) { [self, current] success in + dependencies.write(current.file.url) { [self, current] result in guard operation === current else { return } - finish(current, feedback: success ? .saved : .failed) + switch result { + case .success: finish(current, feedback: .saved) + case .failure(let reason): finish(current, feedback: .failed(reason)) + } } case .denied, .notDetermined: finish(current, feedback: .denied) case .restricted: finish(current, feedback: .restricted) diff --git a/swift/JournalPhotoSaveFile.swift b/swift/JournalPhotoSaveFile.swift index e07167c8..459654d7 100644 --- a/swift/JournalPhotoSaveFile.swift +++ b/swift/JournalPhotoSaveFile.swift @@ -9,8 +9,9 @@ enum JournalPhotoSaveFile { do { try FileManager.default.removeItem(at: directory) } catch let error as CocoaError where error.code == .fileNoSuchFile { } // Idempotent cleanup. catch { + let reason = JournalAssetDiagnostic.classify(error, stage: .cleanup) Logger(subsystem: "com.logseq.journal", category: "cleanup") - .error("Temporary photo cleanup could not finish (photoSave.cleanup.failed)") + .error("Temporary photo cleanup could not finish (photoSave.cleanup.failed; \(reason.description, privacy: .public))") } } diff --git a/swift/JournalPhotos.swift b/swift/JournalPhotos.swift index e953c43f..8db4667d 100644 --- a/swift/JournalPhotos.swift +++ b/swift/JournalPhotos.swift @@ -30,8 +30,11 @@ import Photos options.shouldMoveFile = false request.addResource(with: .photo, fileURL: url, options: options) } - let finished: @Sendable (Bool, (any Error)?) -> Void = { success, _ in - Task { @MainActor in completion(success) } + let finished: @Sendable (Bool, (any Error)?) -> Void = { success, error in + let result: Result = success ? .success(()) + : .failure(error.map { .classify($0, stage: .photoWrite) } + ?? .init(stage: .photoWrite, category: .failed)) + Task { @MainActor in completion(result) } } PHPhotoLibrary.shared().performChanges(changes, completionHandler: finished) }, diff --git a/swift/JournalPlatformServices.swift b/swift/JournalPlatformServices.swift index 523693f3..3e934ed1 100644 --- a/swift/JournalPlatformServices.swift +++ b/swift/JournalPlatformServices.swift @@ -1,5 +1,64 @@ import Foundation +enum JournalCognitoError: String, Error, Equatable, Sendable, LocalizedError { + case configuration, callback, expired, invalidToken, storage, unavailable, request, invalidGrant + var errorDescription: String? { + switch self { + case .configuration: "Web sign-in is not configured." + case .callback: "The sign-in response could not be verified. Please try again." + case .expired: "Sign-in timed out. Please try again." + case .invalidToken: "The account session could not be verified. Please sign in again." + case .storage: "Unable to save the account session securely. Please try again." + case .unavailable: "Unable to open the sign-in page. Please try again." + case .request: "Unable to connect to sign-in. Please try again." + case .invalidGrant: "Your account session expired. Please sign in again." + } + } +} + +/// Producer-owned tags and numbers only; never retains external error strings. +struct JournalAccountDiagnostic: Equatable, Sendable { + enum Source: String { + case keychainLoad, keychainSave, keychainAdd, keychainClear, legacyClear, browser + case token, jwks, tokenValidation, revoke, network, secureStorage, recovery + case bindingLoad, bindingWrite, bindingAdd, bindingRestore, bindingClear + case recoveryLoad, recoveryWrite, recoveryAdd, recoveryClear + } + enum Reason: String { + case status, wrongType, responseTooLarge, invalidRecord, operationInProgress + case missingAnchor, startRejected, browserFailed, redirect, httpStatus, responseShape + case transport, verificationFailed, unsupportedFormat + } + enum OAuth: String { + case invalid_request, invalid_client, invalid_grant, unauthorized_client + case unsupported_grant_type, invalid_scope, access_denied, server_error, temporarily_unavailable, unknown + init(safeCode: String) { self = Self(rawValue: safeCode) ?? .unknown } + } + let source: Source + let reason: Reason + var httpStatus: Int? = nil + var urlCode: Int? = nil + var osStatus: Int? = nil + var oauth: OAuth? = nil + var diagnostic: String { + "\(source.rawValue).\(reason.rawValue)" + + (httpStatus.map { " · http \($0)" } ?? "") + + (urlCode.map { " · url \($0)" } ?? "") + + (osStatus.map { " · osstatus \($0)" } ?? "") + + (oauth.map { " · oauth.\($0.rawValue)" } ?? "") + } +} + +struct JournalCognitoFailure: Error, Equatable, Sendable, LocalizedError { + let category: JournalCognitoError + let detail: JournalAccountDiagnostic + var accountKind: JournalAccountIssue.Kind = .unavailable + var errorDescription: String? { "\(category.errorDescription ?? "Unable to complete sign-in.") (\(detail.diagnostic))" } + static func category(of error: Error) -> JournalCognitoError? { + (error as? Self)?.category ?? (error as? JournalCognitoError) + } +} + /// Only bounded diagnostic metadata crosses the native account boundary. struct JournalAccountIssue: Error, Equatable, Sendable, LocalizedError { enum Kind: String { case unsupportedFormat, corruptBinding, secureStorageUnavailable, network, signInRequired, accountChangeBlocked, unavailable } @@ -7,14 +66,20 @@ struct JournalAccountIssue: Error, Equatable, Sendable, LocalizedError { let kind: Kind let stage: Stage var formatVersion: Int? = nil + var cognitoReason: JournalCognitoError? = nil + var nativeDetail: JournalAccountDiagnostic? = nil var rejectsBinding: Bool { kind == .unsupportedFormat || kind == .corruptBinding } var offersSignIn: Bool { rejectsBinding || kind == .signInRequired || kind == .accountChangeBlocked || kind == .unavailable } var diagnostic: String { "account.\(stage.rawValue).\(kind.rawValue)" + (formatVersion.map { " · format \($0)" } ?? "") + + (cognitoReason.map { " · cognito.\($0.rawValue)" } ?? "") + + (nativeDetail.map { " · \($0.diagnostic)" } ?? "") } var errorDescription: String? { - switch kind { + let message: String? + if let cognitoReason { message = cognitoReason.errorDescription } + else { message = switch kind { case .unsupportedFormat: "This app cannot read the saved account format. Sign in again to reconnect your account." case .corruptBinding: "The saved account connection cannot be read. Sign in again to reconnect your account." case .secureStorageUnavailable: "Secure account storage is unavailable. Unlock your device, then retry." @@ -22,17 +87,30 @@ struct JournalAccountIssue: Error, Equatable, Sendable, LocalizedError { case .signInRequired: "Sign in again to connect your account." case .accountChangeBlocked: "This session has a local graph open. Sign in to the original account to finish pending work before changing accounts." case .unavailable: "Unable to prepare your account. Retry, or reopen sign-in." - } + } } + guard let nativeDetail else { return message } + return "\(message ?? "Unable to prepare your account.") (\(nativeDetail.diagnostic))" } static func classify(_ error: Error, stage: Stage) -> Self { if let issue = error as? Self { return issue } + if let failure = error as? JournalCognitoFailure { + return Self(kind: failure.accountKind, + stage: stage, cognitoReason: failure.category, nativeDetail: failure.detail) + } + if let reason = error as? JournalCognitoError { + return Self(kind: .unavailable, stage: stage, cognitoReason: reason) + } if case JournalPlatformServices.Failure.invalidBinding = error { return Self(kind: .corruptBinding, stage: stage) } - if error is URLError { return Self(kind: .network, stage: stage) } + if let error = error as? URLError { + return Self(kind: .network, stage: stage, + nativeDetail: .init(source: .network, reason: .transport, urlCode: error.errorCode)) + } let native = error as NSError if native.domain == NSOSStatusErrorDomain { - return Self(kind: .secureStorageUnavailable, stage: stage) + return Self(kind: .secureStorageUnavailable, stage: stage, + nativeDetail: .init(source: .secureStorage, reason: .status, osStatus: native.code)) } return Self(kind: .unavailable, stage: stage) } diff --git a/swift/JournalPlatformWire.swift b/swift/JournalPlatformWire.swift index b7f4f77a..82d6fcbc 100644 --- a/swift/JournalPlatformWire.swift +++ b/swift/JournalPlatformWire.swift @@ -3,6 +3,24 @@ import Foundation /// The application-owned protocol shared with Journal_platform in OCaml. enum JournalPlatformWire { enum Failure: Error { case invalidPacket } + enum RequestFailure: UInt16 { + case cancelled=1, requestDecode=2, responseEncode=3, clipboardUnavailable=4 + case serviceUnavailable=5, serviceFailed=6, authenticationRequired=7, invalidBinding=8 + case network=9, secureStorage=10, invalidPacket=11 + } + /// Versioned failure envelope: tag/reason plus account/presentation generations. + /// It never includes request bytes, challenges, account values or copied text. + static func requestFailure(_ request: Data, reason: RequestFailure, scope:StartupScope = .initial) -> Data { + let known: [UInt8] = [6,8,10,13,20,22,28,30,32] + let tag: UInt8 = request.count >= 8 && request.prefix(6) == Data([76,74,80,50,2,0]) + && request[7] == 0 && known.contains(request[6]) ? request[6] : 0 + var result=Data([76,74,70,49,tag,0,UInt8(reason.rawValue),0]) + for value in [scope.accountGeneration,scope.presentationGeneration] { + let bits=UInt64(bitPattern:value) + result.append(contentsOf:(0..<8).map { UInt8(truncatingIfNeeded:bits >> ($0*8)) }) + } + return result + } enum Request: Equatable { case authenticatedUser, signOut, terminationReady, localAccount, timelinePresented, onlineRequested, onlineCancelled case idToken(challengeID: String) diff --git a/swift/JournalRuntime.swift b/swift/JournalRuntime.swift index 3a26b04f..bc1e00cc 100644 --- a/swift/JournalRuntime.swift +++ b/swift/JournalRuntime.swift @@ -20,6 +20,8 @@ private func luiOCamlStart( _ payload: UnsafePointer?, _ payloadLength: Int32 ) -> Int32 +@_silgen_name("journal_ocaml_failure_reason") +private func journalOCamlFailureReason(_ operation:Int32) -> Int32 @_silgen_name("lui_ocaml_stop") private func luiOCamlStop() -> Int32 @_silgen_name("journal_ocaml_pump") @@ -60,31 +62,46 @@ private let wakeup: WakeupCallback = { private final class JournalPlatformRequestIngress: @unchecked Sendable { private let lock = NSLock() private var owner: UUID? - private var pending: [Data] = [] - private var receiver: (@Sendable (Data) -> Void)? + struct Request: Sendable { + let bytes:Data + let ticket:UUID + let tag:UInt8? + } + private var latest:[UInt8:UUID] = [:] + private var pending: [Request] = [] + private var receiver: (@Sendable (Request) -> Void)? func begin(_ identity: UUID) { lock.lock(); defer { lock.unlock() } - owner = identity; pending = []; receiver = nil + owner = identity; pending = []; latest = [:]; receiver = nil } func submit(_ request: Data) { lock.lock() guard owner != nil else { lock.unlock(); return } + let tag:UInt8? = request.count >= 8 && request.prefix(6) == Data([76,74,80,50,2,0]) + && request[7] == 0 && [6,8,10,13,20,22,28,30,32].contains(request[6]) ? request[6] : nil + let receipt=Request(bytes:request,ticket:UUID(),tag:tag) + if let tag { latest[tag] = receipt.ticket } let deliver = receiver - if deliver == nil { pending.append(request) } + if deliver == nil { pending.append(receipt) } lock.unlock() - deliver?(request) + deliver?(receipt) } - func activate(_ identity: UUID, receiver: @escaping @Sendable (Data) -> Void) -> [Data] { + func activate(_ identity: UUID, receiver: @escaping @Sendable (Request) -> Void) -> [Request] { lock.lock(); defer { lock.unlock() } guard owner == identity else { return [] } self.receiver = receiver let requests = pending; pending = [] return requests } + func owns(_ identity:UUID, _ request:Request) -> Bool { + lock.lock(); defer { lock.unlock() } + guard owner == identity else { return false } + return request.tag.map { latest[$0] == request.ticket } ?? true + } func close(_ identity: UUID) { lock.lock(); defer { lock.unlock() } guard owner == identity else { return } - owner = nil; receiver = nil; pending = [] + owner = nil; receiver = nil; pending = []; latest = [:] } } private let platformRequestIngress = JournalPlatformRequestIngress() @@ -109,6 +126,44 @@ private let platformRequest: PlatformRequestCallback = { data, length in "Journal could not complete a native operation (\(rawValue)). Retry opening Journal." } } + struct FailureDetail: Error, LocalizedError { + let operation: Failure + let reason: JournalSafeDiagnostic + var errorDescription:String? { + "Journal could not complete a native operation (\(operation.rawValue) · \(reason.rawValue)). \(reason.errorDescription!)" + } + } + private(set) var failureReason: JournalSafeDiagnostic? + var failureDescription:String? { + failure.map { FailureDetail(operation:$0,reason:failureReason ?? .unknown).errorDescription! } + } + static func safeReason(_ error:Error) -> JournalSafeDiagnostic { + if case let LUIBackendError.invalidBatch(source) = error { return .backendBatch(source) } + return .classify(error) + } + static func safeDescription(_ error:Error, operation:String) -> String { + if let error = error as? FailureDetail { return error.errorDescription! } + let reason = safeReason(error) + return "Journal could not finish (\(operation) · \(reason.rawValue)). \(reason.errorDescription!)" + } + private func record(_ operation:Failure, _ reason:JournalSafeDiagnostic) { + failure = operation; failureReason = reason + } + private func bridgeReason(_ operation:Int32) -> JournalSafeDiagnostic { + switch journalOCamlFailureReason(operation) { + case 1: .missingHandler + case 2: .callbackFailed + case 3: .allocationFailed + case 4: .invalidInput + case 5: .startupDecodeFailed + case 6: .startupWorkerFailed + case 7: .startupMountFailed + default: .unknown + } + } + private var reportedFailure:FailureDetail? { + failure.map { FailureDetail(operation:$0,reason:failureReason ?? .unknown) } + } let backend: LUIAppleBackend let platform: JournalApplicationPlatform private let startupPayload: Data @@ -128,10 +183,9 @@ private let platformRequest: PlatformRequestCallback = { data, length in ) throws { self.platform = platform self.startupPayload = startupPayload - backend = try LUIAppleBackend( - appIcons: journalAppIcons, - extensionRegistry: extensionRegistry - ) + do { + backend = try LUIAppleBackend(appIcons: journalAppIcons, extensionRegistry: extensionRegistry) + } catch { throw FailureDetail(operation:.start,reason:Self.safeReason(error)) } backend.onEvent = { [weak self] event in self?.handle(event) } } @@ -139,7 +193,7 @@ private let platformRequest: PlatformRequestCallback = { data, length in /// `lui_ocaml_start` so init receives it before the worker session begins. /// The platform event stream attaches afterwards. func start() throws { - if let failure { throw failure } + if let reportedFailure { throw reportedFailure } guard !started else { return } generation = UUID() let identity = generation @@ -161,35 +215,37 @@ private let platformRequest: PlatformRequestCallback = { data, length in bytes.baseAddress?.assumingMemoryBound(to: CChar.self), Int32(bytes.count)) } + guard generation == identity else { throw CancellationError() } guard accepted == 1 else { + record(.start,bridgeReason(1)) + let reported = reportedFailure! _ = luiOCamlStop() activeRuntime = nil - failure = .start - throw Failure.start + throw reported } - if let failure { + if let reportedFailure { _ = luiOCamlStop() activeRuntime = nil - throw failure + throw reportedFailure } guard rootID != nil else { _ = luiOCamlStop() activeRuntime = nil - failure = .start - throw Failure.start + record(.start,.missingRoot) + throw reportedFailure! } started = true platform.connect(to: self) let requests = platformRequestIngress.activate(identity) { [weak self] request in Task { @MainActor [weak self] in guard let self, self.generation == identity else { return } - await self.deliverPlatformRequest(request) + await self.deliverPlatformRequest(request.bytes,receipt:request) } } initialPlatformTasks = requests.map { request in Task { @MainActor [weak self] in guard let self, self.generation == identity else { return } - await self.deliverPlatformRequest(request) + await self.deliverPlatformRequest(request.bytes,receipt:request) } } } @@ -214,47 +270,59 @@ private let platformRequest: PlatformRequestCallback = { data, length in @discardableResult func stop() -> Bool { guard started || stopPending else { return true } platformRequestIngress.close(generation) + generation = UUID() for task in initialPlatformTasks { task.cancel() } initialPlatformTasks = [] platform.disconnect() started = false activeRuntime = nil stopPending = luiOCamlStop() != 1 - if stopPending { failure = .stop } + if stopPending { record(.stop,bridgeReason(2)) } return !stopPending } func apply(json: String) { + let identity = generation do { try backend.apply(json: json) + guard generation == identity else { return } rootID = backend.rootIDs.first appliedPatches += 1 } catch { - failure = .patch + guard generation == identity else { return } + record(.patch,Self.safeReason(error)) } } func pump() { guard started else { return } - if journalOCamlPump() != 1 { failure = .pump } + let identity = generation, accepted = journalOCamlPump() + if generation == identity, started, accepted != 1 { record(.pump,bridgeReason(3)) } } /// Marshals one LJP2 request onto the platform actor and ships its response - /// envelope back through the C entry. A nil response (decode or service - /// failure) reports the request as failed so OCaml resolves its pending - /// continuation instead of waiting forever. + /// envelope back through the C entry. Current failure/cancellation completes + /// the owned continuation via LJF1; nil belongs to retired/suppressed work. func deliverPlatformRequest(_ bytes: Data) async { + await deliverPlatformRequest(bytes,receipt:nil) + } + private func deliverPlatformRequest(_ bytes:Data, receipt:JournalPlatformRequestIngress.Request?) async { guard started else { return } let identity = generation + func ownsRequest() -> Bool { + generation == identity && (receipt.map { platformRequestIngress.owns(identity,$0) } ?? true) + } + guard ownsRequest() else { return } let response = await platform.request(bytes) - guard started, generation == identity, !Task.isCancelled else { return } - guard let response else { - let accepted = bytes.withUnsafeBytes { buffer in - journalOCamlPlatformFailure( - buffer.baseAddress?.assumingMemoryBound(to: CChar.self), - Int32(buffer.count)) + guard started, ownsRequest(), !Task.isCancelled else { return } + // nil belongs to retired/suppressed work. Current cancellation is an LJF1 + // result and still completes the original continuation without a ledger row. + guard let response else { return } + if response.prefix(4) == Data("LJF1".utf8) { + let accepted = response.withUnsafeBytes { buffer in + journalOCamlPlatformFailure(buffer.baseAddress?.assumingMemoryBound(to:CChar.self),Int32(buffer.count)) } - if accepted != 1 { failure = .platformFailure } + if generation == identity, started, accepted != 1 { record(.platformFailure,bridgeReason(6)) } return } let accepted = response.withUnsafeBytes { buffer in @@ -262,22 +330,24 @@ private let platformRequest: PlatformRequestCallback = { data, length in buffer.baseAddress?.assumingMemoryBound(to: CChar.self), Int32(buffer.count)) } - if accepted != 1 { failure = .platformResponse } + if generation == identity, started, accepted != 1 { record(.platformResponse,bridgeReason(5)) } } /// Pushes one host-originated LJP2 event envelope to OCaml. func sendPlatformEvent(_ bytes: Data) { guard started else { return } + let identity = generation let accepted = bytes.withUnsafeBytes { buffer in journalOCamlPlatformEvent( buffer.baseAddress?.assumingMemoryBound(to: CChar.self), Int32(buffer.count)) } - if accepted != 1 { failure = .platformEvent } + if generation == identity, started, accepted != 1 { record(.platformEvent,bridgeReason(4)) } } private func handle(_ event: LUIEvent) { guard started else { return } - if JournalLUIEvents.dispatch(event) != 1 { failure = .event } + let identity = generation, accepted = JournalLUIEvents.dispatch(event) + if generation == identity, started, accepted != 1 { record(.event,bridgeReason(7)) } } } diff --git a/swift/JournalRuntimeHost.swift b/swift/JournalRuntimeHost.swift index 5cc4d1b9..8094fb32 100644 --- a/swift/JournalRuntimeHost.swift +++ b/swift/JournalRuntimeHost.swift @@ -28,8 +28,7 @@ struct JournalRuntimeHost: View { ContentUnavailableView { Label("Unable to open journal", systemImage: "exclamationmark.folder") } description: { - Text(runtime?.failure?.errorDescription ?? - (failure as? JournalRuntime.Failure)?.errorDescription ?? + Text(runtime?.failureDescription ?? failure.map { JournalRuntime.safeDescription($0,operation:"runtime.start.failed") } ?? "Journal could not start (runtime.start.failed). Retry opening Journal.") } actions: { Button("Retry") { @@ -193,11 +192,11 @@ struct JournalHost: View { switch setup { case .none: JournalStartupLoadingSurface(loading: false, initial: true, active: scenePhase != .background) - case .failure: + case .failure(let error): ContentUnavailableView { Label("Unable to open local application storage", systemImage: "exclamationmark.folder") } description: { - Text("Local storage setup could not finish (storage.setup.failed). Check available storage and retry.") + Text(JournalRuntime.safeDescription(error,operation:"storage.setup.failed")) } actions: { Button("Retry") { setup = nil; clock.restart(); retry += 1 } .accessibilityIdentifier("journal-storage-retry") diff --git a/swift/JournalSafeDiagnostic.swift b/swift/JournalSafeDiagnostic.swift new file mode 100644 index 00000000..4f0f263a --- /dev/null +++ b/swift/JournalSafeDiagnostic.swift @@ -0,0 +1,79 @@ +import Foundation + +/// A closed vocabulary; associated exception strings never become user text. +enum JournalSafeDiagnostic: String, Error, LocalizedError, Sendable { + case unknown, missingPayload, typeMismatch, keyNotFound, valueNotFound, dataCorrupted, invalidPayload + case missingHandler, callbackFailed, allocationFailed, invalidInput, missingRoot + case startupDecodeFailed, startupWorkerFailed, startupMountFailed + case invalidStartupConfiguration + case backendInvalidBatch, backendUnsupportedProperty, backendUnknownNode, backendUnknownExtension + case backendFingerprintMismatch, backendInvalidHierarchy, backendInvalidEvent, backendRegistryInvalid + case noSpace, permissionDenied, sourceUnavailable, network, secureStorageUnavailable + var errorDescription: String? { + switch self { + case .unknown: "The native operation could not finish. Retry the operation." + case .missingPayload: "The required native payload is missing." + case .typeMismatch: "A native field has an unexpected type." + case .keyNotFound: "A required native field is missing." + case .valueNotFound: "A required native value is missing." + case .dataCorrupted: "The native payload format is invalid." + case .invalidPayload: "The native payload is unsupported." + case .missingHandler: "The native runtime callback is not registered. Reopen Journal." + case .callbackFailed: "The native runtime callback failed. Retry opening Journal." + case .allocationFailed: "The native runtime could not allocate its response. Reopen Journal." + case .invalidInput: "The native runtime received an invalid input." + case .missingRoot: "The native runtime did not provide a root view. Reopen Journal." + case .startupDecodeFailed: "The native startup configuration could not be decoded. Reopen Journal." + case .startupWorkerFailed: "The native graph worker could not start. Retry opening Journal." + case .startupMountFailed: "The native root view could not be mounted. Retry opening Journal." + case .invalidStartupConfiguration: "The native startup configuration is invalid. Reopen Journal." + case .backendInvalidBatch: "The native backend rejected the patch or event batch." + case .backendUnsupportedProperty: "The native backend rejected an unsupported property." + case .backendUnknownNode: "The native backend could not find the destination node." + case .backendUnknownExtension: "The native extension is not registered." + case .backendFingerprintMismatch: "The native extension schema does not match." + case .backendInvalidHierarchy: "The native node hierarchy is invalid." + case .backendInvalidEvent: "The native event schema is invalid." + case .backendRegistryInvalid: "The native extension registry is invalid." + case .noSpace: "There is not enough storage to finish the operation. Free some space and retry." + case .permissionDenied: "The native storage operation was denied access. Check access and retry." + case .sourceUnavailable: "The required local file is unavailable. Retry the operation." + case .network: "The native operation could not connect. Check your connection and retry." + case .secureStorageUnavailable: "Secure storage is unavailable. Unlock your device and retry." + } + } + static func classify(_ error: Error) -> Self { + if let reason = error as? Self { return reason } + switch error { + case DecodingError.typeMismatch: return .typeMismatch + case DecodingError.keyNotFound: return .keyNotFound + case DecodingError.valueNotFound: return .valueNotFound + case DecodingError.dataCorrupted: return .dataCorrupted + case is URLError: return .network + case let error as CocoaError: + switch error.code { + case .fileWriteOutOfSpace: return .noSpace + case .fileReadNoPermission, .fileWriteNoPermission: return .permissionDenied + case .fileReadNoSuchFile, .fileNoSuchFile: return .sourceUnavailable + default: return .unknown + } + default: + return (error as NSError).domain == NSOSStatusErrorDomain ? .secureStorageUnavailable : .unknown + } + } + static func backendBatch(_ source: String) -> Self { + switch source { + case "unsupported property", "unsupported property value", "unknown extension property", "unsupported extension property value": .backendUnsupportedProperty + case "unknown node", "unknown extension node", "unknown parent or child": .backendUnknownNode + case "unknown extension identifier": .backendUnknownExtension + case "extension fingerprint mismatch": .backendFingerprintMismatch + case "child is already attached", "child index is out of bounds", "child insertion would create a cycle", "child is not attached to parent", "cannot drop an attached node": .backendInvalidHierarchy + case "unknown extension event", "invalid extension event payload", "unknown extension event field", "invalid extension event field", "missing required extension event field": .backendInvalidEvent + case "extension registry is frozen", "invalid extension identifier", "extension identifier shadows a standard node", "extension identifier is already registered", "invalid extension child identifier", "invalid extension property name", "invalid extension property default", "invalid extension event name", "unknown extension child schema", "duplicate extension property", "duplicate extension event", "duplicate extension event field", "duplicate extension child identifier": .backendRegistryInvalid + default: .backendInvalidBatch + } + } + static func decode(_ type: T.Type, from data: Data) throws -> T { + do { return try JSONDecoder().decode(type, from:data) } catch { throw classify(error) } + } +} diff --git a/test/application_view_test.ml b/test/application_view_test.ml index fa27d5fa..f8f29f7b 100644 --- a/test/application_view_test.ml +++ b/test/application_view_test.ml @@ -1572,10 +1572,36 @@ let run_favorites_native_visibility dispatch (Lui_protocol.Press (Option.get (find "text" "Diagnostics"))); wait "actual mounted decoder reports to Diagnostics" (fun () -> Option.is_some (find "text" operation)); + let expected_cause = if mode = `Extension then "Native event shape is invalid" + else "Native event JSON syntax is invalid" in + let expected_message = operation ^ ": Input could not be parsed. Cause: " ^ expected_cause ^ "." in + Alcotest.(check bool) "decoder preserves safe specific cause" true + (Option.is_some (find "text" expected_message)); Alcotest.(check bool) "raw event never becomes diagnostic text" true (Option.is_none (find "text" "private-malformed-event")); dispatch (Lui_protocol.Press (Option.get - (find "accessibility-identifier" "journal-diagnostics-close")))) malformed_native_event; + (find "accessibility-identifier" "journal-diagnostics-close"))); + if mode = `List then ( + let list = Option.get (find "_extension" "journal-list") in + let duplicate = {|{"type":"visible_range","first":0,"first":1,"last":2}|} in + consume (hooks.extension_event list "event" (Yojson.Safe.to_string + (`Assoc ["id", `Int 1; "payload", `String duplicate]))); + dispatch (Lui_protocol.Press (Option.get (find "text" "Diagnostics"))); + wait "same operation retains a later distinct decoding cause" (fun () -> + Option.is_some (find "text" + "decodeListEvent: Input could not be parsed. Cause: duplicate JSON field.")); + dispatch (Lui_protocol.Press (Option.get + (find "accessibility-identifier" "journal-diagnostics-close"))); + let list = Option.get (find "_extension" "journal-list") in + consume (hooks.extension_event list "event" (Yojson.Safe.to_string + (`Assoc ["id", `Int 1; "payload", `String "private-malformed-event"]))); + dispatch (Lui_protocol.Press (Option.get (find "text" "Diagnostics"))); + let syntax_occurrences () = Hashtbl.fold (fun _ values count -> + if List.assoc_opt "text" values = Some (`String expected_message) + then count+1 else count) props 0 in + wait "returning to an earlier cause becomes a new latest occurrence" (fun () -> syntax_occurrences ()=2); + dispatch (Lui_protocol.Press (Option.get + (find "accessibility-identifier" "journal-diagnostics-close"))))) malformed_native_event; (* Delete admission is private to Application, absent from Root_navigation's public pure events. Use its existing public bridge and controlled Worker fixture to execute the actual UI admission/completion boundary. *) @@ -5520,7 +5546,7 @@ let test_repeated_native_sign_in_intent_receives_permission () = (* Local-account completion and request continuations belong to Application, not Root_navigation. Drive a genuine failed platform request and its Retry. *) -let test_local_account_failure_retries_local_discovery () = +let test_local_account_failure_retries_local_discovery ?(typed_reason=false) ?(retired_scope=false) () = let requests = ref [] in let hooks, _, release, client = unlock_fixture ~cold_start:true ~on_platform_request:(fun bytes -> requests := !requests @ [Bytes.copy bytes]) () in @@ -5554,11 +5580,28 @@ let test_local_account_failure_retries_local_discovery () = Option.iter Logseq_db_worker_lui.Journal_worker_runtime.stop !client) (fun () -> consume (hooks.init 2 2 config); wait "local discovery requested" (fun () -> local_requests () <> []); - hooks.platform_failure (Bytes.to_string (List.hd (local_requests ()))); + let typed_failure account = + let bytes=Bytes.make 24 '\000' in + Bytes.blit_string "LJF1" 0 bytes 0 4; + Bytes.set_uint16_le bytes 4 20; Bytes.set_uint16_le bytes 6 9; + Bytes.set_int64_le bytes 8 account; + Bytes.to_string bytes in + if retired_scope then ( + hooks.platform_failure (typed_failure 999L); + for _=1 to 20 do consume(hooks.pump()); Unix.sleepf 0.001 done; + Alcotest.(check bool) "retired native scope cannot fail current discovery" true + (Option.is_none(find "journal-startup-retry"))); + hooks.platform_failure (if typed_reason then typed_failure 0L + else Bytes.to_string (List.hd (local_requests ()))); wait "transient local failure retains a local Retry" (fun () -> Option.is_some (find "journal-startup-retry")); - let before=List.length (local_requests ()) in - consume (hooks.dispatch (Lui_protocol.Press (Option.get (find "journal-startup-retry")))); - wait "Retry reissues local discovery" (fun () -> List.length (local_requests ()) = before+1); + if typed_reason then ( + consume(hooks.dispatch(Lui_protocol.Press(Option.get(find "journal-startup-diagnostics")))); + wait "typed platform reason reaches mounted diagnostics" (fun () -> Hashtbl.fold (fun _ props found -> found || + List.assoc_opt "text" props=Some(`String "native.platform.network · The native account request could not connect. Check your connection and retry.")) props false)) + else ( + let before=List.length (local_requests ()) in + consume (hooks.dispatch (Lui_protocol.Press (Option.get (find "journal-startup-retry")))); + wait "Retry reissues local discovery" (fun () -> List.length (local_requests ()) = before+1)); Alcotest.(check bool) "local retry requests no online permission" false (List.exists (fun bytes -> Bytes.get_uint16_le bytes 6 = 30) !requests)) ;; @@ -5647,7 +5690,8 @@ let capture_unlock_logs run = ;; let run_unlock_application ?(worker_failure = false) ?(queue_full = false) - ?(stopping = false) ?(check_diagnostics = false) ?(native_report = false) ?malformed_platform () = + ?(stopping = false) ?(check_diagnostics = false) ?(native_report = false) + ?(native_report_unknown = false) ?malformed_platform () = let hooks, submissions, release, client = unlock_fixture ~worker_failure ~queue_full () in let props = Hashtbl.create 64 in let track_teardown = track_json_wire_teardown (Hashtbl.remove props) in @@ -5752,7 +5796,9 @@ let run_unlock_application ?(worker_failure = false) ?(queue_full = false) let extension = Hashtbl.fold (fun id values found -> if List.mem_assoc "payload" values then Some id else found) props None |> Option.get in consume (hooks.extension_event extension "event" - {|{"id":-1,"payload":"diagnostic.nativePropsDecode"}|})); + (if native_report_unknown then + {|{"id":-1,"payload":"diagnostic.nativePropsDecode.SECRET /private/token"}|} + else {|{"id":-1,"payload":"diagnostic.nativePropsDecode.keyNotFound"}|}))); Option.iter (fun kind -> (if kind="response" then hooks.platform_response else hooks.platform_failure) "short"; consume(hooks.pump())) malformed_platform; @@ -5763,6 +5809,13 @@ let run_unlock_application ?(worker_failure = false) ?(queue_full = false) | Some "response" -> "decodePlatformResponse" | Some _ -> "decodePlatformFailure" | None -> if native_report then "decodeNativeProps" else "submitE2eePassword"))); + if native_report then ( + let message = if native_report_unknown then + "native.decodeNativeProps.unknown · The native operation could not finish. Retry the operation." + else "native.decodeNativeProps.keyNotFound · A required native field is missing." in + wait "native finite reason reaches the mounted ledger" (fun () -> Option.is_some(find "text" message)); + Alcotest.(check bool) "unrecognized native suffix is not displayed" true + (Option.is_none(find "text" "diagnostic.nativePropsDecode.SECRET /private/token"))); Alcotest.(check bool) "diagnostic excludes submitted password" true (Option.is_none (find "text" " synthetic-only ")))) else ( @@ -6583,6 +6636,8 @@ let () = (fun () -> run_unlock_application ~queue_full:true ~check_diagnostics:true ()) ; Alcotest.test_case "native extension failure reaches Diagnostics" `Quick (fun () -> run_unlock_application ~worker_failure:true ~native_report:true ()) + ; Alcotest.test_case "unknown native suffix uses a safe reason" `Quick + (fun () -> run_unlock_application ~worker_failure:true ~native_report:true ~native_report_unknown:true ()) ; Alcotest.test_case "short platform response reaches Diagnostics" `Quick (fun () -> run_unlock_application ~worker_failure:true ~malformed_platform:"response" ()) ; Alcotest.test_case "short platform failure reaches Diagnostics" `Quick @@ -6606,7 +6661,10 @@ let () = ; "pagination" ] ) ; ("Favorites compact title",[Alcotest.test_case "Favorite Page uses shared reference lookup" `Quick test_favorite_page_uses_shared_reference_lookup; Alcotest.test_case "page property values keep name without duplication" `Quick test_detail_page_property_value_presentation; Alcotest.test_case "root Capture only in Journals" `Quick test_root_capture_placement; Alcotest.test_case "LUI titles icons and no arrow" `Quick test_favorites_compact_shared_titles; Alcotest.test_case "page outline click and native Back" `Quick (fun () -> run_favorites_native_visibility ~check_favorite_targets:true ())]) - ; ( "local account retry", [Alcotest.test_case "transient failure retries local discovery" `Quick test_local_account_failure_retries_local_discovery]) + ; ( "local account retry", [ + Alcotest.test_case "transient failure retries local discovery" `Quick (fun () -> test_local_account_failure_retries_local_discovery ()); + Alcotest.test_case "typed native reason completes local discovery" `Quick (fun () -> test_local_account_failure_retries_local_discovery ~typed_reason:true ()); + Alcotest.test_case "retired native scope cannot fail local discovery" `Quick (fun () -> test_local_account_failure_retries_local_discovery ~retired_scope:true ())]) ; ( "graph picker", [ Alcotest.test_case "offline deletion is disabled with explanation" `Quick (test_graph_picker_controls `Offline); Alcotest.test_case "refresh failure replaces old presence failure" `Quick (test_graph_picker_controls `Refresh_after_presence_failure); diff --git a/test/wrapped_graph_key_crypto_test.swift b/test/wrapped_graph_key_crypto_test.swift index 92fe8f83..c448b539 100644 --- a/test/wrapped_graph_key_crypto_test.swift +++ b/test/wrapped_graph_key_crypto_test.swift @@ -65,13 +65,16 @@ private func unlockLogTest() throws { try? FileManager.default.removeItem(at: capture) unsetenv("JOURNAL_UNLOCK_DIAGNOSTICS") } - func invoke(_ object: [String: Any]) throws { + @discardableResult + func invoke(_ object: [String: Any]) throws -> JournalCryptoResponse { let data = try JSONSerialization.data(withJSONObject: object) let response = String(decoding: data, as: UTF8.self).withCString { logseqJournalCryptoJSON($0) } guard let response else { throw HarnessError.failed("missing crypto response") } - free(response) + defer { free(response) } + return try JSONDecoder().decode( + JournalCryptoResponse.self, from: Data(String(cString: response).utf8)) } let secrets = ["LOG_SECRET_PASSWORD", "LOG_SECRET_USER", "LOG_SECRET_KEY", "LOG_SECRET_GRAPH", "LOG_SECRET_BODY", "LOG_SECRET_OPERATION"] @@ -87,14 +90,28 @@ private func unlockLogTest() throws { let disabledLogs = try Data(contentsOf: capture) try require(disabledLogs.isEmpty, "diagnostics must be opt-in") setenv("JOURNAL_UNLOCK_DIAGNOSTICS", "1", 1) - try invoke(request) + let authenticationFailure = try invoke(request) var logs = try String(contentsOf: capture, encoding: .utf8) try require(logs.contains("source=crypto op=109 request=unlockPrivateKey phase=begin"), "native request must preserve the numeric runner operation ID") try require(logs.contains("stage=derive-private-key") && logs.contains("stage=open-private-key"), "native crypto must expose the actual failing stage") - try require(logs.contains("phase=end result=crypto-failed") && logs.contains("elapsed_ms="), - "native crypto must record a typed terminal result and duration") + try require(!authenticationFailure.ok + && authenticationFailure.error == "cryptoAuthenticationFailed", + "native crypto must return the precise authentication failure") + let terminalRecords = logs.split(separator: "\n").map { $0.split(separator: " ") } + .filter { $0.contains("source=crypto") && $0.contains("op=109") + && $0.contains("request=unlockPrivateKey") && $0.contains("phase=end") } + try require(terminalRecords.count == 1, + "native crypto must record exactly one terminal result for the request") + let terminal = terminalRecords[0] + try require(terminal.contains("result=cryptoAuthenticationFailed") + && terminal.contains("stage=open-private-key"), + "native crypto terminal result must preserve the response cause and failing stage") + let elapsed = terminal.first { $0.hasPrefix("elapsed_ms=") }?.dropFirst("elapsed_ms=".count) + try require(elapsed.map { !$0.isEmpty && $0.utf8.allSatisfy { $0 >= 48 && $0 <= 57 } + && (Int($0).map { $0 >= 0 } ?? false) } ?? false, + "native crypto terminal duration must be a nonnegative integer") try invoke(["operation": "loadAndVerifyWrappedGraphKey", "origin": "https://example.invalid", "userId": secrets[1], "graphId": "10000000-0000-4000-8000-000000000999", "diagnosticOperation": "110"]) diff --git a/tool/test_journal_runtime_failure.py b/tool/test_journal_runtime_failure.py index 73078673..40f6b685 100644 --- a/tool/test_journal_runtime_failure.py +++ b/tool/test_journal_runtime_failure.py @@ -14,13 +14,14 @@ backend.write_text(''' import Foundation public struct LUIEvent: Sendable {} +public enum LUIBackendError: Error { case invalidBatch(String) } public struct LUIAppleExtensionRegistry { public init() {} } @MainActor public final class LUIAppleBackend { public var rootIDs: [Int] = [] public var onEvent: ((LUIEvent) -> Void)? public init(appIcons: [String: String], extensionRegistry: LUIAppleExtensionRegistry) throws {} public func apply(json: String) throws { - if json == "invalid" { throw NSError(domain: "SENSITIVE renderer content", code: 1) } + if json == "invalid" { throw LUIBackendError.invalidBatch("unsupported property") } rootIDs = [42] } } @@ -29,6 +30,7 @@ bridge.write_text(''' #include static int mode; +int32_t journal_ocaml_failure_reason(int32_t operation) { return 2; } static int stops; static int platform_calls; int32_t fixture_platform_count(void) { return platform_calls; } @@ -54,7 +56,7 @@ def run(args): executable = work / "runtime-tests" run(["xcrun", "swiftc", "-swift-version", "6", "-Xfrontend", "-disable-sandbox", "-I", str(work), "-L", str(work), "-lLUIAppleBackend", "-Xlinker", "-rpath", "-Xlinker", str(work), - "-module-cache-path", str(work / "modules"), str(ROOT / "swift/JournalRuntime.swift"), + "-module-cache-path", str(work / "modules"), str(ROOT / "swift/JournalSafeDiagnostic.swift"), str(ROOT / "swift/JournalPlatformWire.swift"), str(ROOT / "swift/JournalRuntime.swift"), str(ROOT / "apple-tests/platform-services/JournalRuntimeFailureTests.swift"), str(work / "bridge.o"), "-o", str(executable)]) failures = [] diff --git a/tool/test_journal_runtime_startup.py b/tool/test_journal_runtime_startup.py index e4054024..9b7a9124 100644 --- a/tool/test_journal_runtime_startup.py +++ b/tool/test_journal_runtime_startup.py @@ -22,9 +22,9 @@ def run(c):subprocess.run(c,cwd=W,check=True) flags=['-swift-version','6','-module-cache-path',str(W/'modules')] run(['xcrun','swiftc',*flags,'-emit-module','-emit-library','-module-name','LUIAppleBackend',str(W/'Backend.swift'),'-o',str(W/'libLUIAppleBackend.dylib')]) run(['xcrun','clang','-c',str(W/'bridge.c'),'-o',str(W/'bridge.o')]) -run(['xcrun','swiftc',*flags,'-Xfrontend','-disable-sandbox','-I',str(W),'-L',str(W),'-lLUIAppleBackend','-Xlinker','-rpath','-Xlinker',str(W),str(source.resolve()),str(W/'Tests.swift'),str(W/'bridge.o'),'-o',str(W/'tests')]) +run(['xcrun','swiftc',*flags,'-Xfrontend','-disable-sandbox','-I',str(W),'-L',str(W),'-lLUIAppleBackend','-Xlinker','-rpath','-Xlinker',str(W),str(ROOT/'swift/JournalSafeDiagnostic.swift'),str(ROOT/'swift/JournalPlatformWire.swift'),str(source.resolve()),str(W/'Tests.swift'),str(W/'bridge.o'),'-o',str(W/'tests')]) rows=[] -for scenario in ['normal','worker','multiple','repeated-start','rejected','patch-failure','stopped','cancelled','stale-cancel','nil-response']: +for scenario in ['normal','worker','multiple','repeated-start','rejected','patch-failure','stopped','cancelled','stale-cancel','nil-response','same-tag']: r=subprocess.run([str(W/'tests'),scenario],capture_output=True,text=True,timeout=20) rows.append(r.returncode);print(scenario,r.returncode,r.stdout.strip(),r.stderr.splitlines()[:1],flush=True) diff --git a/tool/test_photo_save.py b/tool/test_photo_save.py index eab3bdff..f481f441 100644 --- a/tool/test_photo_save.py +++ b/tool/test_photo_save.py @@ -3,6 +3,7 @@ import pathlib import subprocess import tempfile +import sys root = pathlib.Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory(prefix="journal-photo-save-test-") as directory: @@ -10,9 +11,11 @@ subprocess.run([ "xcrun", "swiftc", "-swift-version", "6", "-parse-as-library", "-module-cache-path", str(output / "module-cache"), + str(root / "swift/JournalAssetDiagnostic.swift"), + str(root / "swift/JournalAssetStaging.swift"), str(root / "swift/JournalPhotoSave.swift"), str(root / "swift/JournalPhotoSaveFile.swift"), str(root / "apple-tests/photo-save/JournalPhotoSaveTests.swift"), "-o", str(output / "tests"), ], check=True) - subprocess.run([str(output / "tests")], check=True) + subprocess.run([str(output / "tests"), *sys.argv[1:]], check=True) diff --git a/tool/test_swiftui_authentication.py b/tool/test_swiftui_authentication.py index e3a28362..6e139773 100644 --- a/tool/test_swiftui_authentication.py +++ b/tool/test_swiftui_authentication.py @@ -8,6 +8,9 @@ executable = Path(directory) / 'auth-tests' subprocess.run(['xcrun', 'swiftc', '-swift-version', '6', '-Xfrontend', '-disable-sandbox', '-module-cache-path', str(Path(directory)/'modules'), + str(root / 'swift/JournalPlatformWire.swift'), + str(root / 'swift/JournalPlatformServices.swift'), + str(root / 'swift/JournalCognitoOAuth.swift'), str(root / 'swift/JournalAuthentication.swift'), str(root / 'apple-tests/authentication/JournalAuthenticationTests.swift'), '-o', str(executable)], check=True) diff --git a/tool/test_swiftui_cognito_native.py b/tool/test_swiftui_cognito_native.py new file mode 100644 index 00000000..9843e7ea --- /dev/null +++ b/tool/test_swiftui_cognito_native.py @@ -0,0 +1,39 @@ +"""Exercise native adapters with only OS entry points replaced; never runs GUI or Keychain.""" +from pathlib import Path +import subprocess +import tempfile +root = Path(__file__).resolve().parents[1] +with tempfile.TemporaryDirectory(prefix='journal-cognito-native-') as directory: + destination = Path(directory) + native = (root/'swift/JournalCognitoNative.swift').read_text() + substitutions = { + 'SecItemCopyMatching(': 'FixtureSecurity.copy(', + 'SecItemUpdate(': 'FixtureSecurity.update(', + 'SecItemAdd(': 'FixtureSecurity.add(', + 'SecItemDelete(': 'FixtureSecurity.delete(', + 'ASWebAuthenticationPresentationContextProviding': 'FixturePresentationContext', + 'ASWebAuthenticationSession(': 'FixtureWebAuthenticationSession(', + 'ASWebAuthenticationSession?': 'FixtureWebAuthenticationSession?', + 'session: ASWebAuthenticationSession)': 'session: FixtureWebAuthenticationSession)', + 'ASPresentationAnchor': 'FixtureAnchor', + 'NSApplication.shared.keyWindow ?? NSApplication.shared.mainWindow': 'FixtureBrowserState.anchor', + } + for before, after in substitutions.items(): + assert before in native, f'native fixture boundary changed: {before}' + native = native.replace(before, after) + # Swift file-private access stays intact; append the narrow native owner + # fixture in the same compilation file rather than changing visibility. + fixture = destination/'NativeTests.swift' + binding = (root/'swift/JournalLocalAccountBindingStore.swift').read_text() + for before, after in list(substitutions.items())[:4]: + assert before in binding, f'binding fixture boundary changed: {before}' + binding = binding.replace(before, after) + fixture.write_text(native + '\n' + binding + '\n' + (root/'apple-tests/authentication/JournalCognitoNativeReasonTests.swift').read_text()) + sources = ['JournalPlatformWire.swift','JournalPlatformServices.swift','JournalAuthentication.swift', + 'JournalCognitoOAuth.swift','JournalCognitoSession.swift'] + command = ['xcrun','swiftc','-swift-version','6','-Xfrontend','-disable-sandbox', + '-module-cache-path',str(destination/'modules'),*[str(root/'swift'/s) for s in sources]] + subprocess.run([*command,str(fixture),'-o',str(destination/'tests')],check=True) + subprocess.run([str(destination/'tests')],check=True,timeout=30) + # The actual native framework types must still compile, without launching. + subprocess.run([*command,str(root/'swift/JournalCognitoNative.swift'),'-typecheck'],check=True) diff --git a/tool/test_swiftui_platform.py b/tool/test_swiftui_platform.py index 052aaebb..307c13e1 100644 --- a/tool/test_swiftui_platform.py +++ b/tool/test_swiftui_platform.py @@ -58,7 +58,7 @@ def run(directory, command): *[source.stem + '.cmx' for source in sources if source.suffix == '.ml']]) run(destination, [str(destination / 'wire_ocaml'), 'emit', 'requests.json']) run(destination, ['swiftc', '-swift-version', '6', '-module-cache-path', str(destination / 'module-cache'), '-o', 'wire_swift', - str(ROOT / 'swift/JournalPlatformWire.swift'), + str(ROOT / 'swift/JournalPlatformWire.swift'), str(ROOT / 'swift/JournalSafeDiagnostic.swift'), str(ROOT / 'swift/JournalPlatformServices.swift'), str(ROOT / 'swift/JournalStartupConfiguration.swift'), str(ROOT / 'swift/JournalLocalAccountBindingStore.swift'), diff --git a/tool/test_swiftui_services.py b/tool/test_swiftui_services.py index 816fbb35..a7715e71 100644 --- a/tool/test_swiftui_services.py +++ b/tool/test_swiftui_services.py @@ -5,9 +5,14 @@ root = Path(__file__).resolve().parents[1] with tempfile.TemporaryDirectory(prefix='journal-platform-services-') as directory: + diagnostics = Path(directory)/'diagnostic-tests' + subprocess.run(['xcrun','swiftc','-swift-version','6','-module-cache-path',str(Path(directory)/'modules'), + str(root/'swift/JournalSafeDiagnostic.swift'), + str(root/'apple-tests/platform-services/JournalSafeDiagnosticTests.swift'),'-o',str(diagnostics)],check=True) + subprocess.run([str(diagnostics)],check=True,timeout=15) executable = Path(directory)/'services-tests' subprocess.run(['xcrun', 'swiftc', '-swift-version', '6', '-module-cache-path', str(Path(directory)/'modules'), - str(root/'swift/JournalPlatformWire.swift'), + str(root/'swift/JournalPlatformWire.swift'), str(root/'swift/JournalSafeDiagnostic.swift'), str(root/'swift/JournalPlatformServices.swift'), str(root/'apple-tests/platform-services/JournalPlatformServicesTests.swift'), '-o', str(executable)], check=True) @@ -15,7 +20,7 @@ bridge = Path(directory)/'application-platform-tests' subprocess.run(['xcrun', 'swiftc', '-swift-version', '6', '-disable-sandbox', '-module-cache-path', str(Path(directory)/'modules'), - str(root/'swift/JournalPlatformWire.swift'), + str(root/'swift/JournalPlatformWire.swift'), str(root/'swift/JournalSafeDiagnostic.swift'), str(root/'swift/JournalPlatformEvents.swift'), str(root/'swift/JournalPlatformServices.swift'), str(root/'swift/JournalApplicationPlatform.swift'), @@ -25,7 +30,7 @@ recovery = Path(directory)/'recovery-tests' subprocess.run(['xcrun', 'swiftc', '-swift-version', '6', '-Xfrontend', '-disable-sandbox', '-module-cache-path', str(Path(directory)/'modules'), - str(root/'swift/JournalPlatformWire.swift'), + str(root/'swift/JournalPlatformWire.swift'), str(root/'swift/JournalSafeDiagnostic.swift'), str(root/'swift/JournalPlatformServices.swift'), str(root/'swift/JournalLocalAccountBindingStore.swift'), str(root/'apple-tests/platform-services/JournalAccountRecoveryTests.swift'), @@ -33,7 +38,7 @@ subprocess.run([str(recovery)], check=True, timeout=60) threading = Path(directory)/'account-threading-tests' subprocess.run(['xcrun', 'swiftc', '-swift-version', '6', '-module-cache-path', str(Path(directory)/'modules'), - str(root/'swift/JournalPlatformWire.swift'), + str(root/'swift/JournalPlatformWire.swift'), str(root/'swift/JournalSafeDiagnostic.swift'), str(root/'swift/JournalPlatformServices.swift'), str(root/'swift/JournalStartupConfiguration.swift'), str(root/'swift/JournalNativeServices.swift'), @@ -42,7 +47,7 @@ subprocess.run([str(threading)], check=True, timeout=60) cold = Path(directory)/'cold-tests' subprocess.run(['xcrun','swiftc','-swift-version','6','-module-cache-path',str(Path(directory)/'modules'), - str(root/'swift/JournalPlatformWire.swift'), + str(root/'swift/JournalPlatformWire.swift'), str(root/'swift/JournalSafeDiagnostic.swift'), str(root/'swift/JournalPlatformServices.swift'), str(root/'swift/JournalStartupConfiguration.swift'), str(root/'swift/JournalNativeServices.swift'),