Harden Scintilla native boundary and loader
ober
450287f52fa78a06232179804e4f86361aaedf37
--- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: build-test-audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Install system tools run: | --- a/.github/workflows/security-baseline.yml +++ b/.github/workflows/security-baseline.yml @@ -13,7 +13,7 @@ jobs: baseline: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - name: Required release files run: | --- a/Makefile +++ b/Makefile @@ -2,8 +2,9 @@ JERBOA_VERSION ?= v0.2.3 JERBOA_TOOL_DIR ?= $(CURDIR)/.jerboa/bin JERBUILD ?= $(shell if [ -x "$(CURDIR)/../jerboa/dist/jerbuild" ]; then printf '%s\n' "$(CURDIR)/../jerboa/dist/jerbuild"; elif command -v jerbuild >/dev/null 2>&1; then command -v jerbuild; else printf '%s\n' "$(JERBOA_TOOL_DIR)/jerbuild"; fi) JH = $(shell "$(JERBUILD)" --jerboa-home 2>/dev/null) +JERBOA_CORE ?= $(CURDIR)/../jerboa -LIBDIRS = lib:$(JH)/lib +LIBDIRS = lib:$(JH)/lib:$(JERBOA_CORE)/lib DIST_DIR ?= $(CURDIR)/dist TARGET_EVIDENCE_DIR ?= $(DIST_DIR)/target-evidence RELEASE_EVIDENCE_DIR ?= $(DIST_DIR)/release-evidence @@ -23,6 +24,10 @@ CC ?= cc UNAME_S := $(shell uname -s) WARN_CFLAGS ?= -Wall -Wextra -Wformat=2 -Wshadow -Wpointer-arith -Wcast-align -Wwrite-strings HARDEN_CFLAGS ?= -fPIC -O2 -fstack-protector-strong -D_FORTIFY_SOURCE=2 +SANITIZER_CFLAGS ?= -O1 -g -fno-omit-frame-pointer -fsanitize=address,undefined +THREAD_SANITIZER_CFLAGS ?= -O1 -g -fno-omit-frame-pointer -fsanitize=thread +SANITIZER_CC ?= $(CC) +SANITIZER_TOOLCHAIN_CFLAGS ?= TERMBOX_COMPAT_CFLAGS ?= -Dutf8_char_to_unicode=tb_utf8_char_to_unicode ifeq ($(UNAME_S),Darwin) SHARED_FLAG = -dynamiclib @@ -31,6 +36,16 @@ ifeq ($(UNAME_S),Darwin) WHOLE_ARCHIVE_OPEN = -Wl,-force_load, WHOLE_ARCHIVE_CLOSE = HARDEN_LDFLAGS ?= + ASAN_OPTIONS_VALUE ?= detect_leaks=0:halt_on_error=1 + # Apple clang 17's ASan runtime deadlocks during initialization on current + # Darwin 25 hosts. Prefer a Homebrew LLVM runtime when it is installed. + ifneq ($(wildcard /opt/homebrew/opt/llvm/bin/clang),) + SANITIZER_CC := /opt/homebrew/opt/llvm/bin/clang + SANITIZER_TOOLCHAIN_CFLAGS := --no-default-config --sysroot=$(shell xcrun --show-sdk-path) + else ifneq ($(wildcard /usr/local/opt/llvm/bin/clang),) + SANITIZER_CC := /usr/local/opt/llvm/bin/clang + SANITIZER_TOOLCHAIN_CFLAGS := --no-default-config --sysroot=$(shell xcrun --show-sdk-path) + endif else SHARED_FLAG = -shared SO_EXT = .so @@ -38,9 +53,12 @@ else WHOLE_ARCHIVE_OPEN = -Wl,--whole-archive WHOLE_ARCHIVE_CLOSE = -Wl,--no-whole-archive HARDEN_LDFLAGS ?= -Wl,-z,relro,-z,now + ASAN_OPTIONS_VALUE ?= detect_leaks=1:halt_on_error=1 endif SHIM := jerboa_scintilla_shim$(SO_EXT) +NATIVE_SECURITY_TEST := $(DIST_DIR)/tests/native-shim-security-test +NATIVE_THREAD_TEST := $(DIST_DIR)/tests/native-shim-thread-test CC_INCLUDES = \ -I$(SCI_DIR)/include \ @@ -49,7 +67,7 @@ CC_INCLUDES = \ -I$(TERMBOX_DIR)/src \ -I$(LEXILLA_DIR)/include -.PHONY: all build native transpile security test test-native native-boundary-corpus clean shim vendor-deps vendor-check \ +.PHONY: all build native transpile security test test-native loader-policy-tests native-boundary-corpus native-security-tests native-thread-tests clean shim vendor-deps vendor-check \ ensure-jerboa-tools audit vendor-provenance-check vendor-provenance-enforce \ sbom reproducibility-report target-evidence verify release-evidence @@ -95,20 +113,49 @@ transpile: ensure-jerboa-tools $(JERBUILD) transpile src lib --force test: transpile + JERBOA_SCINTILLA_DEV_NATIVE=1 \ JERBOA_SCINTILLA_LIB=$(CURDIR) \ $(LD_VAR)=$(CURDIR) \ $(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/run-tests.ss test-native: transpile shim + JERBOA_SCINTILLA_DEV_NATIVE=1 \ JERBOA_SCINTILLA_LIB=$(CURDIR) \ $(LD_VAR)=$(CURDIR) \ $(JERBUILD) exec --libdirs "$(LIBDIRS)" tests/run-tests.ss +loader-policy-tests: transpile shim tests/loader-policy.ss + @REPO_ROOT="$(CURDIR)" JERBUILD="$(JERBUILD)" \ + LIBDIRS="$(CURDIR)/lib:$(JH)/lib:$(JERBOA_CORE)/lib" SHIM="$(CURDIR)/$(SHIM)" \ + $(LD_VAR)=$(CURDIR) \ + sh scripts/loader-policy-test.sh + native-boundary-corpus: transpile shim support/native-boundary-corpus.ss + JERBOA_SCINTILLA_DEV_NATIVE=1 \ JERBOA_SCINTILLA_LIB=$(CURDIR) \ $(LD_VAR)=$(CURDIR) \ $(JERBUILD) exec --libdirs "$(LIBDIRS)" support/native-boundary-corpus.ss +native-security-tests: jerboa_scintilla_shim.c tests/fake_scintilla_backend.h tests/native_shim_security_test.c + @mkdir -p "$(dir $(NATIVE_SECURITY_TEST))" + $(SANITIZER_CC) $(SANITIZER_TOOLCHAIN_CFLAGS) -std=c11 $(WARN_CFLAGS) -Werror \ + $(SANITIZER_CFLAGS) $(TERMBOX_COMPAT_CFLAGS) \ + -DJERBOA_SCINTILLA_FAKE_BACKEND=1 -DJERBOA_SCINTILLA_TESTING=1 \ + -Itests -o "$(NATIVE_SECURITY_TEST)" \ + jerboa_scintilla_shim.c tests/native_shim_security_test.c -lpthread + ASAN_OPTIONS=$(ASAN_OPTIONS_VALUE) \ + UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 \ + "$(NATIVE_SECURITY_TEST)" + +native-thread-tests: jerboa_scintilla_shim.c tests/fake_scintilla_backend.h tests/native_shim_security_test.c + @mkdir -p "$(dir $(NATIVE_THREAD_TEST))" + $(SANITIZER_CC) $(SANITIZER_TOOLCHAIN_CFLAGS) -std=c11 $(WARN_CFLAGS) -Werror \ + $(THREAD_SANITIZER_CFLAGS) $(TERMBOX_COMPAT_CFLAGS) \ + -DJERBOA_SCINTILLA_FAKE_BACKEND=1 -DJERBOA_SCINTILLA_TESTING=1 \ + -Itests -o "$(NATIVE_THREAD_TEST)" \ + jerboa_scintilla_shim.c tests/native_shim_security_test.c -lpthread + TSAN_OPTIONS=halt_on_error=1 "$(NATIVE_THREAD_TEST)" + audit: transpile @echo "==> Scintilla native dependency roots" @echo "SCINTILLA_VENDOR=$(SCINTILLA_VENDOR)" @@ -180,7 +227,7 @@ reproducibility-report: target-evidence: @TARGET_EVIDENCE_DIR="$(TARGET_EVIDENCE_DIR)" sh scripts/target-evidence.sh -verify: security test audit sbom reproducibility-report target-evidence +verify: security test-native loader-policy-tests native-boundary-corpus native-security-tests native-thread-tests audit sbom reproducibility-report target-evidence release-evidence: security @rm -rf "$(RELEASE_EVIDENCE_DIR)" --- a/README.md +++ b/README.md @@ -6,9 +6,14 @@ tools. The native boundary is intentionally narrow: -- The shared object is loaded lazily, not during module import. +- Statically registered native symbols are preferred. Development-only dynamic + loading is lazy and requires an explicit trusted absolute directory. - Text arguments cross into C as UTF-8 bytevectors with explicit lengths. +- Text results cross into caller-owned bytevectors with a checked capacity and + an explicit 64 MiB API limit; there is no fixed 64 KiB receive buffer. - Embedded NUL bytes are rejected before native calls. +- Opaque numeric editor IDs are never reused. Native operations, notification + delivery, and destruction use synchronized lifecycle references. - Vendored Scintilla, Lexilla, and termbox_next archives are required only for native shim builds. - Pure API tests can run without a terminal or native vendor checkout. @@ -29,12 +34,26 @@ shim: make vendor-deps make vendor-provenance-enforce make test-native +make native-boundary-corpus +make native-security-tests +make native-thread-tests make audit ``` By default, the Makefile uses the ignored `./scintilla-vendor` directory. Set `SCINTILLA_VENDOR=/path/to/scintilla-vendor` to use another checkout. +The Makefile supplies the development loader controls for its native tests. +Other dynamic development hosts must set both +`JERBOA_SCINTILLA_DEV_NATIVE=1` and `JERBOA_SCINTILLA_LIB` to a trusted, +canonical absolute directory containing the shim. Production hosts should +statically register the shim symbols instead. Current-directory, relative, +bare-name, symlinked, unexpected-owner, and world-writable native paths are +rejected by the shared `(std native-loader)` policy, which validates every +ancestor. Group-write is allowed only on same-user components in explicit +unprivileged development mode. Privileged processes ignore the development +environment entirely. + ## Security Review [SECURITY.md](SECURITY.md), [docs/ffi-boundary.md](docs/ffi-boundary.md), @@ -45,5 +64,7 @@ provenance status, SBOM manifests, native linkage, sanitized target evidence, and hash-only reproducibility output under `dist/release-evidence/`. Target proof files are rejected before copying when they contain overlarge, host-private, or high-confidence secret material. Public production release -still requires sanitizer builds, terminal smoke tests, hostile-input coverage, -and marker-complete target-platform evidence. +still requires sanitizer coverage of the complete vendored C++ graph, terminal +smoke tests, and marker-complete target-platform evidence. The checked-in shim +harness already runs ASan/UBSan boundary cases and ThreadSanitizer over the +deterministic lifecycle/concurrency suite. --- a/SECURITY.md +++ b/SECURITY.md @@ -6,12 +6,21 @@ tracker gates are complete. ## Security Posture -- Native bindings load lazily through `(jerboa ffi)` and do not call native - loaders during module import. +- Native bindings prefer statically registered symbols. Lazy dynamic loading + requires an explicit development opt-in and a trusted canonical absolute + directory. The shared `(std native-loader)` policy validates every path + component's type, owner, and write mode; current-directory, bare-name, + symlinked, unexpected-owner, and world-writable paths are forbidden. + Group-write is accepted only on same-user components in explicit + unprivileged development mode. Privileged processes ignore overrides. - Scheme strings that cross into C are converted to UTF-8 bytevectors with explicit lengths. - Embedded NUL bytes in text, property keys, lexer names, and terminal output are rejected before native calls. +- Native text and clipboard results use capacity-checked caller-owned + bytevectors with a 64 MiB public limit instead of fixed receive storage. +- Never-reused numeric handles and synchronized operation, callback, + notification, and destruction state prevent stale-handle aliasing and UAF. - Blocking termbox calls are declared collect-safe. - CI runs pure build/tests and records whether native Scintilla vendor archives are available for linkage audit. @@ -40,10 +49,14 @@ have been scrubbed. - Validate the pinned Scintilla, Lexilla, and termbox_next checkout with `make vendor-provenance-enforce` on the native release machine. - Add native terminal smoke tests and deterministic teardown checks. -- Add hostile-input tests for malformed UTF-8, very large buffers, long lexer - names, notification queue pressure, and clipboard edge cases. -- Add sanitizer builds for the C/C++ shim and vendored native code. -- Document thread ownership and lifecycle guarantees for native handles. +- Extend hostile-input tests for malformed UTF-8, long lexer names, + notification queue pressure, and clipboard edge cases. Boundary sizes through + 1 MiB, embedded NUL, and multibyte data are covered now. +- Run ASan/UBSan and ThreadSanitizer over the complete vendored C++ build on + every target. The shim and deterministic fake backend are already tested by + all three sanitizers. +- Validate upstream UI-thread requirements; the shim itself serializes native + operations and provides callback-aware destruction. - Generate passing native shim reproducibility evidence for Jerboa, compilers, libc/libc++, Scintilla, Lexilla, and termbox_next. - Attach a marker-complete target native proof with no buffers, clipboard --- a/docs/ffi-boundary.md +++ b/docs/ffi-boundary.md @@ -8,14 +8,28 @@ - Own Scintilla instance allocation and destruction. - Keep a per-instance notification queue and expose one drained notification at a time. - Copy Scheme-provided bytevectors into short-lived NUL-terminated buffers before calling Scintilla or termbox text APIs. -- Reject null handles and embedded NUL bytes at native entry points. -- Return empty strings instead of null C strings for optional clipboard, notification, and text results. +- Resolve never-reused numeric handle IDs under a synchronized registry and + hold a lifecycle reference for every operation and callback. +- Serialize operations per instance, synchronize notification queues, and + remove an ID before destruction waits for in-flight references. +- Recheck text-result length and actual bytevector capacity while holding the + same operation lock as the Scintilla write. A too-small buffer is rejected + before Scintilla receives its pointer. ## Scheme Responsibilities - Load `jerboa_scintilla_shim` lazily through `(jerboa ffi)`. +- Prefer statically registered symbols. Permit dynamic development loading only + with `JERBOA_SCINTILLA_DEV_NATIVE=1` and an absolute `JERBOA_SCINTILLA_LIB` + directory accepted by `(std native-loader)`. The shared policy rejects + non-canonical or symlinked paths, unexpected owners, and world-writable + libraries or ancestors. Group-write is allowed only on same-user components + in explicit unprivileged development mode; privileged processes ignore the + environment. - Pass text, property keys, lexer names, and terminal output as UTF-8 bytevectors with explicit lengths. - Reject strings containing NUL bytes before any native call. +- Decode checked, exact-length result bytevectors only after the native call; + reject results larger than 64 MiB rather than truncating them. - Treat editor handles as single-owner resources and call `editor-destroy` when done. - Avoid terminal event calls from threads that cannot tolerate blocking. @@ -23,14 +37,22 @@ - `create-scintilla-editor` returns an editor that owns one native Scintilla handle. - `editor-destroy` releases the native handle and clears the Scheme record field. -- Notification text returned by accessors is copied from a fixed native buffer and should be treated as transient. +- Notification text returned by accessors is copied from a bounded per-entry + native buffer and should be treated as transient. +- Clipboard and editor text use caller-owned, length-aware bytevectors and are + never routed through process- or thread-global receive storage. - Lexilla lexer ownership is delegated to Scintilla after `SCI_SETILEXER`; this assumption needs upstream verification before public production release. ## Error Mapping -- Missing native libraries are reported when a native FFI function is first called, not during module import. +- Missing or untrusted dynamic native libraries are reported when a native FFI + function is first called, not during module import. - Null native handles return neutral values or no-op in the C shim. - Embedded NUL strings raise Scheme errors before the native call. +- Invalid, stale, and already-destroyed numeric IDs never dereference caller + memory and cannot alias a later instance. +- A native result that changes between the sizing and receive calls is retried + up to three times; oversized or unstable results raise an error. - Native allocation failures currently return null/zero and require caller-level checks for production workflows. - `make native-boundary-corpus` exercises deterministic NUL rejection, non-ASCII UTF-8 strings, large editor text, notification accessors/draining, @@ -43,6 +65,9 @@ ignored local checkout before native release evidence. - Hosted CI still needs a terminal integration test job with the native vendor checkout populated. -- The notification queue is not thread-safe. - There are no production memory or render-time quotas around hostile editor - content beyond the bounded local corpus. + content beyond the 64 MiB result boundary and bounded local corpus. +- The sanitizer harness instruments the shim and a deterministic fake backend + with ASan/UBSan and ThreadSanitizer; + release evidence still needs ASan coverage of the complete vendored + Scintilla/Lexilla/termbox C++ build on each supported target. --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -15,22 +15,31 @@ process FFI boundary, not as a sandbox. - Scheme strings cross into C and then into Scintilla or termbox_next. - Terminal input events cross from termbox_next into Scheme event records. -- Clipboard data crosses from Scintilla-owned memory into a native static buffer and then into Scheme strings. +- Clipboard data crosses from Scintilla-owned memory into a capacity-checked, + Scheme-owned bytevector and then into Scheme strings. - Lexer names cross into Lexilla dynamic lexer creation. ## Primary Risks - Embedded NUL bytes causing text truncation or property confusion across C APIs. - Blocking terminal calls stalling the Jerboa runtime. -- Missing native library loading at import time breaking pure callers or CI. +- Loader-path substitution in development dynamic-library configurations. - Native handle misuse after `editor-destroy`. - Untrusted content triggering parser, lexer, rendering, or notification queue bugs in vendored native code. - Clipboard or notification data being logged accidentally by callers. ## Current Controls -- Native loading is lazy and uses `(jerboa ffi)` `load-shared-object*`. +- Statically registered native symbols take precedence. Lazy dynamic loading is + development-only and requires an explicit canonical absolute directory. The + shared `(std native-loader)` policy validates the library and every ancestor's + type, owner, and write mode, and privileged processes ignore the environment. - Text arguments cross as bytevectors with explicit lengths and are checked for NUL bytes. +- Text and clipboard results use caller-owned bytevectors whose capacity is + checked under the same per-instance lock as the native write. +- Never-reused numeric handles, synchronized registry references, per-instance + operation/ring locks, and close-before-wait destruction protect lifecycle + and notification state. - Blocking termbox poll/peek/present and text-print calls are collect-safe. - Pure tests run without vendored native archives and still validate the public API surface. - Native shim builds fail closed until expected Scintilla, Lexilla, and termbox_next archives exist. @@ -47,9 +56,13 @@ process FFI boundary, not as a sandbox. - Run `make vendor-provenance-enforce` with a local checkout matching `vendor-lock.env`. - Add native terminal smoke tests with deterministic teardown on Linux and macOS. -- Add malformed UTF-8, very long text, property, lexer-name, notification, and clipboard tests. -- Add sanitizer builds for the C/C++ shim and vendored native code. -- Document thread ownership and whether Scintilla handles can cross threads. +- Extend malformed UTF-8 and terminal integration coverage. Boundary-sized + text, multibyte content, property strings, notifications, and clipboard + access are in the native corpus. +- Run sanitizer builds for the complete vendored native code; the shim and + deterministic fake backend already run under ASan/UBSan and ThreadSanitizer. +- Validate upstream UI-thread requirements even though shim calls and + destruction are serialized and lifecycle-safe. - Generate passing native shim reproducibility evidence for Jerboa, C/C++ compilers, libc/libc++, Scintilla, Lexilla, and termbox_next. - Require marker-complete `SCINTILLA_TARGET_PROOF_FILE` evidence before making a --- a/jerboa_scintilla_shim.c +++ b/jerboa_scintilla_shim.c @@ -14,11 +14,17 @@ #include <string.h> #include <stdint.h> #include <stdbool.h> +#include <limits.h> +#include <pthread.h> +#ifdef JERBOA_SCINTILLA_FAKE_BACKEND +#include "fake_scintilla_backend.h" +#else #include "Scintilla.h" #include "ScintillaTermbox.h" #include "Lexilla.h" #include "termbox.h" +#endif /* ================================================================ Types @@ -26,11 +32,14 @@ typedef intptr_t sptr_t; typedef uintptr_t uptr_t; typedef void Scintilla; +typedef uint64_t jerboa_scintilla_handle_t; + +#define JERBOA_SCINTILLA_INVALID_HANDLE UINT64_C(0) +#define JERBOA_SCINTILLA_BUFFER_TOO_SMALL (-2L) static char *copy_ffi_bytes(const unsigned char *bytes, size_t len) { if (len > 0 && !bytes) return NULL; if (len > SIZE_MAX - 1) return NULL; - if (len > 0 && memchr(bytes, '\0', len)) return NULL; char *out = (char *)malloc(len + 1); if (!out) return NULL; @@ -66,36 +75,123 @@ typedef struct { } notify_entry_t; typedef struct instance_node { + jerboa_scintilla_handle_t id; Scintilla *view; notify_entry_t ring[NOTIFY_RING_SIZE]; int ring_head; int ring_tail; + bool closing; + size_t active_calls; + pthread_mutex_t operation_mutex; + pthread_mutex_t ring_mutex; + pthread_cond_t idle; struct instance_node *next; } instance_node_t; static instance_node_t *instance_list = NULL; +static pthread_mutex_t instance_list_mutex = PTHREAD_MUTEX_INITIALIZER; +static jerboa_scintilla_handle_t next_instance_id = UINT64_C(1); + +#ifdef JERBOA_SCINTILLA_TESTING +size_t jerboa_scintilla_test_live_instances(void) { + size_t count = 0; + pthread_mutex_lock(&instance_list_mutex); + for (instance_node_t *node = instance_list; node; node = node->next) count++; + pthread_mutex_unlock(&instance_list_mutex); + return count; +} + +int jerboa_scintilla_test_handle_registered(jerboa_scintilla_handle_t id) { + int registered = 0; + pthread_mutex_lock(&instance_list_mutex); + for (instance_node_t *node = instance_list; node; node = node->next) { + if (node->id == id) { + registered = 1; + break; + } + } + pthread_mutex_unlock(&instance_list_mutex); + return registered; +} +#endif /* Current notification for drain_one -> accessor pattern. */ static __thread notify_entry_t current_scn; -/* Find instance node by view pointer */ -static instance_node_t *find_instance(Scintilla *view) { +/* The numeric handle is never reused. Callers never dereference it, so a + * stale handle cannot become an alias for a later allocation at the same + * address. The registry lock turns lookup into a synchronized reference + * acquisition; deletion removes the ID before waiting for acquired calls. */ +static instance_node_t *find_instance_locked(jerboa_scintilla_handle_t id) { instance_node_t *n = instance_list; while (n) { - if (n->view == view) return n; + if (n->id == id) return n; n = n->next; } return NULL; } +static instance_node_t *acquire_instance(jerboa_scintilla_handle_t id) { + if (id == JERBOA_SCINTILLA_INVALID_HANDLE) return NULL; + + pthread_mutex_lock(&instance_list_mutex); + instance_node_t *inst = find_instance_locked(id); + if (inst && !inst->closing) { + inst->active_calls++; + } else { + inst = NULL; + } + pthread_mutex_unlock(&instance_list_mutex); + + if (inst) pthread_mutex_lock(&inst->operation_mutex); + return inst; +} + +static void release_instance(instance_node_t *inst) { + if (!inst) return; + pthread_mutex_unlock(&inst->operation_mutex); + + pthread_mutex_lock(&instance_list_mutex); + if (inst->active_calls > 0) inst->active_calls--; + if (inst->closing && inst->active_calls == 0) { + pthread_cond_signal(&inst->idle); + } + pthread_mutex_unlock(&instance_list_mutex); +} + +static bool acquire_callback(instance_node_t *inst) { + bool acquired = false; + pthread_mutex_lock(&instance_list_mutex); + if (inst && !inst->closing) { + inst->active_calls++; + acquired = true; + } + pthread_mutex_unlock(&instance_list_mutex); + return acquired; +} + +static void release_callback(instance_node_t *inst) { + pthread_mutex_lock(&instance_list_mutex); + if (inst->active_calls > 0) inst->active_calls--; + if (inst->closing && inst->active_calls == 0) { + pthread_cond_signal(&inst->idle); + } + pthread_mutex_unlock(&instance_list_mutex); +} + /* Notification callback — called synchronously from C++ */ static void notification_callback(Scintilla *view, int msg, SCNotification *scn, void *userdata) { (void)msg; - (void)userdata; + instance_node_t *inst = (instance_node_t *)userdata; - instance_node_t *inst = find_instance(view); - if (!inst) return; + if (!scn || !acquire_callback(inst)) return; + if (inst->view != view) { + release_callback(inst); + return; + } + + pthread_mutex_lock(&inst->ring_mutex); int next_head = (inst->ring_head + 1) % NOTIFY_RING_SIZE; if (next_head == inst->ring_tail) { @@ -132,112 +228,224 @@ static void notification_callback(Scintilla *view, int msg, } inst->ring_head = next_head; + pthread_mutex_unlock(&inst->ring_mutex); + release_callback(inst); } /* ================================================================ Instance lifecycle ================================================================ */ -void *jerboa_scintilla_new(void) { - Scintilla *sci = scintilla_new(notification_callback, NULL); - if (!sci) return NULL; - +jerboa_scintilla_handle_t jerboa_scintilla_new(void) { instance_node_t *node = (instance_node_t *)calloc(1, sizeof(instance_node_t)); - if (!node) { + if (!node) return JERBOA_SCINTILLA_INVALID_HANDLE; + + if (pthread_mutex_init(&node->operation_mutex, NULL) != 0) { + free(node); + return JERBOA_SCINTILLA_INVALID_HANDLE; + } + if (pthread_mutex_init(&node->ring_mutex, NULL) != 0) { + pthread_mutex_destroy(&node->operation_mutex); + free(node); + return JERBOA_SCINTILLA_INVALID_HANDLE; + } + if (pthread_cond_init(&node->idle, NULL) != 0) { + pthread_mutex_destroy(&node->ring_mutex); + pthread_mutex_destroy(&node->operation_mutex); + free(node); + return JERBOA_SCINTILLA_INVALID_HANDLE; + } + + Scintilla *sci = scintilla_new(notification_callback, node); + if (!sci) { + pthread_cond_destroy(&node->idle); + pthread_mutex_destroy(&node->ring_mutex); + pthread_mutex_destroy(&node->operation_mutex); + free(node); + return JERBOA_SCINTILLA_INVALID_HANDLE; + } + node->view = sci; + + pthread_mutex_lock(&instance_list_mutex); + if (next_instance_id == JERBOA_SCINTILLA_INVALID_HANDLE) { + pthread_mutex_unlock(&instance_list_mutex); scintilla_delete(sci); - return NULL; + pthread_cond_destroy(&node->idle); + pthread_mutex_destroy(&node->ring_mutex); + pthread_mutex_destroy(&node->operation_mutex); + free(node); + return JERBOA_SCINTILLA_INVALID_HANDLE; } - node->view = sci; + node->id = next_instance_id++; node->next = instance_list; instance_list = node; + pthread_mutex_unlock(&instance_list_mutex); - return (void *)sci; + return node->id; } -void jerboa_scintilla_delete(void *handle) { - if (!handle) return; - Scintilla *sci = (Scintilla *)handle; - /* Remove from instance list */ +void jerboa_scintilla_delete(jerboa_scintilla_handle_t handle) { + if (handle == JERBOA_SCINTILLA_INVALID_HANDLE) return; + + pthread_mutex_lock(&instance_list_mutex); instance_node_t **pp = &instance_list; while (*pp) { - if ((*pp)->view == sci) { - instance_node_t *rm = *pp; - *pp = rm->next; - free(rm); + if ((*pp)->id == handle) { break; } pp = &(*pp)->next; } - scintilla_delete(sci); + instance_node_t *node = *pp; + if (!node) { + pthread_mutex_unlock(&instance_list_mutex); + return; + } + + *pp = node->next; + node->next = NULL; + node->closing = true; + while (node->active_calls != 0) { + pthread_cond_wait(&node->idle, &instance_list_mutex); + } + pthread_mutex_unlock(&instance_list_mutex); + + /* No new call can acquire the removed ID, and every prior call and + * callback has released its reference. Callbacks made by the upstream + * destructor observe closing=true and are discarded before queue access. */ + pthread_mutex_lock(&node->operation_mutex); + scintilla_delete(node->view); + node->view = NULL; + pthread_mutex_lock(&node->ring_mutex); + memset(node->ring, 0, sizeof(node->ring)); + node->ring_head = 0; + node->ring_tail = 0; + pthread_mutex_unlock(&node->ring_mutex); + pthread_mutex_unlock(&node->operation_mutex); + + pthread_cond_destroy(&node->idle); + pthread_mutex_destroy(&node->ring_mutex); + pthread_mutex_destroy(&node->operation_mutex); + free(node); } /* ================================================================ Message passing ================================================================ */ -long jerboa_scintilla_send_message(void *handle, unsigned int msg, +long jerboa_scintilla_send_message(jerboa_scintilla_handle_t handle, unsigned int msg, unsigned long wparam, long lparam) { - if (!handle) return 0; - return (long)scintilla_send_message((Scintilla *)handle, msg, - (uptr_t)wparam, (sptr_t)lparam); + instance_node_t *inst = acquire_instance(handle); + if (!inst) return 0; + long result = (long)scintilla_send_message(inst->view, msg, + (uptr_t)wparam, (sptr_t)lparam); + release_instance(inst); + return result; } /* Send message with string as lparam (cast char* to sptr_t) */ -long jerboa_scintilla_send_message_string(void *handle, unsigned int msg, +long jerboa_scintilla_send_message_string(jerboa_scintilla_handle_t handle, unsigned int msg, unsigned long wparam, const char *str) { - if (!handle || !str) return 0; - return (long)scintilla_send_message((Scintilla *)handle, msg, - (uptr_t)wparam, (sptr_t)str); + if (!str) return 0; + instance_node_t *inst = acquire_instance(handle); + if (!inst) return 0; + long result = (long)scintilla_send_message(inst->view, msg, + (uptr_t)wparam, (sptr_t)str); + release_instance(inst); + return result; } -long jerboa_scintilla_send_message_bytes(void *handle, unsigned int msg, +long jerboa_scintilla_send_message_bytes(jerboa_scintilla_handle_t handle, unsigned int msg, unsigned long wparam, const unsigned char *bytes, size_t len) { - if (!handle) return 0; char *str = copy_ffi_bytes(bytes, len); if (!str) return 0; - long result = (long)scintilla_send_message((Scintilla *)handle, msg, + instance_node_t *inst = acquire_instance(handle); + if (!inst) { + free(str); + return 0; + } + long result = (long)scintilla_send_message(inst->view, msg, (uptr_t)wparam, (sptr_t)str); + release_instance(inst); free(str); return result; } -/* Receive string: call msg twice (get length, then fill buffer) */ -#define RECV_BUF_SIZE 65536 -static __thread char recv_buf[RECV_BUF_SIZE]; +/* Query the message-specific result length without issuing a write. The + * caller uses this only to size a Scheme-owned bytevector; receive_bytes + * repeats the query while holding the same per-instance operation lock as the + * write, so a stale size can never become an overflow. */ +long jerboa_scintilla_receive_length(jerboa_scintilla_handle_t handle, + unsigned int msg, + unsigned long wparam) { + instance_node_t *inst = acquire_instance(handle); + if (!inst) return -1; + long len = (long)scintilla_send_message(inst->view, msg, + (uptr_t)wparam, (sptr_t)NULL); + release_instance(inst); + return len; +} + +long jerboa_scintilla_receive_bytes(jerboa_scintilla_handle_t handle, + unsigned int msg, + unsigned long wparam, + unsigned char *out, + size_t capacity) { + if (!out || capacity == 0) return -1; + instance_node_t *inst = acquire_instance(handle); + if (!inst) return -1; + + long queried = (long)scintilla_send_message(inst->view, msg, + (uptr_t)wparam, (sptr_t)NULL); + if (queried < 0 || (uintmax_t)queried >= (uintmax_t)capacity) { + release_instance(inst); + return queried < 0 ? -1 : JERBOA_SCINTILLA_BUFFER_TOO_SMALL; + } -const char *jerboa_scintilla_receive_string(void *handle, unsigned int msg, - unsigned long wparam) { - if (!handle) { - recv_buf[0] = '\0'; - return recv_buf; + /* SCI_GETTEXT's wParam is the destination capacity. Never pass a value + * larger than the actual bytevector even if a caller supplied one. Other + * string-result messages use wParam for their own message-specific input; + * the checked query above bounds their write while this lock excludes + * concurrent edits through the shim. */ + uptr_t call_wparam = (uptr_t)wparam; + if (msg == SCI_GETTEXT && call_wparam > (uptr_t)capacity) { + call_wparam = (uptr_t)capacity; } - long len = (long)scintilla_send_message((Scintilla *)handle, msg, - (uptr_t)wparam, (sptr_t)NULL); - if (len <= 0) { - recv_buf[0] = '\0'; - return recv_buf; + + long written = (long)scintilla_send_message(inst->view, msg, + call_wparam, (sptr_t)out); + if (written < 0 || (uintmax_t)written >= (uintmax_t)capacity) { + /* SCI_GETTEXT is capacity-clamped above. For other messages this is + * a defensive contract check; the pre-write query and serialized + * operation should make it unreachable for conforming Scintilla. */ + out[capacity - 1] = '\0'; + release_instance(inst); + return -1; } - if (len >= RECV_BUF_SIZE) len = RECV_BUF_SIZE - 1; - scintilla_send_message((Scintilla *)handle, msg, - (uptr_t)wparam, (sptr_t)recv_buf); - recv_buf[len] = '\0'; - return recv_buf; + + size_t text_len = (size_t)written; + if (text_len > 0 && out[text_len - 1] == '\0') text_len--; + out[text_len] = '\0'; + release_instance(inst); + return (long)text_len; } /* Set property: wparam = key string, lparam = value string */ -long jerboa_scintilla_set_property(void *handle, unsigned int msg, +long jerboa_scintilla_set_property(jerboa_scintilla_handle_t handle, unsigned int msg, const char *key, const char *value) { - if (!handle || !key || !value) return 0; - return (long)scintilla_send_message((Scintilla *)handle, msg, - (uptr_t)key, (sptr_t)value); + if (!key || !value) return 0; + instance_node_t *inst = acquire_instance(handle); + if (!inst) return 0; + long result = (long)scintilla_send_message(inst->view, msg, + (uptr_t)key, (sptr_t)value); + release_instance(inst); + return result; } -long jerboa_scintilla_set_property_bytes(void *handle, unsigned int msg, +long jerboa_scintilla_set_property_bytes(jerboa_scintilla_handle_t handle, unsigned int msg, const unsigned char *key, size_t key_len, const unsigned char *value, size_t value_len) { - if (!handle) return 0; char *key_str = copy_ffi_bytes(key, key_len); if (!key_str) return 0; char *value_str = copy_ffi_bytes(value, value_len); @@ -246,84 +454,223 @@ long jerboa_scintilla_set_property_bytes(void *handle, unsigned int msg, return 0; } - long result = (long)scintilla_send_message((Scintilla *)handle, msg, + if (memchr(key_str, '\0', key_len) || memchr(value_str, '\0', value_len)) { + free(value_str); + free(key_str); + return 0; + } + instance_node_t *inst = acquire_instance(handle); + if (!inst) { + free(value_str); + free(key_str); + return 0; + } + long result = (long)scintilla_send_message(inst->view, msg, (uptr_t)key_str, (sptr_t)value_str); + release_instance(inst); free(value_str); free(key_str); return result; } +long jerboa_scintilla_property_length(jerboa_scintilla_handle_t handle, + unsigned int msg, + const unsigned char *key, + size_t key_len) { + char *key_str = copy_ffi_bytes(key, key_len); + if (!key_str || memchr(key_str, '\0', key_len)) { + free(key_str); + return -1; + } + instance_node_t *inst = acquire_instance(handle); + if (!inst) { + free(key_str); + return -1; + } + long len = (long)scintilla_send_message(inst->view, msg, + (uptr_t)key_str, (sptr_t)NULL); + release_instance(inst); + free(key_str); + return len; +} + +long jerboa_scintilla_receive_property_bytes( + jerboa_scintilla_handle_t handle, unsigned int msg, + const unsigned char *key, size_t key_len, + unsigned char *out, size_t capacity) { + if (!out || capacity == 0) return -1; + char *key_str = copy_ffi_bytes(key, key_len); + if (!key_str || memchr(key_str, '\0', key_len)) { + free(key_str); + return -1; + } + instance_node_t *inst = acquire_instance(handle); + if (!inst) { + free(key_str); + return -1; + } + long queried = (long)scintilla_send_message(inst->view, msg, + (uptr_t)key_str, + (sptr_t)NULL); + if (queried < 0 || (uintmax_t)queried >= (uintmax_t)capacity) { + release_instance(inst); + free(key_str); + return queried < 0 ? -1 : JERBOA_SCINTILLA_BUFFER_TOO_SMALL; + } + long written = (long)scintilla_send_message(inst->view, msg, + (uptr_t)key_str, + (sptr_t)out); + if (written < 0 || (uintmax_t)written >= (uintmax_t)capacity) { + out[capacity - 1] = '\0'; + release_instance(inst); + free(key_str); + return -1; + } + size_t text_len = (size_t)written; + if (text_len > 0 && out[text_len - 1] == '\0') text_len--; + out[text_len] = '\0'; + release_instance(inst); + free(key_str); + return (long)text_len; +} + +long jerboa_scintilla_property_int(jerboa_scintilla_handle_t handle, + unsigned int msg, + const unsigned char *key, + size_t key_len, + long default_value) { + char *key_str = copy_ffi_bytes(key, key_len); + if (!key_str || memchr(key_str, '\0', key_len)) { + free(key_str); + return default_value; + } + instance_node_t *inst = acquire_instance(handle); + if (!inst) { + free(key_str); + return default_value; + } + long result = (long)scintilla_send_message(inst->view, msg, + (uptr_t)key_str, + (sptr_t)default_value); + release_instance(inst); + free(key_str); + return result; +} + /* ================================================================ Input ================================================================ */ -void jerboa_scintilla_send_key(void *handle, int key, +void jerboa_scintilla_send_key(jerboa_scintilla_handle_t handle, int key, int shift, int ctrl, int alt) { - if (!handle) return; - scintilla_send_key((Scintilla *)handle, key, shift, ctrl, alt); + instance_node_t *inst = acquire_instance(handle); + if (!inst) return; + scintilla_send_key(inst->view, key, shift, ctrl, alt); + release_instance(inst); } -int jerboa_scintilla_send_mouse(void *handle, int event, int button, +int jerboa_scintilla_send_mouse(jerboa_scintilla_handle_t handle, int event, int button, int y, int x, int shift, int ctrl, int alt) { - if (!handle) return 0; - return scintilla_send_mouse((Scintilla *)handle, event, button, - y, x, shift, ctrl, alt); + instance_node_t *inst = acquire_instance(handle); + if (!inst) return 0; + int result = scintilla_send_mouse(inst->view, event, button, + y, x, shift, ctrl, alt); + release_instance(inst); + return result; } /* ================================================================ Display ================================================================ */ -void jerboa_scintilla_refresh(void *handle) { - if (!handle) return; - scintilla_refresh((Scintilla *)handle); +void jerboa_scintilla_refresh(jerboa_scintilla_handle_t handle) { + instance_node_t *inst = acquire_instance(handle); + if (!inst) return; + scintilla_refresh(inst->view); + release_instance(inst); } -void jerboa_scintilla_resize(void *handle, int width, int height) { - if (!handle) return; - scintilla_resize((Scintilla *)handle, width, height); +void jerboa_scintilla_resize(jerboa_scintilla_handle_t handle, int width, int height) { + instance_node_t *inst = acquire_instance(handle); + if (!inst) return; + scintilla_resize(inst->view, width, height); + release_instance(inst); } -void jerboa_scintilla_move(void *handle, int x, int y) { - if (!handle) return; - scintilla_move((Scintilla *)handle, x, y); +void jerboa_scintilla_move(jerboa_scintilla_handle_t handle, int x, int y) { + instance_node_t *inst = acquire_instance(handle); + if (!inst) return; + scintilla_move(inst->view, x, y); + release_instance(inst); } /* ================================================================ Clipboard ================================================================ */ -const char *jerboa_scintilla_get_clipboard(void *handle) { - if (!handle) return ""; +long jerboa_scintilla_clipboard_length(jerboa_scintilla_handle_t handle) { + instance_node_t *inst = acquire_instance(handle); + if (!inst) return -1; int len = 0; - char *buf = scintilla_get_clipboard((Scintilla *)handle, &len); - if (!buf || len < 0) return ""; - /* Copy into static buffer so Chez can read it, then free */ - if (len >= RECV_BUF_SIZE) len = RECV_BUF_SIZE - 1; - memcpy(recv_buf, buf, len); - recv_buf[len] = '\0'; + char *buf = scintilla_get_clipboard(inst->view, &len); + free(buf); + release_instance(inst); + return len < 0 ? -1 : (long)len; +} + +long jerboa_scintilla_receive_clipboard_bytes( + jerboa_scintilla_handle_t handle, unsigned char *out, size_t capacity) { + if (!out || capacity == 0) return -1; + instance_node_t *inst = acquire_instance(handle);