From da93824eb71613a33422409e93b2cb4c314eb77c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:17:28 +0000 Subject: [PATCH 01/19] Add Rust application ABI and SDK foundation Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- CMakeLists.txt | 10 + cmake/ccf_app.cmake | 86 +++ include/ccf/rust_ffi.h | 106 +++ samples/CMakeLists.txt | 3 + samples/apps/basic_rust/CMakeLists.txt | 19 + samples/apps/basic_rust/Cargo.lock | 95 +++ samples/apps/basic_rust/Cargo.toml | 14 + samples/apps/basic_rust/rust-toolchain.toml | 2 + samples/apps/basic_rust/src/lib.rs | 53 ++ src/rust/Cargo.toml | 2 +- src/rust/app_bridge.cpp | 634 +++++++++++++++++ src/rust/src/lib.rs | 712 ++++++++++++++++++++ 12 files changed, 1735 insertions(+), 1 deletion(-) create mode 100644 include/ccf/rust_ffi.h create mode 100644 samples/apps/basic_rust/CMakeLists.txt create mode 100644 samples/apps/basic_rust/Cargo.lock create mode 100644 samples/apps/basic_rust/Cargo.toml create mode 100644 samples/apps/basic_rust/rust-toolchain.toml create mode 100644 samples/apps/basic_rust/src/lib.rs create mode 100644 src/rust/app_bridge.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index e99080203f16..a7b0a30ac6ec 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -178,6 +178,16 @@ install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cmake/tools.cmake DESTINATION cmake) include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/ccf_app.cmake) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cmake/ccf_app.cmake DESTINATION cmake) +install( + DIRECTORY ${CCF_DIR}/src/rust/ + DESTINATION share/ccf/rust + PATTERN target EXCLUDE +) +install( + FILES ${CCF_DIR}/samples/apps/main.cpp + DESTINATION share/ccf/rust + RENAME app_main.cpp +) # Copy and install CCF utilities set(CCF_UTILITIES keygenerator.sh submit_recovery_share.sh) diff --git a/cmake/ccf_app.cmake b/cmake/ccf_app.cmake index 2f119beae2bf..10e273f91a87 100644 --- a/cmake/ccf_app.cmake +++ b/cmake/ccf_app.cmake @@ -52,6 +52,92 @@ function(add_ccf_app name) endif() endfunction() +function(add_ccf_rust_app name) + cmake_parse_arguments( + PARSE_ARGV 1 + PARSED_ARGS + "" + "MANIFEST_PATH;PACKAGE" + "DEPS" + ) + + if(NOT PARSED_ARGS_MANIFEST_PATH) + message(FATAL_ERROR "add_ccf_rust_app requires MANIFEST_PATH") + endif() + if(NOT PARSED_ARGS_PACKAGE) + set(PARSED_ARGS_PACKAGE ${name}) + endif() + + find_program(CARGO NAMES cargo REQUIRED) + find_program(RUSTC NAMES rustc REQUIRED) + + if(CMAKE_CONFIGURATION_TYPES) + message( + FATAL_ERROR + "Multi-config generators are not supported for Rust CCF applications" + ) + endif() + + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(CARGO_PROFILE_FLAG "") + set(CARGO_PROFILE_DIR debug) + else() + set(CARGO_PROFILE_FLAG --release) + set(CARGO_PROFILE_DIR release) + endif() + + string(REPLACE "-" "_" RUST_LIB_NAME ${PARSED_ARGS_PACKAGE}) + get_filename_component(MANIFEST_PATH ${PARSED_ARGS_MANIFEST_PATH} ABSOLUTE) + get_filename_component(MANIFEST_DIR ${MANIFEST_PATH} DIRECTORY) + set(CARGO_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/cargo/${name}) + set(RUST_APP_LIB ${CARGO_TARGET_DIR}/${CARGO_PROFILE_DIR}/lib${RUST_LIB_NAME}.a) + + file( + GLOB_RECURSE + RUST_APP_SOURCES + CONFIGURE_DEPENDS + ${MANIFEST_DIR}/src/*.rs + ) + + set(RUSTFLAGS "--remap-path-prefix=${MANIFEST_DIR}=APP") + add_custom_command( + OUTPUT ${RUST_APP_LIB} + COMMAND ${CMAKE_COMMAND} -E make_directory ${CARGO_TARGET_DIR} + COMMAND + ${CMAKE_COMMAND} -E env --unset=CARGO_BUILD_TARGET + "RUSTFLAGS=${RUSTFLAGS}" "CARGO_NET_RETRY=10" "CARGO_HTTP_TIMEOUT=60" + "CC=${CMAKE_C_COMPILER}" "CXX=${CMAKE_CXX_COMPILER}" "AR=${CMAKE_AR}" + "CARGO_BUILD_RUSTC=${RUSTC}" ${CARGO} build --lib --package + ${PARSED_ARGS_PACKAGE} --manifest-path ${MANIFEST_PATH} --target-dir + ${CARGO_TARGET_DIR} ${CARGO_PROFILE_FLAG} --locked + WORKING_DIRECTORY ${MANIFEST_DIR} + DEPENDS + ${MANIFEST_PATH} + ${MANIFEST_DIR}/Cargo.lock + ${RUST_APP_SOURCES} + ${PARSED_ARGS_DEPS} + COMMENT "Building Rust CCF application ${name}" + USES_TERMINAL + VERBATIM + ) + add_custom_target(cargo-build_${name} DEPENDS ${RUST_APP_LIB}) + + if(EXISTS "${CCF_DIR}/src/rust/app_bridge.cpp") + set(RUST_BRIDGE_SOURCE "${CCF_DIR}/src/rust/app_bridge.cpp") + set(RUST_APP_MAIN_SOURCE "${CCF_DIR}/samples/apps/main.cpp") + else() + set(RUST_BRIDGE_SOURCE "${CCF_DIR}/share/ccf/rust/app_bridge.cpp") + set(RUST_APP_MAIN_SOURCE "${CCF_DIR}/share/ccf/rust/app_main.cpp") + endif() + + add_ccf_app( + ${name} + SRCS ${RUST_BRIDGE_SOURCE} ${RUST_APP_MAIN_SOURCE} + LINK_LIBS ${RUST_APP_LIB} + DEPS cargo-build_${name} + ) +endfunction() + function(add_ccf_static_library name) cmake_parse_arguments(PARSE_ARGV 1 PARSED_ARGS "" "" "SRCS;LINK_LIBS") diff --git a/include/ccf/rust_ffi.h b/include/ccf/rust_ffi.h new file mode 100644 index 000000000000..efe3b9eb11e6 --- /dev/null +++ b/include/ccf/rust_ffi.h @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include +#include + +#ifdef __cplusplus +extern "C" +{ +#endif + + static const uint32_t CCF_RUST_ABI_VERSION = 1; + + typedef struct ccf_rust_registry ccf_rust_registry; + typedef struct ccf_rust_endpoint_context ccf_rust_endpoint_context; + + typedef struct ccf_rust_slice + { + const uint8_t* data; + size_t len; + } ccf_rust_slice; + + typedef enum ccf_rust_result + { + CCF_RUST_OK = 0, + CCF_RUST_NOT_FOUND = 1, + CCF_RUST_INVALID_ARGUMENT = 2, + CCF_RUST_READ_ONLY = 3, + CCF_RUST_INTERNAL_ERROR = 4 + } ccf_rust_result; + + typedef enum ccf_rust_auth + { + CCF_RUST_AUTH_NONE = 0, + CCF_RUST_AUTH_USER_CERT = 1 + } ccf_rust_auth; + + typedef int (*ccf_rust_endpoint_callback)( + void* user_data, ccf_rust_endpoint_context* ctx); + typedef void (*ccf_rust_drop_callback)(void* user_data); + + uint32_t ccf_rust_get_abi_version(void); + + int ccf_rust_register_endpoint( + ccf_rust_registry* registry, + ccf_rust_slice path, + ccf_rust_slice method, + ccf_rust_auth auth, + int read_only, + ccf_rust_endpoint_callback callback, + ccf_rust_drop_callback drop, + void* user_data); + + int ccf_rust_request_body( + ccf_rust_endpoint_context* ctx, ccf_rust_slice* body); + int ccf_rust_request_query( + ccf_rust_endpoint_context* ctx, ccf_rust_slice* query); + int ccf_rust_request_path_param( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice name, + ccf_rust_slice* value); + int ccf_rust_request_header( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice name, + ccf_rust_slice* value); + + int ccf_rust_response_status(ccf_rust_endpoint_context* ctx, uint16_t status); + int ccf_rust_response_header( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice name, + ccf_rust_slice value); + int ccf_rust_response_body( + ccf_rust_endpoint_context* ctx, ccf_rust_slice body); + int ccf_rust_response_error( + ccf_rust_endpoint_context* ctx, + uint16_t status, + ccf_rust_slice code, + ccf_rust_slice message); + + int ccf_rust_kv_get( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + ccf_rust_slice* value); + int ccf_rust_kv_has( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + int* present); + int ccf_rust_kv_put( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + ccf_rust_slice value); + int ccf_rust_kv_remove( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key); + + uint32_t ccf_rust_app_abi_version(void); + int ccf_rust_app_register(ccf_rust_registry* registry); + +#ifdef __cplusplus +} +#endif diff --git a/samples/CMakeLists.txt b/samples/CMakeLists.txt index 30c6c9f7dbcd..6e016bc8f9e4 100644 --- a/samples/CMakeLists.txt +++ b/samples/CMakeLists.txt @@ -9,3 +9,6 @@ add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/apps/nobuiltins) # Add Programmability app add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/apps/programmability) + +# Add Rust basic app +add_subdirectory(${CMAKE_CURRENT_LIST_DIR}/apps/basic_rust) diff --git a/samples/apps/basic_rust/CMakeLists.txt b/samples/apps/basic_rust/CMakeLists.txt new file mode 100644 index 000000000000..13206a000bed --- /dev/null +++ b/samples/apps/basic_rust/CMakeLists.txt @@ -0,0 +1,19 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +cmake_minimum_required(VERSION 3.21) + +project(basic_rust LANGUAGES C CXX) + +set(CCF_PROJECT "ccf") + +if(NOT TARGET "ccf") + find_package(${CCF_PROJECT} REQUIRED) +endif() + +add_ccf_rust_app( + basic_rust + MANIFEST_PATH ${CMAKE_CURRENT_LIST_DIR}/Cargo.toml + PACKAGE ccf-basic-rust + DEPS ${CCF_DIR}/src/rust/src/lib.rs +) diff --git a/samples/apps/basic_rust/Cargo.lock b/samples/apps/basic_rust/Cargo.lock new file mode 100644 index 000000000000..802803b9fb70 --- /dev/null +++ b/samples/apps/basic_rust/Cargo.lock @@ -0,0 +1,95 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "cborrs" +version = "0.1.0" +source = "git+https://github.com/project-everest/everparse.git?rev=950bc93838ac2faae51126d8acd0637cf8c8a569#950bc93838ac2faae51126d8acd0637cf8c8a569" + +[[package]] +name = "cborrs-nondet" +version = "0.1.0" +source = "git+https://github.com/project-everest/everparse.git?rev=950bc93838ac2faae51126d8acd0637cf8c8a569#950bc93838ac2faae51126d8acd0637cf8c8a569" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "ccf-basic-rust" +version = "0.1.0" +dependencies = [ + "ccf-rs", +] + +[[package]] +name = "ccf-rs" +version = "0.1.0" +dependencies = [ + "cose-rs", +] + +[[package]] +name = "cose-openssl" +version = "0.1.0" +dependencies = [ + "cborrs", + "cborrs-nondet", + "openssl-sys", +] + +[[package]] +name = "cose-rs" +version = "0.1.0" +dependencies = [ + "cose-openssl", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" diff --git a/samples/apps/basic_rust/Cargo.toml b/samples/apps/basic_rust/Cargo.toml new file mode 100644 index 000000000000..b2c5e9aa113f --- /dev/null +++ b/samples/apps/basic_rust/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "ccf-basic-rust" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +ccf-rs = { path = "../../../src/rust" } + +[profile.release] +lto = true +codegen-units = 1 diff --git a/samples/apps/basic_rust/rust-toolchain.toml b/samples/apps/basic_rust/rust-toolchain.toml new file mode 100644 index 000000000000..ff100edcbbe7 --- /dev/null +++ b/samples/apps/basic_rust/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "1.90.0" diff --git a/samples/apps/basic_rust/src/lib.rs b/samples/apps/basic_rust/src/lib.rs new file mode 100644 index 000000000000..34f75a237fb2 --- /dev/null +++ b/samples/apps/basic_rust/src/lib.rs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +use ccf_rs::{Auth, BridgeError, EndpointError, EndpointResult, Registry}; + +const RECORDS: &str = "records"; + +fn required_key(value: Result, BridgeError>) -> Result { + value?.ok_or_else(|| EndpointError::new(400, "InvalidResourceName", "Missing key")) +} + +fn register(registry: &mut Registry) -> Result<(), BridgeError> { + registry.read_write( + "/records/{key}", + "PUT", + Auth::UserCert, + |context| -> EndpointResult { + let body = context.body()?.to_vec(); + let key = required_key(context.path_param("key"))?; + context.map(RECORDS).put(key.as_bytes(), &body)?; + context.set_status(204)?; + Ok(()) + }, + )?; + + registry.read_only( + "/records/{key}", + "GET", + Auth::UserCert, + |context| -> EndpointResult { + let key = required_key(context.path_param("key"))?; + match context.map(RECORDS).get(key.as_bytes())? { + Some(value) => { + context.set_status(200)?; + context.set_header("content-type", "application/octet-stream")?; + context.set_body(&value)?; + Ok(()) + } + None => Err(EndpointError::new(404, "ResourceNotFound", "No such key")), + } + }, + )?; + + registry.read_only("/health", "GET", Auth::None, |context| { + context.set_status(200)?; + context.set_body(b"OK")?; + Ok(()) + })?; + + Ok(()) +} + +ccf_rs::export_app!(register); diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 06c5c8d89a51..8b9c39fea731 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["staticlib"] +crate-type = ["rlib", "staticlib"] [dependencies] cose-rs = { path = "../cose/cose_rs" } diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp new file mode 100644 index 000000000000..8f46ff90e8af --- /dev/null +++ b/src/rust/app_bridge.cpp @@ -0,0 +1,634 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +#include "ccf/app_interface.h" +#include "ccf/common_auth_policies.h" +#include "ccf/http_status.h" +#include "ccf/odata_error.h" +#include "ccf/rust_ffi.h" +#include "kv/untyped_map.h" + +#include +#include +#include +#include +#include + +namespace +{ + using RawMap = ccf::kv::untyped::Map; + class RustEndpointRegistry; + + bool is_valid_utf8(const ccf_rust_slice& value) + { + if (value.data == nullptr) + { + return value.len == 0; + } + + size_t i = 0; + while (i < value.len) + { + const auto first = value.data[i++]; + if (first <= 0x7f) + { + continue; + } + + size_t continuation_count = 0; + uint32_t code_point = 0; + if ((first & 0xe0) == 0xc0) + { + continuation_count = 1; + code_point = first & 0x1f; + } + else if ((first & 0xf0) == 0xe0) + { + continuation_count = 2; + code_point = first & 0x0f; + } + else if ((first & 0xf8) == 0xf0) + { + continuation_count = 3; + code_point = first & 0x07; + } + else + { + return false; + } + + if (i + continuation_count > value.len) + { + return false; + } + + for (size_t j = 0; j < continuation_count; ++j) + { + const auto next = value.data[i++]; + if ((next & 0xc0) != 0x80) + { + return false; + } + code_point = (code_point << 6) | (next & 0x3f); + } + + const auto minimum = + continuation_count == 1 ? 0x80u : + continuation_count == 2 ? 0x800u : + 0x10000u; + if ( + code_point < minimum || code_point > 0x10ffff || + (code_point >= 0xd800 && code_point <= 0xdfff)) + { + return false; + } + } + + return true; + } + + bool is_valid_buffer(const ccf_rust_slice& value) + { + return value.data != nullptr || value.len == 0; + } + + std::string to_string(const ccf_rust_slice& value) + { + if (value.len == 0) + { + return {}; + } + return {reinterpret_cast(value.data), value.len}; + } + + std::vector to_bytes(const ccf_rust_slice& value) + { + if (value.len == 0) + { + return {}; + } + return {value.data, value.data + value.len}; + } + + void set_slice(ccf_rust_slice* out, const std::vector& value) + { + out->data = value.data(); + out->len = value.size(); + } + + void set_slice(ccf_rust_slice* out, const std::string& value) + { + out->data = reinterpret_cast(value.data()); + out->len = value.size(); + } + + struct CallbackState + { + ccf_rust_endpoint_callback callback; + ccf_rust_drop_callback drop; + void* user_data; + + ~CallbackState() + { + if (drop != nullptr) + { + drop(user_data); + } + } + }; +} + +struct ccf_rust_registry +{ + RustEndpointRegistry* registry; +}; + +struct ccf_rust_endpoint_context +{ + std::shared_ptr rpc; + ccf::kv::ReadOnlyTx* tx; + ccf::kv::Tx* writable_tx; + std::unordered_map read_handles; + std::unordered_map write_handles; + std::vector scratch; + + RawMap::ReadOnlyHandle* read_handle(const std::string& map_name) + { + const auto existing = read_handles.find(map_name); + if (existing != read_handles.end()) + { + return existing->second; + } + + auto* handle = tx->ro(map_name); + read_handles.emplace(map_name, handle); + return handle; + } + + RawMap::Handle* write_handle(const std::string& map_name) + { + if (writable_tx == nullptr) + { + return nullptr; + } + + const auto existing = write_handles.find(map_name); + if (existing != write_handles.end()) + { + return existing->second; + } + + auto* handle = writable_tx->rw(map_name); + write_handles.emplace(map_name, handle); + read_handles[map_name] = handle; + return handle; + } +}; + +namespace +{ + class RustEndpointRegistry : public ccf::UserEndpointRegistry + { + public: + using ccf::UserEndpointRegistry::UserEndpointRegistry; + + void init_handlers() override + { + CommonEndpointRegistry::init_handlers(); + if (ccf_rust_app_abi_version() != CCF_RUST_ABI_VERSION) + { + throw std::logic_error("Rust application ABI version mismatch"); + } + + ccf_rust_registry registry{this}; + if (ccf_rust_app_register(®istry) != CCF_RUST_OK) + { + throw std::logic_error("Rust application endpoint registration failed"); + } + } + + void add_endpoint( + const std::string& path, + const ccf::RESTVerb& method, + ccf_rust_auth auth, + bool read_only, + const std::shared_ptr& state) + { + ccf::AuthnPolicies policies; + if (auth == CCF_RUST_AUTH_USER_CERT) + { + policies = {ccf::user_cert_auth_policy}; + } + + if (read_only) + { + make_read_only_endpoint( + path, + method, + [state](ccf::endpoints::ReadOnlyEndpointContext& ctx) { + ccf_rust_endpoint_context rust_ctx{ + ctx.rpc_ctx, &ctx.tx, nullptr, {}, {}, {}}; + try + { + if (state->callback(state->user_data, &rust_ctx) != CCF_RUST_OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + "Rust endpoint execution failed"); + } + } + catch (const std::exception& e) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format("Rust endpoint bridge failed: {}", e.what())); + } + catch (...) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + "Rust endpoint bridge failed"); + } + }, + policies) + .install(); + } + else + { + make_endpoint( + path, + method, + [state](ccf::endpoints::EndpointContext& ctx) { + ccf_rust_endpoint_context rust_ctx{ + ctx.rpc_ctx, &ctx.tx, &ctx.tx, {}, {}, {}}; + try + { + if (state->callback(state->user_data, &rust_ctx) != CCF_RUST_OK) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + "Rust endpoint execution failed"); + } + } + catch (const std::exception& e) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + fmt::format("Rust endpoint bridge failed: {}", e.what())); + } + catch (...) + { + ctx.rpc_ctx->set_error( + HTTP_STATUS_INTERNAL_SERVER_ERROR, + ccf::errors::InternalError, + "Rust endpoint bridge failed"); + } + }, + policies) + .install(); + } + } + }; +} + +extern "C" +{ + uint32_t ccf_rust_get_abi_version(void) + { + return CCF_RUST_ABI_VERSION; + } + + int ccf_rust_register_endpoint( + ccf_rust_registry* registry, + ccf_rust_slice path, + ccf_rust_slice method, + ccf_rust_auth auth, + int read_only, + ccf_rust_endpoint_callback callback, + ccf_rust_drop_callback drop, + void* user_data) + { + if ( + registry == nullptr || registry->registry == nullptr || + !is_valid_utf8(path) || path.len == 0 || !is_valid_utf8(method) || + method.len == 0 || callback == nullptr || + (auth != CCF_RUST_AUTH_NONE && auth != CCF_RUST_AUTH_USER_CERT) || + (read_only != 0 && read_only != 1)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + + try + { + auto state = + std::make_shared(callback, drop, user_data); + registry->registry->add_endpoint( + to_string(path), + ccf::RESTVerb(to_string(method)), + auth, + read_only == 1, + state); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_request_body( + ccf_rust_endpoint_context* ctx, ccf_rust_slice* body) + { + if (ctx == nullptr || body == nullptr) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + set_slice(body, ctx->rpc->get_request_body()); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_request_query( + ccf_rust_endpoint_context* ctx, ccf_rust_slice* query) + { + if (ctx == nullptr || query == nullptr) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + set_slice(query, ctx->rpc->get_request_query()); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_request_path_param( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice name, + ccf_rust_slice* value) + { + if (ctx == nullptr || value == nullptr || !is_valid_utf8(name)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + const auto& params = ctx->rpc->get_decoded_request_path_params(); + const auto it = params.find(to_string(name)); + if (it == params.end()) + { + return CCF_RUST_NOT_FOUND; + } + ctx->scratch.assign(it->second.begin(), it->second.end()); + set_slice(value, ctx->scratch); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_request_header( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice name, + ccf_rust_slice* value) + { + if (ctx == nullptr || value == nullptr || !is_valid_utf8(name)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + const auto header = ctx->rpc->get_request_header(to_string(name)); + if (!header.has_value()) + { + return CCF_RUST_NOT_FOUND; + } + ctx->scratch.assign(header->begin(), header->end()); + set_slice(value, ctx->scratch); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_response_status( + ccf_rust_endpoint_context* ctx, uint16_t status) + { + if (ctx == nullptr || status < 100 || status > 599) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + ctx->rpc->set_response_status(status); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_response_header( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice name, + ccf_rust_slice value) + { + if ( + ctx == nullptr || !is_valid_utf8(name) || name.len == 0 || + !is_valid_utf8(value)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + ctx->rpc->set_response_header(to_string(name), to_string(value)); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_response_body( + ccf_rust_endpoint_context* ctx, ccf_rust_slice body) + { + if (ctx == nullptr || !is_valid_buffer(body)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + ctx->rpc->set_response_body(to_bytes(body)); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_response_error( + ccf_rust_endpoint_context* ctx, + uint16_t status, + ccf_rust_slice code, + ccf_rust_slice message) + { + if ( + ctx == nullptr || status < 400 || status > 599 || + !is_valid_utf8(code) || code.len == 0 || !is_valid_utf8(message)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + ctx->rpc->set_error( + static_cast(status), + to_string(code), + to_string(message)); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_kv_get( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + ccf_rust_slice* value) + { + if ( + ctx == nullptr || value == nullptr || !is_valid_utf8(map_name) || + map_name.len == 0 || !is_valid_buffer(key)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + const auto result = + ctx->read_handle(to_string(map_name))->get(to_bytes(key)); + if (!result.has_value()) + { + return CCF_RUST_NOT_FOUND; + } + ctx->scratch = std::move(result.value()); + set_slice(value, ctx->scratch); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_kv_has( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + int* present) + { + if ( + ctx == nullptr || present == nullptr || !is_valid_utf8(map_name) || + map_name.len == 0 || !is_valid_buffer(key)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + *present = + ctx->read_handle(to_string(map_name))->has(to_bytes(key)) ? 1 : 0; + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_kv_put( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + ccf_rust_slice value) + { + if ( + ctx == nullptr || !is_valid_utf8(map_name) || map_name.len == 0 || + !is_valid_buffer(key) || !is_valid_buffer(value)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + auto* handle = ctx->write_handle(to_string(map_name)); + if (handle == nullptr) + { + return CCF_RUST_READ_ONLY; + } + handle->put(to_bytes(key), to_bytes(value)); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + int ccf_rust_kv_remove( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key) + { + if ( + ctx == nullptr || !is_valid_utf8(map_name) || map_name.len == 0 || + !is_valid_buffer(key)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + auto* handle = ctx->write_handle(to_string(map_name)); + if (handle == nullptr) + { + return CCF_RUST_READ_ONLY; + } + handle->remove(to_bytes(key)); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } +} + +namespace ccf +{ + std::unique_ptr make_user_endpoints( + ccf::AbstractNodeContext& context) + { + return std::make_unique(context); + } +} diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 83a1476d5c74..77d03edc17f5 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -1,4 +1,716 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. +//! Minimal Rust API for native CCF applications. +//! +//! Endpoint handlers may execute concurrently and must therefore be `Send` and +//! `Sync`. Request, response, transaction, and map objects are borrowed for one +//! callback invocation and cannot be retained. + +use std::ffi::c_void; +use std::marker::PhantomData; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr::NonNull; +use std::slice; + pub use cose_rs; + +pub const ABI_VERSION: u32 = 1; + +#[repr(C)] +pub struct RawRegistry { + _private: [u8; 0], +} + +#[repr(C)] +pub struct RawEndpointContext { + _private: [u8; 0], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawSlice { + data: *const u8, + len: usize, +} + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RawResult { + Ok = 0, + NotFound = 1, + InvalidArgument = 2, + ReadOnly = 3, + InternalError = 4, +} + +#[repr(i32)] +#[derive(Clone, Copy)] +enum RawAuth { + None = 0, + UserCert = 1, +} + +type RawHandler = unsafe extern "C" fn(*mut c_void, *mut RawEndpointContext) -> i32; +type RawDrop = unsafe extern "C" fn(*mut c_void); + +#[cfg(not(test))] +mod ffi { + use super::*; + + unsafe extern "C" { + pub fn ccf_rust_get_abi_version() -> u32; + pub fn ccf_rust_register_endpoint( + registry: *mut RawRegistry, + path: RawSlice, + method: RawSlice, + auth: RawAuth, + read_only: i32, + callback: RawHandler, + drop: RawDrop, + user_data: *mut c_void, + ) -> i32; + pub fn ccf_rust_request_body(ctx: *mut RawEndpointContext, body: *mut RawSlice) -> i32; + pub fn ccf_rust_request_query(ctx: *mut RawEndpointContext, query: *mut RawSlice) -> i32; + pub fn ccf_rust_request_path_param( + ctx: *mut RawEndpointContext, + name: RawSlice, + value: *mut RawSlice, + ) -> i32; + pub fn ccf_rust_request_header( + ctx: *mut RawEndpointContext, + name: RawSlice, + value: *mut RawSlice, + ) -> i32; + pub fn ccf_rust_response_status(ctx: *mut RawEndpointContext, status: u16) -> i32; + pub fn ccf_rust_response_header( + ctx: *mut RawEndpointContext, + name: RawSlice, + value: RawSlice, + ) -> i32; + pub fn ccf_rust_response_body(ctx: *mut RawEndpointContext, body: RawSlice) -> i32; + pub fn ccf_rust_response_error( + ctx: *mut RawEndpointContext, + status: u16, + code: RawSlice, + message: RawSlice, + ) -> i32; + pub fn ccf_rust_kv_get( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + value: *mut RawSlice, + ) -> i32; + pub fn ccf_rust_kv_has( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + present: *mut i32, + ) -> i32; + pub fn ccf_rust_kv_put( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + value: RawSlice, + ) -> i32; + pub fn ccf_rust_kv_remove( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + ) -> i32; + } +} + +#[cfg(test)] +mod ffi { + use super::*; + + pub unsafe extern "C" fn ccf_rust_get_abi_version() -> u32 { + ABI_VERSION + } + + pub unsafe extern "C" fn ccf_rust_register_endpoint( + _registry: *mut RawRegistry, + _path: RawSlice, + _method: RawSlice, + _auth: RawAuth, + _read_only: i32, + _callback: RawHandler, + _drop: RawDrop, + _user_data: *mut c_void, + ) -> i32 { + RawResult::InternalError as i32 + } + + macro_rules! failing_ffi { + ($name:ident($($arg:ident: $ty:ty),*)) => { + pub unsafe extern "C" fn $name($($arg: $ty),*) -> i32 { + $(let _ = $arg;)* + RawResult::InternalError as i32 + } + }; + } + + failing_ffi!(ccf_rust_request_body(ctx: *mut RawEndpointContext, body: *mut RawSlice)); + failing_ffi!(ccf_rust_request_query(ctx: *mut RawEndpointContext, query: *mut RawSlice)); + failing_ffi!(ccf_rust_request_path_param(ctx: *mut RawEndpointContext, name: RawSlice, value: *mut RawSlice)); + failing_ffi!(ccf_rust_request_header(ctx: *mut RawEndpointContext, name: RawSlice, value: *mut RawSlice)); + failing_ffi!(ccf_rust_response_status(ctx: *mut RawEndpointContext, status: u16)); + failing_ffi!(ccf_rust_response_header(ctx: *mut RawEndpointContext, name: RawSlice, value: RawSlice)); + failing_ffi!(ccf_rust_response_body(ctx: *mut RawEndpointContext, body: RawSlice)); + failing_ffi!(ccf_rust_response_error(ctx: *mut RawEndpointContext, status: u16, code: RawSlice, message: RawSlice)); + failing_ffi!(ccf_rust_kv_get(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, value: *mut RawSlice)); + failing_ffi!(ccf_rust_kv_has(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, present: *mut i32)); + failing_ffi!(ccf_rust_kv_put(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, value: RawSlice)); + failing_ffi!(ccf_rust_kv_remove(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice)); +} + +fn raw_slice(value: &[u8]) -> RawSlice { + RawSlice { + data: value.as_ptr(), + len: value.len(), + } +} + +fn raw_str(value: &str) -> RawSlice { + raw_slice(value.as_bytes()) +} + +fn decode_result(result: i32) -> Result<(), BridgeError> { + match result { + value if value == RawResult::Ok as i32 => Ok(()), + value if value == RawResult::NotFound as i32 => Err(BridgeError::NotFound), + value if value == RawResult::InvalidArgument as i32 => Err(BridgeError::InvalidArgument), + value if value == RawResult::ReadOnly as i32 => Err(BridgeError::ReadOnly), + _ => Err(BridgeError::Internal), + } +} + +unsafe fn borrowed_slice<'a>(value: RawSlice) -> &'a [u8] { + if value.len == 0 { + &[] + } else { + // SAFETY: The C++ bridge guarantees that successful output slices are + // valid until the next bridge call on this callback context. + unsafe { slice::from_raw_parts(value.data, value.len) } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BridgeError { + NotFound, + InvalidArgument, + ReadOnly, + Internal, + AbiMismatch, +} + +pub type BridgeResult = Result; + +#[derive(Clone, Copy, Debug)] +pub enum Auth { + None, + UserCert, +} + +impl Auth { + fn raw(self) -> RawAuth { + match self { + Self::None => RawAuth::None, + Self::UserCert => RawAuth::UserCert, + } + } +} + +#[derive(Clone, Debug)] +pub struct EndpointError { + pub status: u16, + pub code: String, + pub message: String, +} + +impl EndpointError { + pub fn new(status: u16, code: impl Into, message: impl Into) -> Self { + Self { + status, + code: code.into(), + message: message.into(), + } + } + + pub fn internal(message: impl Into) -> Self { + Self::new(500, "InternalError", message) + } +} + +impl From for EndpointError { + fn from(error: BridgeError) -> Self { + Self::internal(format!("CCF bridge error: {error:?}")) + } +} + +pub type EndpointResult = Result<(), EndpointError>; + +pub trait Codec { + type Error; + + fn encode(value: &T) -> Result, Self::Error>; + fn decode(value: &[u8]) -> Result; +} + +struct Context<'a> { + raw: NonNull, + _lifetime: PhantomData<&'a mut RawEndpointContext>, +} + +impl Context<'_> { + fn body(&self) -> BridgeResult<&[u8]> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw is valid for the handler callback and value is writable. + decode_result(unsafe { ffi::ccf_rust_request_body(self.raw.as_ptr(), &mut value) })?; + // SAFETY: The returned body is owned by the request and outlives self. + Ok(unsafe { borrowed_slice(value) }) + } + + fn query(&self) -> BridgeResult<&str> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw is valid for the handler callback and value is writable. + decode_result(unsafe { ffi::ccf_rust_request_query(self.raw.as_ptr(), &mut value) })?; + // SAFETY: The returned query is owned by the request and outlives self. + let bytes = unsafe { borrowed_slice(value) }; + std::str::from_utf8(bytes).map_err(|_| BridgeError::Internal) + } + + fn copied_optional( + &mut self, + name: &str, + get: unsafe extern "C" fn(*mut RawEndpointContext, RawSlice, *mut RawSlice) -> i32, + ) -> BridgeResult>> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw is valid for the callback and all pointers remain valid + // for this call. + match decode_result(unsafe { get(self.raw.as_ptr(), raw_str(name), &mut value) }) { + Ok(()) => { + // SAFETY: The bridge returned a valid scratch slice. + Ok(Some(unsafe { borrowed_slice(value) }.to_vec())) + } + Err(BridgeError::NotFound) => Ok(None), + Err(error) => Err(error), + } + } + + fn path_param(&mut self, name: &str) -> BridgeResult> { + self.copied_optional(name, ffi::ccf_rust_request_path_param)? + .map(|value| String::from_utf8(value).map_err(|_| BridgeError::Internal)) + .transpose() + } + + fn header(&mut self, name: &str) -> BridgeResult>> { + self.copied_optional(name, ffi::ccf_rust_request_header) + } + + fn set_status(&mut self, status: u16) -> BridgeResult<()> { + // SAFETY: raw is valid for the callback. + decode_result(unsafe { ffi::ccf_rust_response_status(self.raw.as_ptr(), status) }) + } + + fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { + // SAFETY: raw and both strings are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_response_header(self.raw.as_ptr(), raw_str(name), raw_str(value)) + }) + } + + fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { + // SAFETY: raw and body are valid for this call. + decode_result(unsafe { ffi::ccf_rust_response_body(self.raw.as_ptr(), raw_slice(body)) }) + } + + fn set_error(&mut self, error: &EndpointError) -> BridgeResult<()> { + // SAFETY: raw and all strings are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_response_error( + self.raw.as_ptr(), + error.status, + raw_str(&error.code), + raw_str(&error.message), + ) + }) + } + + fn get(&mut self, map_name: &str, key: &[u8]) -> BridgeResult>> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw and input buffers are valid for this call. + match decode_result(unsafe { + ffi::ccf_rust_kv_get( + self.raw.as_ptr(), + raw_str(map_name), + raw_slice(key), + &mut value, + ) + }) { + Ok(()) => { + // SAFETY: The bridge returned a valid scratch slice. + Ok(Some(unsafe { borrowed_slice(value) }.to_vec())) + } + Err(BridgeError::NotFound) => Ok(None), + Err(error) => Err(error), + } + } + + fn has(&mut self, map_name: &str, key: &[u8]) -> BridgeResult { + let mut present = 0; + // SAFETY: raw and input buffers are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_kv_has( + self.raw.as_ptr(), + raw_str(map_name), + raw_slice(key), + &mut present, + ) + })?; + Ok(present != 0) + } + + fn put(&mut self, map_name: &str, key: &[u8], value: &[u8]) -> BridgeResult<()> { + // SAFETY: raw and input buffers are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_kv_put( + self.raw.as_ptr(), + raw_str(map_name), + raw_slice(key), + raw_slice(value), + ) + }) + } + + fn remove(&mut self, map_name: &str, key: &[u8]) -> BridgeResult<()> { + // SAFETY: raw and input buffers are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_kv_remove(self.raw.as_ptr(), raw_str(map_name), raw_slice(key)) + }) + } +} + +pub struct ReadOnlyContext<'a>(Context<'a>); + +impl<'ctx> ReadOnlyContext<'ctx> { + pub fn body(&self) -> BridgeResult<&[u8]> { + self.0.body() + } + + pub fn query(&self) -> BridgeResult<&str> { + self.0.query() + } + + pub fn path_param(&mut self, name: &str) -> BridgeResult> { + self.0.path_param(name) + } + + pub fn header(&mut self, name: &str) -> BridgeResult>> { + self.0.header(name) + } + + pub fn set_status(&mut self, status: u16) -> BridgeResult<()> { + self.0.set_status(status) + } + + pub fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { + self.0.set_header(name, value) + } + + pub fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { + self.0.set_body(body) + } + + pub fn map<'a>(&'a mut self, name: &'a str) -> ReadOnlyMap<'a, 'ctx> { + ReadOnlyMap { + context: &mut self.0, + name, + } + } +} + +pub struct WriteContext<'a>(Context<'a>); + +impl<'ctx> WriteContext<'ctx> { + pub fn body(&self) -> BridgeResult<&[u8]> { + self.0.body() + } + + pub fn query(&self) -> BridgeResult<&str> { + self.0.query() + } + + pub fn path_param(&mut self, name: &str) -> BridgeResult> { + self.0.path_param(name) + } + + pub fn header(&mut self, name: &str) -> BridgeResult>> { + self.0.header(name) + } + + pub fn set_status(&mut self, status: u16) -> BridgeResult<()> { + self.0.set_status(status) + } + + pub fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { + self.0.set_header(name, value) + } + + pub fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { + self.0.set_body(body) + } + + pub fn map<'a>(&'a mut self, name: &'a str) -> Map<'a, 'ctx> { + Map { + context: &mut self.0, + name, + } + } +} + +pub struct ReadOnlyMap<'a, 'ctx> { + context: &'a mut Context<'ctx>, + name: &'a str, +} + +impl ReadOnlyMap<'_, '_> { + pub fn get(&mut self, key: &[u8]) -> BridgeResult>> { + self.context.get(self.name, key) + } + + pub fn has(&mut self, key: &[u8]) -> BridgeResult { + self.context.has(self.name, key) + } +} + +pub struct Map<'a, 'ctx> { + context: &'a mut Context<'ctx>, + name: &'a str, +} + +impl Map<'_, '_> { + pub fn get(&mut self, key: &[u8]) -> BridgeResult>> { + self.context.get(self.name, key) + } + + pub fn has(&mut self, key: &[u8]) -> BridgeResult { + self.context.has(self.name, key) + } + + pub fn put(&mut self, key: &[u8], value: &[u8]) -> BridgeResult<()> { + self.context.put(self.name, key, value) + } + + pub fn remove(&mut self, key: &[u8]) -> BridgeResult<()> { + self.context.remove(self.name, key) + } +} + +type ReadHandler = + dyn for<'a> Fn(&mut ReadOnlyContext<'a>) -> EndpointResult + Send + Sync + 'static; +type WriteHandler = dyn for<'a> Fn(&mut WriteContext<'a>) -> EndpointResult + Send + Sync + 'static; + +enum Handler { + Read(Box), + Write(Box), +} + +unsafe extern "C" fn invoke_handler( + user_data: *mut c_void, + raw_context: *mut RawEndpointContext, +) -> i32 { + if user_data.is_null() || raw_context.is_null() { + return RawResult::InvalidArgument as i32; + } + + // SAFETY: The registry owns this Handler until it invokes drop_handler. + let handler = unsafe { &*(user_data.cast::()) }; + let raw = match NonNull::new(raw_context) { + Some(raw) => raw, + None => return RawResult::InvalidArgument as i32, + }; + + let result = catch_unwind(AssertUnwindSafe(|| match handler { + Handler::Read(handler) => handler(&mut ReadOnlyContext(Context { + raw, + _lifetime: PhantomData, + })), + Handler::Write(handler) => handler(&mut WriteContext(Context { + raw, + _lifetime: PhantomData, + })), + })); + + let endpoint_error = match result { + Ok(Ok(())) => return RawResult::Ok as i32, + Ok(Err(error)) => error, + Err(_) => EndpointError::internal("Rust endpoint panicked"), + }; + + let mut context = Context { + raw, + _lifetime: PhantomData, + }; + match context.set_error(&endpoint_error) { + Ok(()) => RawResult::Ok as i32, + Err(_) => RawResult::InternalError as i32, + } +} + +unsafe extern "C" fn drop_handler(user_data: *mut c_void) { + if !user_data.is_null() { + let _ = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: The pointer was created by Box::into_raw during endpoint + // registration and is dropped exactly once by the C++ registry. + drop(unsafe { Box::from_raw(user_data.cast::()) }); + })); + } +} + +pub struct Registry { + raw: NonNull, +} + +impl Registry { + /// # Safety + /// + /// `raw` must point to the live C++ registry passed to + /// `ccf_rust_app_register` and may not outlive that call. + pub unsafe fn from_raw(raw: *mut RawRegistry) -> BridgeResult { + if unsafe { ffi::ccf_rust_get_abi_version() } != ABI_VERSION { + return Err(BridgeError::AbiMismatch); + } + NonNull::new(raw) + .map(|raw| Self { raw }) + .ok_or(BridgeError::InvalidArgument) + } + + pub fn read_only( + &mut self, + path: &str, + method: &str, + auth: Auth, + handler: F, + ) -> BridgeResult<()> + where + F: for<'a> Fn(&mut ReadOnlyContext<'a>) -> EndpointResult + Send + Sync + 'static, + { + self.register(path, method, auth, Handler::Read(Box::new(handler))) + } + + pub fn read_write( + &mut self, + path: &str, + method: &str, + auth: Auth, + handler: F, + ) -> BridgeResult<()> + where + F: for<'a> Fn(&mut WriteContext<'a>) -> EndpointResult + Send + Sync + 'static, + { + self.register(path, method, auth, Handler::Write(Box::new(handler))) + } + + fn register( + &mut self, + path: &str, + method: &str, + auth: Auth, + handler: Handler, + ) -> BridgeResult<()> { + let read_only = matches!(handler, Handler::Read(_)) as i32; + let user_data = Box::into_raw(Box::new(handler)).cast::(); + // SAFETY: All inputs are valid for this call. Ownership of user_data is + // transferred only when registration succeeds. + let result = unsafe { + ffi::ccf_rust_register_endpoint( + self.raw.as_ptr(), + raw_str(path), + raw_str(method), + auth.raw(), + read_only, + invoke_handler, + drop_handler, + user_data, + ) + }; + if let Err(error) = decode_result(result) { + // SAFETY: Registration failed, so C++ did not retain user_data. + unsafe { drop_handler(user_data) }; + return Err(error); + } + Ok(()) + } +} + +#[macro_export] +macro_rules! export_app { + ($register:path) => { + #[unsafe(no_mangle)] + pub extern "C" fn ccf_rust_app_abi_version() -> u32 { + $crate::ABI_VERSION + } + + #[unsafe(no_mangle)] + pub extern "C" fn ccf_rust_app_register(raw_registry: *mut $crate::RawRegistry) -> i32 { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: The C++ bridge passes a live registry for this call. + let mut registry = unsafe { $crate::Registry::from_raw(raw_registry) }?; + $register(&mut registry) + })); + match result { + Ok(Ok(())) => 0, + _ => 4, + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_raw_result_codes() { + assert_eq!(decode_result(0), Ok(())); + assert_eq!(decode_result(1), Err(BridgeError::NotFound)); + assert_eq!(decode_result(2), Err(BridgeError::InvalidArgument)); + assert_eq!(decode_result(3), Err(BridgeError::ReadOnly)); + assert_eq!(decode_result(99), Err(BridgeError::Internal)); + } + + #[test] + fn rejects_null_registry() { + // SAFETY: This intentionally exercises null validation. + assert!(matches!( + unsafe { Registry::from_raw(std::ptr::null_mut()) }, + Err(BridgeError::InvalidArgument) + )); + } + + #[test] + fn contains_handler_panics() { + let handler = Box::new(Handler::Write(Box::new(|_| panic!("test panic")))); + let user_data = Box::into_raw(handler).cast::(); + let raw_context = NonNull::::dangling().as_ptr(); + // SAFETY: Both pointers are valid for this direct trampoline test. + let result = unsafe { invoke_handler(user_data, raw_context) }; + assert_eq!(result, RawResult::InternalError as i32); + // SAFETY: The test retains ownership of the handler. + unsafe { drop_handler(user_data) }; + } +} From fdd861ab95b2448bacff6f1cf1ef8c42ec43e0f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:22:40 +0000 Subject: [PATCH 02/19] Add Rust sample tests and documentation Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- CHANGELOG.md | 1 + CMakeLists.txt | 7 ++++ doc/build_apps/example_rust.rst | 66 +++++++++++++++++++++++++++++++++ doc/build_apps/get_started.rst | 9 ++++- doc/build_apps/index.rst | 10 ++++- src/rust/app_bridge.cpp | 28 +++++++++----- tests/basic_rust.py | 51 +++++++++++++++++++++++++ 7 files changed, 160 insertions(+), 12 deletions(-) create mode 100644 doc/build_apps/example_rust.rst create mode 100644 tests/basic_rust.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 126fdff584c8..696dcc100149 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - C++ endpoints can now use `ccf::endpoints::Endpoint::add_openapi_response()` to document additional HTTP responses in their generated OpenAPI schema without changing the endpoint's primary success response (#8115). - New `ledger.max_transaction_size` node configuration option (default `32MB`), which caps the total serialised size of transactions written to the ledger. The limit covers the whole ledger entry: the fixed 8-byte ledger entry header, the ledger encryption header, public domain size field, public domain and encrypted private domain. It is checked before a transaction is applied, so an oversized transaction is now rejected with `413 Payload Too Large` and error code `TransactionTooLarge`, and subsequent transactions are unaffected, where previously an excessively large transaction could terminate the node. Reserved internal signature transactions are exempt because they must fill their reserved ledger version. The limit applies only to newly serialised non-reserved transactions; deserialising existing entries (including during recovery), historical queries and snapshots are unaffected, so entries written under a larger or unset limit remain readable. It must be smaller than `memory.max_msg_size` by at least the ring-buffer range response overhead, which is validated at node startup and by `--check` (#7992). +- Native CCF applications can now be written in Rust through a minimal API for registering endpoints and accessing raw-byte KV maps (#8156). ### Changed diff --git a/CMakeLists.txt b/CMakeLists.txt index a7b0a30ac6ec..5162bb543f17 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1294,6 +1294,13 @@ if(BUILD_TESTS) ADDITIONAL_ARGS --js-app-bundle ${CMAKE_SOURCE_DIR}/samples/apps/logging/js ) + add_e2e_test( + NAME basic_rust + PYTHON_SCRIPT ${CMAKE_SOURCE_DIR}/tests/basic_rust.py + BUCKET bucket_c + ADDITIONAL_ARGS --package samples/apps/basic_rust/basic_rust + ) + set( RBAC_CONSTITUTION_ARGS --constitution diff --git a/doc/build_apps/example_rust.rst b/doc/build_apps/example_rust.rst new file mode 100644 index 000000000000..1a8a6d822a53 --- /dev/null +++ b/doc/build_apps/example_rust.rst @@ -0,0 +1,66 @@ +Example app (Rust) +================== + +CCF provides an initial Rust interface for native applications. It deliberately +exposes a small subset of the public application API: + +- read-write and read-only HTTP endpoints; +- user-certificate authentication or no authentication; +- request bodies, raw queries, decoded path parameters, and named headers; +- response status, headers, body, and OData errors; and +- raw-byte KV ``get``, ``has``, ``put``, and ``remove`` operations. + +Advanced endpoint configuration, custom authentication, historical queries, +indexing, and commit callbacks are not currently exposed. + +Build +----- + +Rust 1.90 and Cargo are required. A Rust application is a ``staticlib`` crate +which depends on the installed or source-tree ``ccf-rs`` crate. Its CMake file +registers the crate with ``add_ccf_rust_app``: + +.. code-block:: cmake + + add_ccf_rust_app( + my_app + MANIFEST_PATH ${CMAKE_CURRENT_LIST_DIR}/Cargo.toml + PACKAGE my-app + ) + +The helper maps CMake ``Debug`` builds to Cargo's development profile and all +other build types to Cargo's release profile. It also links the generic C++ ABI +bridge, launcher, and CCF libraries. Cargo sources, the manifest, and the lock +file are build dependencies. The application should commit ``Cargo.lock`` and +pin a Rust toolchain for reproducible builds. + +The complete records example is in :ccf_repo:`samples/apps/basic_rust`. It +exports a registration function with ``ccf_rs::export_app!`` and registers +handlers through ``Registry::read_write`` and ``Registry::read_only``. + +Endpoint execution +------------------ + +Handlers may run concurrently and must be ``Send`` and ``Sync``. CCF may also +retry a read-write handler when a transaction conflicts, so handlers should be +deterministic and should not perform non-transactional side effects. + +Request, response, transaction, and map values borrow the callback context and +cannot be retained. Rust panics are caught at the ABI boundary and become HTTP +500 errors. C++ exceptions are also contained by the bridge. + +KV values and keys +------------------ + +The initial API treats keys and values as byte strings. Applications may layer +their own serializers on these operations; the ``Codec`` trait provides a +common interface without prescribing a wire format. + +Map names retain the standard CCF security semantics. Names beginning with +``public:`` are written to the ledger in plaintext. All other application map +names, such as the sample's ``records`` map, are private and encrypted. The +framework continues to enforce reserved governance and internal map namespaces. + +Read-only handlers receive only ``ReadOnlyMap``, so write operations are +not available at compile time. Errors returned by a handler use the normal CCF +transaction semantics: unsuccessful responses discard writes. diff --git a/doc/build_apps/get_started.rst b/doc/build_apps/get_started.rst index 2c0ef95df894..b62a831a49e4 100644 --- a/doc/build_apps/get_started.rst +++ b/doc/build_apps/get_started.rst @@ -6,7 +6,7 @@ Application Development using CCF Overview - :ref:`What is Confidential Consortium Framework (CCF) ` - Read the :doc:`CCF overview ` and get familiar with :ref:`overview/what_is_ccf:Core Concepts` and `Azure confidential computing `__ -- :doc:`Build new CCF applications ` in TypeScript/JavaScript or C++ +- :doc:`Build new CCF applications ` in TypeScript/JavaScript, C++, or Rust - CCF `JavaScript module API reference `__ - CCF application get started repos `CCF application template `__ and `CCF application samples `__ @@ -91,6 +91,13 @@ Packaging your C++ app To create distributable packages for your CCF application, create a ``cpack.cmake`` file that includes CCF's packaging configuration and add it to your ``CMakeLists.txt``. See :ccf_repo:`tests/ccfapp/CMakeLists.txt` and :ccf_repo:`tests/ccfapp/cpack.cmake` for a complete working example. +Rust Applications +----------------- + +Rust applications are native CCF executables with the same deployment model as +C++ applications. See :doc:`example_rust` for the supported API and build +instructions. + Network Governance ------------------ diff --git a/doc/build_apps/index.rst b/doc/build_apps/index.rst index 47147bcd426c..4d41b11de8f3 100644 --- a/doc/build_apps/index.rst +++ b/doc/build_apps/index.rst @@ -5,7 +5,7 @@ This section describes how CCF applications can be developed and deployed to a C .. tip:: The `ccf-app-template `_ repository can be used to quickly build and run a sample CCF application and provides a minimal template to create new CCF apps. -Applications can be written in JavaScript/TypeScript or C++. An application consists of a collection of endpoints that can be triggered by :term:`Users`. Each endpoint can define an :ref:`build_apps/example_cpp:API Schema` to validate user requests. +Applications can be written in JavaScript/TypeScript, C++, or Rust. An application consists of a collection of endpoints that can be triggered by :term:`Users`. Each endpoint can define an :ref:`build_apps/example_cpp:API Schema` to validate user requests. These endpoints can read or mutate the state of a unique :ref:`build_apps/kv/index:Key-Value Store` that represents the internal state of the application. Applications define a set of ``Maps`` (see :doc:`kv/kv_how_to`), mapping from a key to a value. When an application endpoint is triggered, the effects on the Store are committed atomically. @@ -37,6 +37,13 @@ These endpoints can read or mutate the state of a unique :ref:`build_apps/kv/ind --- + :fa:`gear` :doc:`example_rust` + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + Minimal native CCF application written in Rust. + + --- + .. image:: ../img/ts.svg :alt: TypeScript :align: left @@ -110,6 +117,7 @@ These endpoints can read or mutate the state of a unique :ref:`build_apps/kv/ind get_started install_bin example + example_rust js_app_ts js_app_bundle logging diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index 8f46ff90e8af..26ec6ca4bced 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -101,7 +101,7 @@ namespace return {reinterpret_cast(value.data), value.len}; } - std::vector to_bytes(const ccf_rust_slice& value) + RawMap::Handle::KeyType to_bytes(const ccf_rust_slice& value) { if (value.len == 0) { @@ -110,13 +110,17 @@ namespace return {value.data, value.data + value.len}; } - void set_slice(ccf_rust_slice* out, const std::vector& value) + std::vector to_vector(const ccf_rust_slice& value) { - out->data = value.data(); - out->len = value.size(); + if (value.len == 0) + { + return {}; + } + return {value.data, value.data + value.len}; } - void set_slice(ccf_rust_slice* out, const std::string& value) + template + void set_slice(ccf_rust_slice* out, const T& value) { out->data = reinterpret_cast(value.data()); out->len = value.size(); @@ -127,10 +131,11 @@ namespace ccf_rust_endpoint_callback callback; ccf_rust_drop_callback drop; void* user_data; + bool owns_user_data = false; ~CallbackState() { - if (drop != nullptr) + if (owns_user_data && drop != nullptr) { drop(user_data); } @@ -150,7 +155,7 @@ struct ccf_rust_endpoint_context ccf::kv::Tx* writable_tx; std::unordered_map read_handles; std::unordered_map write_handles; - std::vector scratch; + RawMap::Handle::ValueType scratch; RawMap::ReadOnlyHandle* read_handle(const std::string& map_name) { @@ -333,6 +338,7 @@ extern "C" auth, read_only == 1, state); + state->owns_user_data = true; return CCF_RUST_OK; } catch (...) @@ -394,7 +400,8 @@ extern "C" { return CCF_RUST_NOT_FOUND; } - ctx->scratch.assign(it->second.begin(), it->second.end()); + ctx->scratch.clear(); + ctx->scratch.append(it->second.begin(), it->second.end()); set_slice(value, ctx->scratch); return CCF_RUST_OK; } @@ -420,7 +427,8 @@ extern "C" { return CCF_RUST_NOT_FOUND; } - ctx->scratch.assign(header->begin(), header->end()); + ctx->scratch.clear(); + ctx->scratch.append(header->begin(), header->end()); set_slice(value, ctx->scratch); return CCF_RUST_OK; } @@ -479,7 +487,7 @@ extern "C" } try { - ctx->rpc->set_response_body(to_bytes(body)); + ctx->rpc->set_response_body(to_vector(body)); return CCF_RUST_OK; } catch (...) diff --git a/tests/basic_rust.py b/tests/basic_rust.py new file mode 100644 index 000000000000..8dbb4eb5c833 --- /dev/null +++ b/tests/basic_rust.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +import http + +import infra.e2e_args +import infra.network +import suite.test_requirements as reqs + + +@reqs.description("Exercise Rust application endpoints and KV access") +@reqs.supports_methods("/app/health", "/app/records/{key}") +def test_basic_rust(network, args): + primary, _ = network.find_primary() + + with primary.client() as anonymous: + response = anonymous.get("/app/health") + assert response.status_code == http.HTTPStatus.OK, response + assert response.body.data() == b"OK", response.body + + response = anonymous.get("/app/records/missing") + assert response.status_code == http.HTTPStatus.UNAUTHORIZED, response + + with primary.client("user0") as user: + value = b"\x00rust\xff" + response = user.put("/app/records/example", body=value) + assert response.status_code == http.HTTPStatus.NO_CONTENT, response + + response = user.get("/app/records/example") + assert response.status_code == http.HTTPStatus.OK, response + assert response.body.data() == value, response.body + + response = user.get("/app/records/missing") + assert response.status_code == http.HTTPStatus.NOT_FOUND, response + + return network + + +def run(args): + with infra.network.network( + args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb + ) as network: + network.start_and_open(args) + test_basic_rust(network, args) + + +if __name__ == "__main__": + args = infra.e2e_args.cli_args() + args.package = "samples/apps/basic_rust/basic_rust" + args.nodes = infra.e2e_args.min_nodes(args, f=0) + run(args) From c946e2b4b0f56239430b913cccf9e07456f1d926 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:30:19 +0000 Subject: [PATCH 03/19] Finalize Rust SDK packaging and validation fixes Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- CMakeLists.txt | 13 ++++++++++++- cmake/ccf_app.cmake | 12 +++++------- cmake/gersemi_definitions.cmake | 10 ++++++++++ doc/build_apps/example_rust.rst | 5 +++-- include/ccf/rust_ffi.h | 17 ++++++++--------- src/rust/app_bridge.cpp | 33 +++++++++++---------------------- src/rust/src/lib.rs | 4 +++- 7 files changed, 52 insertions(+), 42 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5162bb543f17..a55ed5d587b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -180,9 +180,20 @@ include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/ccf_app.cmake) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cmake/ccf_app.cmake DESTINATION cmake) install( DIRECTORY ${CCF_DIR}/src/rust/ - DESTINATION share/ccf/rust + DESTINATION share/ccf/src/rust + PATTERN target EXCLUDE +) +install( + DIRECTORY ${CCF_DIR}/src/cose/cose_rs/ + DESTINATION share/ccf/src/cose/cose_rs + PATTERN target EXCLUDE +) +install( + DIRECTORY ${CCF_DIR}/3rdparty/internal/cose-openssl/ + DESTINATION share/ccf/3rdparty/internal/cose-openssl PATTERN target EXCLUDE ) +install(FILES ${CCF_DIR}/src/rust/app_bridge.cpp DESTINATION share/ccf/rust) install( FILES ${CCF_DIR}/samples/apps/main.cpp DESTINATION share/ccf/rust diff --git a/cmake/ccf_app.cmake b/cmake/ccf_app.cmake index 10e273f91a87..ed063fd5cafd 100644 --- a/cmake/ccf_app.cmake +++ b/cmake/ccf_app.cmake @@ -90,15 +90,13 @@ function(add_ccf_rust_app name) get_filename_component(MANIFEST_PATH ${PARSED_ARGS_MANIFEST_PATH} ABSOLUTE) get_filename_component(MANIFEST_DIR ${MANIFEST_PATH} DIRECTORY) set(CARGO_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/cargo/${name}) - set(RUST_APP_LIB ${CARGO_TARGET_DIR}/${CARGO_PROFILE_DIR}/lib${RUST_LIB_NAME}.a) - - file( - GLOB_RECURSE - RUST_APP_SOURCES - CONFIGURE_DEPENDS - ${MANIFEST_DIR}/src/*.rs + set( + RUST_APP_LIB + ${CARGO_TARGET_DIR}/${CARGO_PROFILE_DIR}/lib${RUST_LIB_NAME}.a ) + file(GLOB_RECURSE RUST_APP_SOURCES CONFIGURE_DEPENDS ${MANIFEST_DIR}/src/*.rs) + set(RUSTFLAGS "--remap-path-prefix=${MANIFEST_DIR}=APP") add_custom_command( OUTPUT ${RUST_APP_LIB} diff --git a/cmake/gersemi_definitions.cmake b/cmake/gersemi_definitions.cmake index 69a0cb4852d7..d1a9579cade5 100644 --- a/cmake/gersemi_definitions.cmake +++ b/cmake/gersemi_definitions.cmake @@ -15,6 +15,16 @@ function(add_ccf_app name) ) endfunction() +function(add_ccf_rust_app name) + cmake_parse_arguments( + PARSE_ARGV 1 + PARSED_ARGS + "" + "MANIFEST_PATH;PACKAGE" + "DEPS" + ) +endfunction() + function(add_ccf_static_library name) cmake_parse_arguments(PARSE_ARGV 1 PARSED_ARGS "" "" "SRCS;LINK_LIBS") endfunction() diff --git a/doc/build_apps/example_rust.rst b/doc/build_apps/example_rust.rst index 1a8a6d822a53..69bfb59150e3 100644 --- a/doc/build_apps/example_rust.rst +++ b/doc/build_apps/example_rust.rst @@ -17,8 +17,9 @@ Build ----- Rust 1.90 and Cargo are required. A Rust application is a ``staticlib`` crate -which depends on the installed or source-tree ``ccf-rs`` crate. Its CMake file -registers the crate with ``add_ccf_rust_app``: +which depends on the source-tree ``src/rust`` crate or the installed +``share/ccf/src/rust`` crate. Its CMake file registers the crate with +``add_ccf_rust_app``: .. code-block:: cmake diff --git a/include/ccf/rust_ffi.h b/include/ccf/rust_ffi.h index efe3b9eb11e6..f0e227180791 100644 --- a/include/ccf/rust_ffi.h +++ b/include/ccf/rust_ffi.h @@ -57,19 +57,13 @@ extern "C" int ccf_rust_request_query( ccf_rust_endpoint_context* ctx, ccf_rust_slice* query); int ccf_rust_request_path_param( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice name, - ccf_rust_slice* value); + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value); int ccf_rust_request_header( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice name, - ccf_rust_slice* value); + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value); int ccf_rust_response_status(ccf_rust_endpoint_context* ctx, uint16_t status); int ccf_rust_response_header( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice name, - ccf_rust_slice value); + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice value); int ccf_rust_response_body( ccf_rust_endpoint_context* ctx, ccf_rust_slice body); int ccf_rust_response_error( @@ -103,4 +97,9 @@ extern "C" #ifdef __cplusplus } + +namespace ccf +{ + inline constexpr uint32_t rust_abi_version = CCF_RUST_ABI_VERSION; +} #endif diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index 26ec6ca4bced..3007c66136f5 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -72,10 +72,9 @@ namespace code_point = (code_point << 6) | (next & 0x3f); } - const auto minimum = - continuation_count == 1 ? 0x80u : - continuation_count == 2 ? 0x800u : - 0x10000u; + const auto minimum = continuation_count == 1 ? 0x80u : + continuation_count == 2 ? 0x800u : + 0x10000u; if ( code_point < minimum || code_point > 0x10ffff || (code_point >= 0xd800 && code_point <= 0xdfff)) @@ -330,8 +329,7 @@ extern "C" try { - auto state = - std::make_shared(callback, drop, user_data); + auto state = std::make_shared(callback, drop, user_data); registry->registry->add_endpoint( to_string(path), ccf::RESTVerb(to_string(method)), @@ -384,9 +382,7 @@ extern "C" } int ccf_rust_request_path_param( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice name, - ccf_rust_slice* value) + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value) { if (ctx == nullptr || value == nullptr || !is_valid_utf8(name)) { @@ -412,9 +408,7 @@ extern "C" } int ccf_rust_request_header( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice name, - ccf_rust_slice* value) + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value) { if (ctx == nullptr || value == nullptr || !is_valid_utf8(name)) { @@ -438,8 +432,7 @@ extern "C" } } - int ccf_rust_response_status( - ccf_rust_endpoint_context* ctx, uint16_t status) + int ccf_rust_response_status(ccf_rust_endpoint_context* ctx, uint16_t status) { if (ctx == nullptr || status < 100 || status > 599) { @@ -457,9 +450,7 @@ extern "C" } int ccf_rust_response_header( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice name, - ccf_rust_slice value) + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice value) { if ( ctx == nullptr || !is_valid_utf8(name) || name.len == 0 || @@ -503,8 +494,8 @@ extern "C" ccf_rust_slice message) { if ( - ctx == nullptr || status < 400 || status > 599 || - !is_valid_utf8(code) || code.len == 0 || !is_valid_utf8(message)) + ctx == nullptr || status < 400 || status > 599 || !is_valid_utf8(code) || + code.len == 0 || !is_valid_utf8(message)) { return CCF_RUST_INVALID_ARGUMENT; } @@ -605,9 +596,7 @@ extern "C" } int ccf_rust_kv_remove( - ccf_rust_endpoint_context* ctx, - ccf_rust_slice map_name, - ccf_rust_slice key) + ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key) { if ( ctx == nullptr || !is_valid_utf8(map_name) || map_name.len == 0 || diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 77d03edc17f5..44822408603e 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -666,7 +666,9 @@ macro_rules! export_app { } #[unsafe(no_mangle)] - pub extern "C" fn ccf_rust_app_register(raw_registry: *mut $crate::RawRegistry) -> i32 { + pub unsafe extern "C" fn ccf_rust_app_register( + raw_registry: *mut $crate::RawRegistry, + ) -> i32 { let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { // SAFETY: The C++ bridge passes a live registry for this call. let mut registry = unsafe { $crate::Registry::from_raw(raw_registry) }?; From 7a24948712605149424fe35aaf7e0659d41103f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:31:43 +0000 Subject: [PATCH 04/19] Address Rust SDK review feedback Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/rust/src/lib.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 44822408603e..39e69826b0f1 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -539,10 +539,8 @@ unsafe extern "C" fn invoke_handler( // SAFETY: The registry owns this Handler until it invokes drop_handler. let handler = unsafe { &*(user_data.cast::()) }; - let raw = match NonNull::new(raw_context) { - Some(raw) => raw, - None => return RawResult::InvalidArgument as i32, - }; + // SAFETY: The null guard above validated raw_context. + let raw = unsafe { NonNull::new_unchecked(raw_context) }; let result = catch_unwind(AssertUnwindSafe(|| match handler { Handler::Read(handler) => handler(&mut ReadOnlyContext(Context { @@ -705,7 +703,7 @@ mod tests { } #[test] - fn contains_handler_panics() { + fn panicking_handler_returns_internal_error() { let handler = Box::new(Handler::Write(Box::new(|_| panic!("test panic")))); let user_data = Box::into_raw(handler).cast::(); let raw_context = NonNull::::dangling().as_ptr(); From c2a4b41a1495438369bea802df1bc3e3e63805c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:47:15 +0000 Subject: [PATCH 05/19] Separate Rust app SDK from core Rust archive Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- doc/build_apps/example_rust.rst | 6 +- samples/apps/basic_rust/CMakeLists.txt | 2 +- samples/apps/basic_rust/Cargo.lock | 85 +-- samples/apps/basic_rust/Cargo.toml | 2 +- samples/apps/basic_rust/src/lib.rs | 4 +- src/rust/Cargo.toml | 2 +- src/rust/ccf-app/Cargo.lock | 7 + src/rust/ccf-app/Cargo.toml | 7 + src/rust/ccf-app/src/lib.rs | 724 +++++++++++++++++++++++++ src/rust/src/lib.rs | 712 ------------------------ 10 files changed, 748 insertions(+), 803 deletions(-) create mode 100644 src/rust/ccf-app/Cargo.lock create mode 100644 src/rust/ccf-app/Cargo.toml create mode 100644 src/rust/ccf-app/src/lib.rs diff --git a/doc/build_apps/example_rust.rst b/doc/build_apps/example_rust.rst index 69bfb59150e3..bb497a41b77a 100644 --- a/doc/build_apps/example_rust.rst +++ b/doc/build_apps/example_rust.rst @@ -17,8 +17,8 @@ Build ----- Rust 1.90 and Cargo are required. A Rust application is a ``staticlib`` crate -which depends on the source-tree ``src/rust`` crate or the installed -``share/ccf/src/rust`` crate. Its CMake file registers the crate with +which depends on the source-tree ``src/rust/ccf-app`` crate or the installed +``share/ccf/src/rust/ccf-app`` crate. Its CMake file registers the crate with ``add_ccf_rust_app``: .. code-block:: cmake @@ -36,7 +36,7 @@ file are build dependencies. The application should commit ``Cargo.lock`` and pin a Rust toolchain for reproducible builds. The complete records example is in :ccf_repo:`samples/apps/basic_rust`. It -exports a registration function with ``ccf_rs::export_app!`` and registers +exports a registration function with ``ccf_app::export_app!`` and registers handlers through ``Registry::read_write`` and ``Registry::read_only``. Endpoint execution diff --git a/samples/apps/basic_rust/CMakeLists.txt b/samples/apps/basic_rust/CMakeLists.txt index 13206a000bed..4601379230ff 100644 --- a/samples/apps/basic_rust/CMakeLists.txt +++ b/samples/apps/basic_rust/CMakeLists.txt @@ -15,5 +15,5 @@ add_ccf_rust_app( basic_rust MANIFEST_PATH ${CMAKE_CURRENT_LIST_DIR}/Cargo.toml PACKAGE ccf-basic-rust - DEPS ${CCF_DIR}/src/rust/src/lib.rs + DEPS ${CCF_DIR}/src/rust/ccf-app/src/lib.rs ) diff --git a/samples/apps/basic_rust/Cargo.lock b/samples/apps/basic_rust/Cargo.lock index 802803b9fb70..80b5c8c99f8c 100644 --- a/samples/apps/basic_rust/Cargo.lock +++ b/samples/apps/basic_rust/Cargo.lock @@ -3,93 +3,12 @@ version = 4 [[package]] -name = "cborrs" +name = "ccf-app" version = "0.1.0" -source = "git+https://github.com/project-everest/everparse.git?rev=950bc93838ac2faae51126d8acd0637cf8c8a569#950bc93838ac2faae51126d8acd0637cf8c8a569" - -[[package]] -name = "cborrs-nondet" -version = "0.1.0" -source = "git+https://github.com/project-everest/everparse.git?rev=950bc93838ac2faae51126d8acd0637cf8c8a569#950bc93838ac2faae51126d8acd0637cf8c8a569" - -[[package]] -name = "cc" -version = "1.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" -dependencies = [ - "find-msvc-tools", - "shlex", -] [[package]] name = "ccf-basic-rust" version = "0.1.0" dependencies = [ - "ccf-rs", -] - -[[package]] -name = "ccf-rs" -version = "0.1.0" -dependencies = [ - "cose-rs", -] - -[[package]] -name = "cose-openssl" -version = "0.1.0" -dependencies = [ - "cborrs", - "cborrs-nondet", - "openssl-sys", -] - -[[package]] -name = "cose-rs" -version = "0.1.0" -dependencies = [ - "cose-openssl", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "ccf-app", ] - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" diff --git a/samples/apps/basic_rust/Cargo.toml b/samples/apps/basic_rust/Cargo.toml index b2c5e9aa113f..07c56e3c0431 100644 --- a/samples/apps/basic_rust/Cargo.toml +++ b/samples/apps/basic_rust/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" crate-type = ["staticlib"] [dependencies] -ccf-rs = { path = "../../../src/rust" } +ccf-app = { path = "../../../src/rust/ccf-app" } [profile.release] lto = true diff --git a/samples/apps/basic_rust/src/lib.rs b/samples/apps/basic_rust/src/lib.rs index 34f75a237fb2..248904b89ea2 100644 --- a/samples/apps/basic_rust/src/lib.rs +++ b/samples/apps/basic_rust/src/lib.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. -use ccf_rs::{Auth, BridgeError, EndpointError, EndpointResult, Registry}; +use ccf_app::{Auth, BridgeError, EndpointError, EndpointResult, Registry}; const RECORDS: &str = "records"; @@ -50,4 +50,4 @@ fn register(registry: &mut Registry) -> Result<(), BridgeError> { Ok(()) } -ccf_rs::export_app!(register); +ccf_app::export_app!(register); diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 8b9c39fea731..06c5c8d89a51 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2024" [lib] -crate-type = ["rlib", "staticlib"] +crate-type = ["staticlib"] [dependencies] cose-rs = { path = "../cose/cose_rs" } diff --git a/src/rust/ccf-app/Cargo.lock b/src/rust/ccf-app/Cargo.lock new file mode 100644 index 000000000000..40c5d7703a86 --- /dev/null +++ b/src/rust/ccf-app/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ccf-app" +version = "0.1.0" diff --git a/src/rust/ccf-app/Cargo.toml b/src/rust/ccf-app/Cargo.toml new file mode 100644 index 000000000000..b0f0519cd228 --- /dev/null +++ b/src/rust/ccf-app/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "ccf-app" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib"] diff --git a/src/rust/ccf-app/src/lib.rs b/src/rust/ccf-app/src/lib.rs new file mode 100644 index 000000000000..053a11f08ef4 --- /dev/null +++ b/src/rust/ccf-app/src/lib.rs @@ -0,0 +1,724 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +//! Minimal Rust API for native CCF applications. +//! +//! Endpoint handlers may execute concurrently and must therefore be `Send` and +//! `Sync`. Request, response, transaction, and map objects are borrowed for one +//! callback invocation and cannot be retained. + +use std::ffi::c_void; +use std::marker::PhantomData; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr::NonNull; +use std::slice; + +pub const ABI_VERSION: u32 = 1; + +#[repr(C)] +pub struct RawRegistry { + _private: [u8; 0], +} + +#[repr(C)] +pub struct RawEndpointContext { + _private: [u8; 0], +} + +#[repr(C)] +#[derive(Clone, Copy)] +struct RawSlice { + data: *const u8, + len: usize, +} + +#[repr(i32)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum RawResult { + Ok = 0, + NotFound = 1, + InvalidArgument = 2, + ReadOnly = 3, + InternalError = 4, +} + +#[repr(i32)] +#[derive(Clone, Copy)] +enum RawAuth { + None = 0, + UserCert = 1, +} + +type RawHandler = unsafe extern "C" fn(*mut c_void, *mut RawEndpointContext) -> i32; +type RawDrop = unsafe extern "C" fn(*mut c_void); + +#[cfg(not(test))] +mod ffi { + use super::*; + + unsafe extern "C" { + pub fn ccf_rust_get_abi_version() -> u32; + pub fn ccf_rust_register_endpoint( + registry: *mut RawRegistry, + path: RawSlice, + method: RawSlice, + auth: RawAuth, + read_only: i32, + callback: RawHandler, + drop: RawDrop, + user_data: *mut c_void, + ) -> i32; + pub fn ccf_rust_request_body(ctx: *mut RawEndpointContext, body: *mut RawSlice) -> i32; + pub fn ccf_rust_request_query(ctx: *mut RawEndpointContext, query: *mut RawSlice) -> i32; + pub fn ccf_rust_request_path_param( + ctx: *mut RawEndpointContext, + name: RawSlice, + value: *mut RawSlice, + ) -> i32; + pub fn ccf_rust_request_header( + ctx: *mut RawEndpointContext, + name: RawSlice, + value: *mut RawSlice, + ) -> i32; + pub fn ccf_rust_response_status(ctx: *mut RawEndpointContext, status: u16) -> i32; + pub fn ccf_rust_response_header( + ctx: *mut RawEndpointContext, + name: RawSlice, + value: RawSlice, + ) -> i32; + pub fn ccf_rust_response_body(ctx: *mut RawEndpointContext, body: RawSlice) -> i32; + pub fn ccf_rust_response_error( + ctx: *mut RawEndpointContext, + status: u16, + code: RawSlice, + message: RawSlice, + ) -> i32; + pub fn ccf_rust_kv_get( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + value: *mut RawSlice, + ) -> i32; + pub fn ccf_rust_kv_has( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + present: *mut i32, + ) -> i32; + pub fn ccf_rust_kv_put( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + value: RawSlice, + ) -> i32; + pub fn ccf_rust_kv_remove( + ctx: *mut RawEndpointContext, + map_name: RawSlice, + key: RawSlice, + ) -> i32; + } +} + +#[cfg(test)] +mod ffi { + use super::*; + + pub unsafe extern "C" fn ccf_rust_get_abi_version() -> u32 { + ABI_VERSION + } + + pub unsafe extern "C" fn ccf_rust_register_endpoint( + _registry: *mut RawRegistry, + _path: RawSlice, + _method: RawSlice, + _auth: RawAuth, + _read_only: i32, + _callback: RawHandler, + _drop: RawDrop, + _user_data: *mut c_void, + ) -> i32 { + RawResult::InternalError as i32 + } + + macro_rules! failing_ffi { + ($name:ident($($arg:ident: $ty:ty),*)) => { + pub unsafe extern "C" fn $name($($arg: $ty),*) -> i32 { + $(let _ = $arg;)* + RawResult::InternalError as i32 + } + }; + } + + failing_ffi!(ccf_rust_request_body(ctx: *mut RawEndpointContext, body: *mut RawSlice)); + failing_ffi!(ccf_rust_request_query(ctx: *mut RawEndpointContext, query: *mut RawSlice)); + failing_ffi!(ccf_rust_request_path_param(ctx: *mut RawEndpointContext, name: RawSlice, value: *mut RawSlice)); + failing_ffi!(ccf_rust_request_header(ctx: *mut RawEndpointContext, name: RawSlice, value: *mut RawSlice)); + failing_ffi!(ccf_rust_response_status(ctx: *mut RawEndpointContext, status: u16)); + failing_ffi!(ccf_rust_response_header(ctx: *mut RawEndpointContext, name: RawSlice, value: RawSlice)); + failing_ffi!(ccf_rust_response_body(ctx: *mut RawEndpointContext, body: RawSlice)); + failing_ffi!(ccf_rust_response_error(ctx: *mut RawEndpointContext, status: u16, code: RawSlice, message: RawSlice)); + failing_ffi!(ccf_rust_kv_get(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, value: *mut RawSlice)); + failing_ffi!(ccf_rust_kv_has(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, present: *mut i32)); + failing_ffi!(ccf_rust_kv_put(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, value: RawSlice)); + failing_ffi!(ccf_rust_kv_remove(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice)); +} + +fn raw_slice(value: &[u8]) -> RawSlice { + RawSlice { + data: value.as_ptr(), + len: value.len(), + } +} + +fn raw_str(value: &str) -> RawSlice { + raw_slice(value.as_bytes()) +} + +fn decode_result(result: i32) -> Result<(), BridgeError> { + match result { + value if value == RawResult::Ok as i32 => Ok(()), + value if value == RawResult::NotFound as i32 => Err(BridgeError::NotFound), + value if value == RawResult::InvalidArgument as i32 => Err(BridgeError::InvalidArgument), + value if value == RawResult::ReadOnly as i32 => Err(BridgeError::ReadOnly), + _ => Err(BridgeError::Internal), + } +} + +unsafe fn borrowed_slice<'a>(value: RawSlice) -> &'a [u8] { + if value.len == 0 { + &[] + } else { + // SAFETY: The C++ bridge guarantees that successful output slices are + // valid until the next bridge call on this callback context. + unsafe { slice::from_raw_parts(value.data, value.len) } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum BridgeError { + NotFound, + InvalidArgument, + ReadOnly, + Internal, + AbiMismatch, +} + +pub type BridgeResult = Result; + +#[derive(Clone, Copy, Debug)] +pub enum Auth { + None, + UserCert, +} + +impl Auth { + fn raw(self) -> RawAuth { + match self { + Self::None => RawAuth::None, + Self::UserCert => RawAuth::UserCert, + } + } +} + +#[derive(Clone, Debug)] +pub struct EndpointError { + pub status: u16, + pub code: String, + pub message: String, +} + +impl EndpointError { + pub fn new(status: u16, code: impl Into, message: impl Into) -> Self { + Self { + status: if (400..=599).contains(&status) { + status + } else { + 500 + }, + code: code.into(), + message: message.into(), + } + } + + pub fn internal(message: impl Into) -> Self { + Self::new(500, "InternalError", message) + } +} + +impl From for EndpointError { + fn from(error: BridgeError) -> Self { + Self::internal(format!("CCF bridge error: {error:?}")) + } +} + +pub type EndpointResult = Result<(), EndpointError>; + +pub trait Codec { + type Error; + + fn encode(value: &T) -> Result, Self::Error>; + fn decode(value: &[u8]) -> Result; +} + +struct Context<'a> { + raw: NonNull, + _lifetime: PhantomData<&'a mut RawEndpointContext>, +} + +impl Context<'_> { + fn body(&self) -> BridgeResult<&[u8]> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw is valid for the handler callback and value is writable. + decode_result(unsafe { ffi::ccf_rust_request_body(self.raw.as_ptr(), &mut value) })?; + // SAFETY: The returned body is owned by the request and outlives self. + Ok(unsafe { borrowed_slice(value) }) + } + + fn query(&self) -> BridgeResult<&str> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw is valid for the handler callback and value is writable. + decode_result(unsafe { ffi::ccf_rust_request_query(self.raw.as_ptr(), &mut value) })?; + // SAFETY: The returned query is owned by the request and outlives self. + let bytes = unsafe { borrowed_slice(value) }; + std::str::from_utf8(bytes).map_err(|_| BridgeError::Internal) + } + + fn copied_optional( + &mut self, + name: &str, + get: unsafe extern "C" fn(*mut RawEndpointContext, RawSlice, *mut RawSlice) -> i32, + ) -> BridgeResult>> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw is valid for the callback and all pointers remain valid + // for this call. + match decode_result(unsafe { get(self.raw.as_ptr(), raw_str(name), &mut value) }) { + Ok(()) => { + // SAFETY: The bridge returned a valid scratch slice. + Ok(Some(unsafe { borrowed_slice(value) }.to_vec())) + } + Err(BridgeError::NotFound) => Ok(None), + Err(error) => Err(error), + } + } + + fn path_param(&mut self, name: &str) -> BridgeResult> { + self.copied_optional(name, ffi::ccf_rust_request_path_param)? + .map(|value| String::from_utf8(value).map_err(|_| BridgeError::Internal)) + .transpose() + } + + fn header(&mut self, name: &str) -> BridgeResult>> { + self.copied_optional(name, ffi::ccf_rust_request_header) + } + + fn set_status(&mut self, status: u16) -> BridgeResult<()> { + // SAFETY: raw is valid for the callback. + decode_result(unsafe { ffi::ccf_rust_response_status(self.raw.as_ptr(), status) }) + } + + fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { + // SAFETY: raw and both strings are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_response_header(self.raw.as_ptr(), raw_str(name), raw_str(value)) + }) + } + + fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { + // SAFETY: raw and body are valid for this call. + decode_result(unsafe { ffi::ccf_rust_response_body(self.raw.as_ptr(), raw_slice(body)) }) + } + + fn set_error(&mut self, error: &EndpointError) -> BridgeResult<()> { + // SAFETY: raw and all strings are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_response_error( + self.raw.as_ptr(), + error.status, + raw_str(&error.code), + raw_str(&error.message), + ) + }) + } + + fn get(&mut self, map_name: &str, key: &[u8]) -> BridgeResult>> { + let mut value = RawSlice { + data: std::ptr::null(), + len: 0, + }; + // SAFETY: raw and input buffers are valid for this call. + match decode_result(unsafe { + ffi::ccf_rust_kv_get( + self.raw.as_ptr(), + raw_str(map_name), + raw_slice(key), + &mut value, + ) + }) { + Ok(()) => { + // SAFETY: The bridge returned a valid scratch slice. + Ok(Some(unsafe { borrowed_slice(value) }.to_vec())) + } + Err(BridgeError::NotFound) => Ok(None), + Err(error) => Err(error), + } + } + + fn has(&mut self, map_name: &str, key: &[u8]) -> BridgeResult { + let mut present = 0; + // SAFETY: raw and input buffers are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_kv_has( + self.raw.as_ptr(), + raw_str(map_name), + raw_slice(key), + &mut present, + ) + })?; + Ok(present != 0) + } + + fn put(&mut self, map_name: &str, key: &[u8], value: &[u8]) -> BridgeResult<()> { + // SAFETY: raw and input buffers are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_kv_put( + self.raw.as_ptr(), + raw_str(map_name), + raw_slice(key), + raw_slice(value), + ) + }) + } + + fn remove(&mut self, map_name: &str, key: &[u8]) -> BridgeResult<()> { + // SAFETY: raw and input buffers are valid for this call. + decode_result(unsafe { + ffi::ccf_rust_kv_remove(self.raw.as_ptr(), raw_str(map_name), raw_slice(key)) + }) + } +} + +pub struct ReadOnlyContext<'a>(Context<'a>); + +impl<'ctx> ReadOnlyContext<'ctx> { + pub fn body(&self) -> BridgeResult<&[u8]> { + self.0.body() + } + + pub fn query(&self) -> BridgeResult<&str> { + self.0.query() + } + + pub fn path_param(&mut self, name: &str) -> BridgeResult> { + self.0.path_param(name) + } + + pub fn header(&mut self, name: &str) -> BridgeResult>> { + self.0.header(name) + } + + pub fn set_status(&mut self, status: u16) -> BridgeResult<()> { + self.0.set_status(status) + } + + pub fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { + self.0.set_header(name, value) + } + + pub fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { + self.0.set_body(body) + } + + pub fn map<'a>(&'a mut self, name: &'a str) -> ReadOnlyMap<'a, 'ctx> { + ReadOnlyMap { + context: &mut self.0, + name, + } + } +} + +pub struct WriteContext<'a>(Context<'a>); + +impl<'ctx> WriteContext<'ctx> { + pub fn body(&self) -> BridgeResult<&[u8]> { + self.0.body() + } + + pub fn query(&self) -> BridgeResult<&str> { + self.0.query() + } + + pub fn path_param(&mut self, name: &str) -> BridgeResult> { + self.0.path_param(name) + } + + pub fn header(&mut self, name: &str) -> BridgeResult>> { + self.0.header(name) + } + + pub fn set_status(&mut self, status: u16) -> BridgeResult<()> { + self.0.set_status(status) + } + + pub fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { + self.0.set_header(name, value) + } + + pub fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { + self.0.set_body(body) + } + + pub fn map<'a>(&'a mut self, name: &'a str) -> Map<'a, 'ctx> { + Map { + context: &mut self.0, + name, + } + } +} + +pub struct ReadOnlyMap<'a, 'ctx> { + context: &'a mut Context<'ctx>, + name: &'a str, +} + +impl ReadOnlyMap<'_, '_> { + pub fn get(&mut self, key: &[u8]) -> BridgeResult>> { + self.context.get(self.name, key) + } + + pub fn has(&mut self, key: &[u8]) -> BridgeResult { + self.context.has(self.name, key) + } +} + +pub struct Map<'a, 'ctx> { + context: &'a mut Context<'ctx>, + name: &'a str, +} + +impl Map<'_, '_> { + pub fn get(&mut self, key: &[u8]) -> BridgeResult>> { + self.context.get(self.name, key) + } + + pub fn has(&mut self, key: &[u8]) -> BridgeResult { + self.context.has(self.name, key) + } + + pub fn put(&mut self, key: &[u8], value: &[u8]) -> BridgeResult<()> { + self.context.put(self.name, key, value) + } + + pub fn remove(&mut self, key: &[u8]) -> BridgeResult<()> { + self.context.remove(self.name, key) + } +} + +type ReadHandler = + dyn for<'a> Fn(&mut ReadOnlyContext<'a>) -> EndpointResult + Send + Sync + 'static; +type WriteHandler = dyn for<'a> Fn(&mut WriteContext<'a>) -> EndpointResult + Send + Sync + 'static; + +enum Handler { + Read(Box), + Write(Box), +} + +unsafe extern "C" fn invoke_handler( + user_data: *mut c_void, + raw_context: *mut RawEndpointContext, +) -> i32 { + if user_data.is_null() || raw_context.is_null() { + return RawResult::InvalidArgument as i32; + } + + // SAFETY: The registry owns this Handler until it invokes drop_handler. + let handler = unsafe { &*(user_data.cast::()) }; + // SAFETY: The null guard above validated raw_context. + let raw = unsafe { NonNull::new_unchecked(raw_context) }; + + let result = catch_unwind(AssertUnwindSafe(|| match handler { + Handler::Read(handler) => handler(&mut ReadOnlyContext(Context { + raw, + _lifetime: PhantomData, + })), + Handler::Write(handler) => handler(&mut WriteContext(Context { + raw, + _lifetime: PhantomData, + })), + })); + + let endpoint_error = match result { + Ok(Ok(())) => return RawResult::Ok as i32, + Ok(Err(error)) => error, + Err(_) => EndpointError::internal("Rust endpoint panicked"), + }; + + let mut context = Context { + raw, + _lifetime: PhantomData, + }; + match context.set_error(&endpoint_error) { + Ok(()) => RawResult::Ok as i32, + Err(_) => RawResult::InternalError as i32, + } +} + +unsafe extern "C" fn drop_handler(user_data: *mut c_void) { + if !user_data.is_null() { + let _ = catch_unwind(AssertUnwindSafe(|| { + // SAFETY: The pointer was created by Box::into_raw during endpoint + // registration and is dropped exactly once by the C++ registry. + drop(unsafe { Box::from_raw(user_data.cast::()) }); + })); + } +} + +pub struct Registry { + raw: NonNull, +} + +impl Registry { + /// # Safety + /// + /// `raw` must point to the live C++ registry passed to + /// `ccf_rust_app_register` and may not outlive that call. + pub unsafe fn from_raw(raw: *mut RawRegistry) -> BridgeResult { + if unsafe { ffi::ccf_rust_get_abi_version() } != ABI_VERSION { + return Err(BridgeError::AbiMismatch); + } + NonNull::new(raw) + .map(|raw| Self { raw }) + .ok_or(BridgeError::InvalidArgument) + } + + pub fn read_only( + &mut self, + path: &str, + method: &str, + auth: Auth, + handler: F, + ) -> BridgeResult<()> + where + F: for<'a> Fn(&mut ReadOnlyContext<'a>) -> EndpointResult + Send + Sync + 'static, + { + self.register(path, method, auth, Handler::Read(Box::new(handler))) + } + + pub fn read_write( + &mut self, + path: &str, + method: &str, + auth: Auth, + handler: F, + ) -> BridgeResult<()> + where + F: for<'a> Fn(&mut WriteContext<'a>) -> EndpointResult + Send + Sync + 'static, + { + self.register(path, method, auth, Handler::Write(Box::new(handler))) + } + + fn register( + &mut self, + path: &str, + method: &str, + auth: Auth, + handler: Handler, + ) -> BridgeResult<()> { + let read_only = matches!(handler, Handler::Read(_)) as i32; + let user_data = Box::into_raw(Box::new(handler)).cast::(); + // SAFETY: All inputs are valid for this call. Ownership of user_data is + // transferred only when registration succeeds. + let result = unsafe { + ffi::ccf_rust_register_endpoint( + self.raw.as_ptr(), + raw_str(path), + raw_str(method), + auth.raw(), + read_only, + invoke_handler, + drop_handler, + user_data, + ) + }; + if let Err(error) = decode_result(result) { + // SAFETY: Registration failed, so C++ did not retain user_data. + unsafe { drop_handler(user_data) }; + return Err(error); + } + Ok(()) + } +} + +#[macro_export] +macro_rules! export_app { + ($register:path) => { + #[unsafe(no_mangle)] + pub extern "C" fn ccf_rust_app_abi_version() -> u32 { + $crate::ABI_VERSION + } + + #[unsafe(no_mangle)] + pub unsafe extern "C" fn ccf_rust_app_register( + raw_registry: *mut $crate::RawRegistry, + ) -> i32 { + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // SAFETY: The C++ bridge passes a live registry for this call. + let mut registry = unsafe { $crate::Registry::from_raw(raw_registry) }?; + $register(&mut registry) + })); + match result { + Ok(Ok(())) => 0, + _ => 4, + } + } + }; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_raw_result_codes() { + assert_eq!(decode_result(0), Ok(())); + assert_eq!(decode_result(1), Err(BridgeError::NotFound)); + assert_eq!(decode_result(2), Err(BridgeError::InvalidArgument)); + assert_eq!(decode_result(3), Err(BridgeError::ReadOnly)); + assert_eq!(decode_result(99), Err(BridgeError::Internal)); + } + + #[test] + fn rejects_null_registry() { + // SAFETY: This intentionally exercises null validation. + assert!(matches!( + unsafe { Registry::from_raw(std::ptr::null_mut()) }, + Err(BridgeError::InvalidArgument) + )); + } + + #[test] + fn normalizes_invalid_error_status() { + assert_eq!(EndpointError::new(200, "Error", "message").status, 500); + assert_eq!(EndpointError::new(404, "Error", "message").status, 404); + } + + #[test] + fn panicking_handler_returns_internal_error() { + let handler = Box::new(Handler::Write(Box::new(|_| panic!("test panic")))); + let user_data = Box::into_raw(handler).cast::(); + let raw_context = NonNull::::dangling().as_ptr(); + // SAFETY: Both pointers are valid for this direct trampoline test. + let result = unsafe { invoke_handler(user_data, raw_context) }; + assert_eq!(result, RawResult::InternalError as i32); + // SAFETY: The test retains ownership of the handler. + unsafe { drop_handler(user_data) }; + } +} diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 39e69826b0f1..83a1476d5c74 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -1,716 +1,4 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the Apache 2.0 License. -//! Minimal Rust API for native CCF applications. -//! -//! Endpoint handlers may execute concurrently and must therefore be `Send` and -//! `Sync`. Request, response, transaction, and map objects are borrowed for one -//! callback invocation and cannot be retained. - -use std::ffi::c_void; -use std::marker::PhantomData; -use std::panic::{AssertUnwindSafe, catch_unwind}; -use std::ptr::NonNull; -use std::slice; - pub use cose_rs; - -pub const ABI_VERSION: u32 = 1; - -#[repr(C)] -pub struct RawRegistry { - _private: [u8; 0], -} - -#[repr(C)] -pub struct RawEndpointContext { - _private: [u8; 0], -} - -#[repr(C)] -#[derive(Clone, Copy)] -struct RawSlice { - data: *const u8, - len: usize, -} - -#[repr(i32)] -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum RawResult { - Ok = 0, - NotFound = 1, - InvalidArgument = 2, - ReadOnly = 3, - InternalError = 4, -} - -#[repr(i32)] -#[derive(Clone, Copy)] -enum RawAuth { - None = 0, - UserCert = 1, -} - -type RawHandler = unsafe extern "C" fn(*mut c_void, *mut RawEndpointContext) -> i32; -type RawDrop = unsafe extern "C" fn(*mut c_void); - -#[cfg(not(test))] -mod ffi { - use super::*; - - unsafe extern "C" { - pub fn ccf_rust_get_abi_version() -> u32; - pub fn ccf_rust_register_endpoint( - registry: *mut RawRegistry, - path: RawSlice, - method: RawSlice, - auth: RawAuth, - read_only: i32, - callback: RawHandler, - drop: RawDrop, - user_data: *mut c_void, - ) -> i32; - pub fn ccf_rust_request_body(ctx: *mut RawEndpointContext, body: *mut RawSlice) -> i32; - pub fn ccf_rust_request_query(ctx: *mut RawEndpointContext, query: *mut RawSlice) -> i32; - pub fn ccf_rust_request_path_param( - ctx: *mut RawEndpointContext, - name: RawSlice, - value: *mut RawSlice, - ) -> i32; - pub fn ccf_rust_request_header( - ctx: *mut RawEndpointContext, - name: RawSlice, - value: *mut RawSlice, - ) -> i32; - pub fn ccf_rust_response_status(ctx: *mut RawEndpointContext, status: u16) -> i32; - pub fn ccf_rust_response_header( - ctx: *mut RawEndpointContext, - name: RawSlice, - value: RawSlice, - ) -> i32; - pub fn ccf_rust_response_body(ctx: *mut RawEndpointContext, body: RawSlice) -> i32; - pub fn ccf_rust_response_error( - ctx: *mut RawEndpointContext, - status: u16, - code: RawSlice, - message: RawSlice, - ) -> i32; - pub fn ccf_rust_kv_get( - ctx: *mut RawEndpointContext, - map_name: RawSlice, - key: RawSlice, - value: *mut RawSlice, - ) -> i32; - pub fn ccf_rust_kv_has( - ctx: *mut RawEndpointContext, - map_name: RawSlice, - key: RawSlice, - present: *mut i32, - ) -> i32; - pub fn ccf_rust_kv_put( - ctx: *mut RawEndpointContext, - map_name: RawSlice, - key: RawSlice, - value: RawSlice, - ) -> i32; - pub fn ccf_rust_kv_remove( - ctx: *mut RawEndpointContext, - map_name: RawSlice, - key: RawSlice, - ) -> i32; - } -} - -#[cfg(test)] -mod ffi { - use super::*; - - pub unsafe extern "C" fn ccf_rust_get_abi_version() -> u32 { - ABI_VERSION - } - - pub unsafe extern "C" fn ccf_rust_register_endpoint( - _registry: *mut RawRegistry, - _path: RawSlice, - _method: RawSlice, - _auth: RawAuth, - _read_only: i32, - _callback: RawHandler, - _drop: RawDrop, - _user_data: *mut c_void, - ) -> i32 { - RawResult::InternalError as i32 - } - - macro_rules! failing_ffi { - ($name:ident($($arg:ident: $ty:ty),*)) => { - pub unsafe extern "C" fn $name($($arg: $ty),*) -> i32 { - $(let _ = $arg;)* - RawResult::InternalError as i32 - } - }; - } - - failing_ffi!(ccf_rust_request_body(ctx: *mut RawEndpointContext, body: *mut RawSlice)); - failing_ffi!(ccf_rust_request_query(ctx: *mut RawEndpointContext, query: *mut RawSlice)); - failing_ffi!(ccf_rust_request_path_param(ctx: *mut RawEndpointContext, name: RawSlice, value: *mut RawSlice)); - failing_ffi!(ccf_rust_request_header(ctx: *mut RawEndpointContext, name: RawSlice, value: *mut RawSlice)); - failing_ffi!(ccf_rust_response_status(ctx: *mut RawEndpointContext, status: u16)); - failing_ffi!(ccf_rust_response_header(ctx: *mut RawEndpointContext, name: RawSlice, value: RawSlice)); - failing_ffi!(ccf_rust_response_body(ctx: *mut RawEndpointContext, body: RawSlice)); - failing_ffi!(ccf_rust_response_error(ctx: *mut RawEndpointContext, status: u16, code: RawSlice, message: RawSlice)); - failing_ffi!(ccf_rust_kv_get(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, value: *mut RawSlice)); - failing_ffi!(ccf_rust_kv_has(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, present: *mut i32)); - failing_ffi!(ccf_rust_kv_put(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice, value: RawSlice)); - failing_ffi!(ccf_rust_kv_remove(ctx: *mut RawEndpointContext, map_name: RawSlice, key: RawSlice)); -} - -fn raw_slice(value: &[u8]) -> RawSlice { - RawSlice { - data: value.as_ptr(), - len: value.len(), - } -} - -fn raw_str(value: &str) -> RawSlice { - raw_slice(value.as_bytes()) -} - -fn decode_result(result: i32) -> Result<(), BridgeError> { - match result { - value if value == RawResult::Ok as i32 => Ok(()), - value if value == RawResult::NotFound as i32 => Err(BridgeError::NotFound), - value if value == RawResult::InvalidArgument as i32 => Err(BridgeError::InvalidArgument), - value if value == RawResult::ReadOnly as i32 => Err(BridgeError::ReadOnly), - _ => Err(BridgeError::Internal), - } -} - -unsafe fn borrowed_slice<'a>(value: RawSlice) -> &'a [u8] { - if value.len == 0 { - &[] - } else { - // SAFETY: The C++ bridge guarantees that successful output slices are - // valid until the next bridge call on this callback context. - unsafe { slice::from_raw_parts(value.data, value.len) } - } -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum BridgeError { - NotFound, - InvalidArgument, - ReadOnly, - Internal, - AbiMismatch, -} - -pub type BridgeResult = Result; - -#[derive(Clone, Copy, Debug)] -pub enum Auth { - None, - UserCert, -} - -impl Auth { - fn raw(self) -> RawAuth { - match self { - Self::None => RawAuth::None, - Self::UserCert => RawAuth::UserCert, - } - } -} - -#[derive(Clone, Debug)] -pub struct EndpointError { - pub status: u16, - pub code: String, - pub message: String, -} - -impl EndpointError { - pub fn new(status: u16, code: impl Into, message: impl Into) -> Self { - Self { - status, - code: code.into(), - message: message.into(), - } - } - - pub fn internal(message: impl Into) -> Self { - Self::new(500, "InternalError", message) - } -} - -impl From for EndpointError { - fn from(error: BridgeError) -> Self { - Self::internal(format!("CCF bridge error: {error:?}")) - } -} - -pub type EndpointResult = Result<(), EndpointError>; - -pub trait Codec { - type Error; - - fn encode(value: &T) -> Result, Self::Error>; - fn decode(value: &[u8]) -> Result; -} - -struct Context<'a> { - raw: NonNull, - _lifetime: PhantomData<&'a mut RawEndpointContext>, -} - -impl Context<'_> { - fn body(&self) -> BridgeResult<&[u8]> { - let mut value = RawSlice { - data: std::ptr::null(), - len: 0, - }; - // SAFETY: raw is valid for the handler callback and value is writable. - decode_result(unsafe { ffi::ccf_rust_request_body(self.raw.as_ptr(), &mut value) })?; - // SAFETY: The returned body is owned by the request and outlives self. - Ok(unsafe { borrowed_slice(value) }) - } - - fn query(&self) -> BridgeResult<&str> { - let mut value = RawSlice { - data: std::ptr::null(), - len: 0, - }; - // SAFETY: raw is valid for the handler callback and value is writable. - decode_result(unsafe { ffi::ccf_rust_request_query(self.raw.as_ptr(), &mut value) })?; - // SAFETY: The returned query is owned by the request and outlives self. - let bytes = unsafe { borrowed_slice(value) }; - std::str::from_utf8(bytes).map_err(|_| BridgeError::Internal) - } - - fn copied_optional( - &mut self, - name: &str, - get: unsafe extern "C" fn(*mut RawEndpointContext, RawSlice, *mut RawSlice) -> i32, - ) -> BridgeResult>> { - let mut value = RawSlice { - data: std::ptr::null(), - len: 0, - }; - // SAFETY: raw is valid for the callback and all pointers remain valid - // for this call. - match decode_result(unsafe { get(self.raw.as_ptr(), raw_str(name), &mut value) }) { - Ok(()) => { - // SAFETY: The bridge returned a valid scratch slice. - Ok(Some(unsafe { borrowed_slice(value) }.to_vec())) - } - Err(BridgeError::NotFound) => Ok(None), - Err(error) => Err(error), - } - } - - fn path_param(&mut self, name: &str) -> BridgeResult> { - self.copied_optional(name, ffi::ccf_rust_request_path_param)? - .map(|value| String::from_utf8(value).map_err(|_| BridgeError::Internal)) - .transpose() - } - - fn header(&mut self, name: &str) -> BridgeResult>> { - self.copied_optional(name, ffi::ccf_rust_request_header) - } - - fn set_status(&mut self, status: u16) -> BridgeResult<()> { - // SAFETY: raw is valid for the callback. - decode_result(unsafe { ffi::ccf_rust_response_status(self.raw.as_ptr(), status) }) - } - - fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { - // SAFETY: raw and both strings are valid for this call. - decode_result(unsafe { - ffi::ccf_rust_response_header(self.raw.as_ptr(), raw_str(name), raw_str(value)) - }) - } - - fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { - // SAFETY: raw and body are valid for this call. - decode_result(unsafe { ffi::ccf_rust_response_body(self.raw.as_ptr(), raw_slice(body)) }) - } - - fn set_error(&mut self, error: &EndpointError) -> BridgeResult<()> { - // SAFETY: raw and all strings are valid for this call. - decode_result(unsafe { - ffi::ccf_rust_response_error( - self.raw.as_ptr(), - error.status, - raw_str(&error.code), - raw_str(&error.message), - ) - }) - } - - fn get(&mut self, map_name: &str, key: &[u8]) -> BridgeResult>> { - let mut value = RawSlice { - data: std::ptr::null(), - len: 0, - }; - // SAFETY: raw and input buffers are valid for this call. - match decode_result(unsafe { - ffi::ccf_rust_kv_get( - self.raw.as_ptr(), - raw_str(map_name), - raw_slice(key), - &mut value, - ) - }) { - Ok(()) => { - // SAFETY: The bridge returned a valid scratch slice. - Ok(Some(unsafe { borrowed_slice(value) }.to_vec())) - } - Err(BridgeError::NotFound) => Ok(None), - Err(error) => Err(error), - } - } - - fn has(&mut self, map_name: &str, key: &[u8]) -> BridgeResult { - let mut present = 0; - // SAFETY: raw and input buffers are valid for this call. - decode_result(unsafe { - ffi::ccf_rust_kv_has( - self.raw.as_ptr(), - raw_str(map_name), - raw_slice(key), - &mut present, - ) - })?; - Ok(present != 0) - } - - fn put(&mut self, map_name: &str, key: &[u8], value: &[u8]) -> BridgeResult<()> { - // SAFETY: raw and input buffers are valid for this call. - decode_result(unsafe { - ffi::ccf_rust_kv_put( - self.raw.as_ptr(), - raw_str(map_name), - raw_slice(key), - raw_slice(value), - ) - }) - } - - fn remove(&mut self, map_name: &str, key: &[u8]) -> BridgeResult<()> { - // SAFETY: raw and input buffers are valid for this call. - decode_result(unsafe { - ffi::ccf_rust_kv_remove(self.raw.as_ptr(), raw_str(map_name), raw_slice(key)) - }) - } -} - -pub struct ReadOnlyContext<'a>(Context<'a>); - -impl<'ctx> ReadOnlyContext<'ctx> { - pub fn body(&self) -> BridgeResult<&[u8]> { - self.0.body() - } - - pub fn query(&self) -> BridgeResult<&str> { - self.0.query() - } - - pub fn path_param(&mut self, name: &str) -> BridgeResult> { - self.0.path_param(name) - } - - pub fn header(&mut self, name: &str) -> BridgeResult>> { - self.0.header(name) - } - - pub fn set_status(&mut self, status: u16) -> BridgeResult<()> { - self.0.set_status(status) - } - - pub fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { - self.0.set_header(name, value) - } - - pub fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { - self.0.set_body(body) - } - - pub fn map<'a>(&'a mut self, name: &'a str) -> ReadOnlyMap<'a, 'ctx> { - ReadOnlyMap { - context: &mut self.0, - name, - } - } -} - -pub struct WriteContext<'a>(Context<'a>); - -impl<'ctx> WriteContext<'ctx> { - pub fn body(&self) -> BridgeResult<&[u8]> { - self.0.body() - } - - pub fn query(&self) -> BridgeResult<&str> { - self.0.query() - } - - pub fn path_param(&mut self, name: &str) -> BridgeResult> { - self.0.path_param(name) - } - - pub fn header(&mut self, name: &str) -> BridgeResult>> { - self.0.header(name) - } - - pub fn set_status(&mut self, status: u16) -> BridgeResult<()> { - self.0.set_status(status) - } - - pub fn set_header(&mut self, name: &str, value: &str) -> BridgeResult<()> { - self.0.set_header(name, value) - } - - pub fn set_body(&mut self, body: &[u8]) -> BridgeResult<()> { - self.0.set_body(body) - } - - pub fn map<'a>(&'a mut self, name: &'a str) -> Map<'a, 'ctx> { - Map { - context: &mut self.0, - name, - } - } -} - -pub struct ReadOnlyMap<'a, 'ctx> { - context: &'a mut Context<'ctx>, - name: &'a str, -} - -impl ReadOnlyMap<'_, '_> { - pub fn get(&mut self, key: &[u8]) -> BridgeResult>> { - self.context.get(self.name, key) - } - - pub fn has(&mut self, key: &[u8]) -> BridgeResult { - self.context.has(self.name, key) - } -} - -pub struct Map<'a, 'ctx> { - context: &'a mut Context<'ctx>, - name: &'a str, -} - -impl Map<'_, '_> { - pub fn get(&mut self, key: &[u8]) -> BridgeResult>> { - self.context.get(self.name, key) - } - - pub fn has(&mut self, key: &[u8]) -> BridgeResult { - self.context.has(self.name, key) - } - - pub fn put(&mut self, key: &[u8], value: &[u8]) -> BridgeResult<()> { - self.context.put(self.name, key, value) - } - - pub fn remove(&mut self, key: &[u8]) -> BridgeResult<()> { - self.context.remove(self.name, key) - } -} - -type ReadHandler = - dyn for<'a> Fn(&mut ReadOnlyContext<'a>) -> EndpointResult + Send + Sync + 'static; -type WriteHandler = dyn for<'a> Fn(&mut WriteContext<'a>) -> EndpointResult + Send + Sync + 'static; - -enum Handler { - Read(Box), - Write(Box), -} - -unsafe extern "C" fn invoke_handler( - user_data: *mut c_void, - raw_context: *mut RawEndpointContext, -) -> i32 { - if user_data.is_null() || raw_context.is_null() { - return RawResult::InvalidArgument as i32; - } - - // SAFETY: The registry owns this Handler until it invokes drop_handler. - let handler = unsafe { &*(user_data.cast::()) }; - // SAFETY: The null guard above validated raw_context. - let raw = unsafe { NonNull::new_unchecked(raw_context) }; - - let result = catch_unwind(AssertUnwindSafe(|| match handler { - Handler::Read(handler) => handler(&mut ReadOnlyContext(Context { - raw, - _lifetime: PhantomData, - })), - Handler::Write(handler) => handler(&mut WriteContext(Context { - raw, - _lifetime: PhantomData, - })), - })); - - let endpoint_error = match result { - Ok(Ok(())) => return RawResult::Ok as i32, - Ok(Err(error)) => error, - Err(_) => EndpointError::internal("Rust endpoint panicked"), - }; - - let mut context = Context { - raw, - _lifetime: PhantomData, - }; - match context.set_error(&endpoint_error) { - Ok(()) => RawResult::Ok as i32, - Err(_) => RawResult::InternalError as i32, - } -} - -unsafe extern "C" fn drop_handler(user_data: *mut c_void) { - if !user_data.is_null() { - let _ = catch_unwind(AssertUnwindSafe(|| { - // SAFETY: The pointer was created by Box::into_raw during endpoint - // registration and is dropped exactly once by the C++ registry. - drop(unsafe { Box::from_raw(user_data.cast::()) }); - })); - } -} - -pub struct Registry { - raw: NonNull, -} - -impl Registry { - /// # Safety - /// - /// `raw` must point to the live C++ registry passed to - /// `ccf_rust_app_register` and may not outlive that call. - pub unsafe fn from_raw(raw: *mut RawRegistry) -> BridgeResult { - if unsafe { ffi::ccf_rust_get_abi_version() } != ABI_VERSION { - return Err(BridgeError::AbiMismatch); - } - NonNull::new(raw) - .map(|raw| Self { raw }) - .ok_or(BridgeError::InvalidArgument) - } - - pub fn read_only( - &mut self, - path: &str, - method: &str, - auth: Auth, - handler: F, - ) -> BridgeResult<()> - where - F: for<'a> Fn(&mut ReadOnlyContext<'a>) -> EndpointResult + Send + Sync + 'static, - { - self.register(path, method, auth, Handler::Read(Box::new(handler))) - } - - pub fn read_write( - &mut self, - path: &str, - method: &str, - auth: Auth, - handler: F, - ) -> BridgeResult<()> - where - F: for<'a> Fn(&mut WriteContext<'a>) -> EndpointResult + Send + Sync + 'static, - { - self.register(path, method, auth, Handler::Write(Box::new(handler))) - } - - fn register( - &mut self, - path: &str, - method: &str, - auth: Auth, - handler: Handler, - ) -> BridgeResult<()> { - let read_only = matches!(handler, Handler::Read(_)) as i32; - let user_data = Box::into_raw(Box::new(handler)).cast::(); - // SAFETY: All inputs are valid for this call. Ownership of user_data is - // transferred only when registration succeeds. - let result = unsafe { - ffi::ccf_rust_register_endpoint( - self.raw.as_ptr(), - raw_str(path), - raw_str(method), - auth.raw(), - read_only, - invoke_handler, - drop_handler, - user_data, - ) - }; - if let Err(error) = decode_result(result) { - // SAFETY: Registration failed, so C++ did not retain user_data. - unsafe { drop_handler(user_data) }; - return Err(error); - } - Ok(()) - } -} - -#[macro_export] -macro_rules! export_app { - ($register:path) => { - #[unsafe(no_mangle)] - pub extern "C" fn ccf_rust_app_abi_version() -> u32 { - $crate::ABI_VERSION - } - - #[unsafe(no_mangle)] - pub unsafe extern "C" fn ccf_rust_app_register( - raw_registry: *mut $crate::RawRegistry, - ) -> i32 { - let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - // SAFETY: The C++ bridge passes a live registry for this call. - let mut registry = unsafe { $crate::Registry::from_raw(raw_registry) }?; - $register(&mut registry) - })); - match result { - Ok(Ok(())) => 0, - _ => 4, - } - } - }; -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn maps_raw_result_codes() { - assert_eq!(decode_result(0), Ok(())); - assert_eq!(decode_result(1), Err(BridgeError::NotFound)); - assert_eq!(decode_result(2), Err(BridgeError::InvalidArgument)); - assert_eq!(decode_result(3), Err(BridgeError::ReadOnly)); - assert_eq!(decode_result(99), Err(BridgeError::Internal)); - } - - #[test] - fn rejects_null_registry() { - // SAFETY: This intentionally exercises null validation. - assert!(matches!( - unsafe { Registry::from_raw(std::ptr::null_mut()) }, - Err(BridgeError::InvalidArgument) - )); - } - - #[test] - fn panicking_handler_returns_internal_error() { - let handler = Box::new(Handler::Write(Box::new(|_| panic!("test panic")))); - let user_data = Box::into_raw(handler).cast::(); - let raw_context = NonNull::::dangling().as_ptr(); - // SAFETY: Both pointers are valid for this direct trampoline test. - let result = unsafe { invoke_handler(user_data, raw_context) }; - assert_eq!(result, RawResult::InternalError as i32); - // SAFETY: The test retains ownership of the handler. - unsafe { drop_handler(user_data) }; - } -} From 0cd46589994d18ad3490e7be8ec69d83c0899035 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 06:48:42 +0000 Subject: [PATCH 06/19] Address final Rust SDK review feedback Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- cmake/ccf_app.cmake | 5 ++++- src/rust/ccf-app/src/lib.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/cmake/ccf_app.cmake b/cmake/ccf_app.cmake index ed063fd5cafd..290d5643d543 100644 --- a/cmake/ccf_app.cmake +++ b/cmake/ccf_app.cmake @@ -97,7 +97,10 @@ function(add_ccf_rust_app name) file(GLOB_RECURSE RUST_APP_SOURCES CONFIGURE_DEPENDS ${MANIFEST_DIR}/src/*.rs) - set(RUSTFLAGS "--remap-path-prefix=${MANIFEST_DIR}=APP") + set( + RUSTFLAGS + "$ENV{RUSTFLAGS} --remap-path-prefix=${MANIFEST_DIR}=APP --remap-path-prefix=${CCF_DIR}=CCF --remap-path-prefix=$ENV{HOME}/.cargo=CARGO" + ) add_custom_command( OUTPUT ${RUST_APP_LIB} COMMAND ${CMAKE_COMMAND} -E make_directory ${CARGO_TARGET_DIR} diff --git a/src/rust/ccf-app/src/lib.rs b/src/rust/ccf-app/src/lib.rs index 053a11f08ef4..ebb766baf7a6 100644 --- a/src/rust/ccf-app/src/lib.rs +++ b/src/rust/ccf-app/src/lib.rs @@ -42,6 +42,9 @@ enum RawResult { InternalError = 4, } +#[doc(hidden)] +pub const INTERNAL_ERROR_CODE: i32 = RawResult::InternalError as i32; + #[repr(i32)] #[derive(Clone, Copy)] enum RawAuth { @@ -676,7 +679,7 @@ macro_rules! export_app { })); match result { Ok(Ok(())) => 0, - _ => 4, + _ => $crate::INTERNAL_ERROR_CODE, } } }; From 4e6bf97ab8af4a22d5307a3844120161328ad4e3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:16:34 +0000 Subject: [PATCH 07/19] Use public KV API in Rust bridge Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/rust/app_bridge.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index 3007c66136f5..00725c639ab4 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -4,9 +4,9 @@ #include "ccf/app_interface.h" #include "ccf/common_auth_policies.h" #include "ccf/http_status.h" +#include "ccf/kv/map.h" #include "ccf/odata_error.h" #include "ccf/rust_ffi.h" -#include "kv/untyped_map.h" #include #include @@ -16,7 +16,8 @@ namespace { - using RawMap = ccf::kv::untyped::Map; + using RawMap = + ccf::kv::RawCopySerialisedMap; class RustEndpointRegistry; bool is_valid_utf8(const ccf_rust_slice& value) From 71d89205d4e14384b469d537b0eaf8363b57bdef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Aug 2026 16:18:23 +0000 Subject: [PATCH 08/19] Compile Rust bridge with public map type Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/rust/app_bridge.cpp | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index 00725c639ab4..95ee58c38997 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -17,7 +17,7 @@ namespace { using RawMap = - ccf::kv::RawCopySerialisedMap; + ccf::kv::RawCopySerialisedMap, std::vector>; class RustEndpointRegistry; bool is_valid_utf8(const ccf_rust_slice& value) @@ -398,7 +398,8 @@ extern "C" return CCF_RUST_NOT_FOUND; } ctx->scratch.clear(); - ctx->scratch.append(it->second.begin(), it->second.end()); + ctx->scratch.insert( + ctx->scratch.end(), it->second.begin(), it->second.end()); set_slice(value, ctx->scratch); return CCF_RUST_OK; } @@ -423,7 +424,7 @@ extern "C" return CCF_RUST_NOT_FOUND; } ctx->scratch.clear(); - ctx->scratch.append(header->begin(), header->end()); + ctx->scratch.insert(ctx->scratch.end(), header->begin(), header->end()); set_slice(value, ctx->scratch); return CCF_RUST_OK; } From 99d86286e56025f873ce81369a1daeecb36582f1 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Mon, 24 Aug 2026 14:47:22 +0100 Subject: [PATCH 09/19] Reference Rust exploration PR in changelog Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 696dcc100149..364fa47958df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - C++ endpoints can now use `ccf::endpoints::Endpoint::add_openapi_response()` to document additional HTTP responses in their generated OpenAPI schema without changing the endpoint's primary success response (#8115). - New `ledger.max_transaction_size` node configuration option (default `32MB`), which caps the total serialised size of transactions written to the ledger. The limit covers the whole ledger entry: the fixed 8-byte ledger entry header, the ledger encryption header, public domain size field, public domain and encrypted private domain. It is checked before a transaction is applied, so an oversized transaction is now rejected with `413 Payload Too Large` and error code `TransactionTooLarge`, and subsequent transactions are unaffected, where previously an excessively large transaction could terminate the node. Reserved internal signature transactions are exempt because they must fill their reserved ledger version. The limit applies only to newly serialised non-reserved transactions; deserialising existing entries (including during recovery), historical queries and snapshots are unaffected, so entries written under a larger or unset limit remain readable. It must be smaller than `memory.max_msg_size` by at least the ring-buffer range response overhead, which is validated at node startup and by `--check` (#7992). -- Native CCF applications can now be written in Rust through a minimal API for registering endpoints and accessing raw-byte KV maps (#8156). +- Native CCF applications can now be written in Rust through a minimal API for registering endpoints and accessing raw-byte KV maps (#8200). ### Changed From 8b91d0b5a9156902f3d43ae4a14a9e19090f2855 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Wed, 26 Aug 2026 14:27:40 +0100 Subject: [PATCH 10/19] Harden Rust bridge integration Preserve compaction retry semantics, reject unsupported HTTP status codes, and keep the CI test bucket inventory in sync. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- include/ccf/kv/compacted_version_conflict.h | 23 +++++++ src/kv/compacted_version_conflict.h | 19 +----- src/rust/app_bridge.cpp | 68 +++++++++++++++++++-- tests/ci-buckets.txt | 1 + 4 files changed, 88 insertions(+), 23 deletions(-) create mode 100644 include/ccf/kv/compacted_version_conflict.h diff --git a/include/ccf/kv/compacted_version_conflict.h b/include/ccf/kv/compacted_version_conflict.h new file mode 100644 index 000000000000..1fb49ca5cc78 --- /dev/null +++ b/include/ccf/kv/compacted_version_conflict.h @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. +#pragma once + +#include +#include + +namespace ccf::kv +{ + class CompactedVersionConflict + { + private: + std::string msg; + + public: + CompactedVersionConflict(std::string s) : msg(std::move(s)) {} + + [[nodiscard]] char const* what() const + { + return msg.c_str(); + } + }; +} diff --git a/src/kv/compacted_version_conflict.h b/src/kv/compacted_version_conflict.h index 093861539398..a48bafe9dde5 100644 --- a/src/kv/compacted_version_conflict.h +++ b/src/kv/compacted_version_conflict.h @@ -2,21 +2,4 @@ // Licensed under the Apache 2.0 License. #pragma once -#include - -namespace ccf::kv -{ - class CompactedVersionConflict - { - private: - std::string msg; - - public: - CompactedVersionConflict(std::string s) : msg(std::move(s)) {} - - [[nodiscard]] char const* what() const - { - return msg.c_str(); - } - }; -} +#include "ccf/kv/compacted_version_conflict.h" diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index 95ee58c38997..ca6885fe386b 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -4,11 +4,13 @@ #include "ccf/app_interface.h" #include "ccf/common_auth_policies.h" #include "ccf/http_status.h" +#include "ccf/kv/compacted_version_conflict.h" #include "ccf/kv/map.h" #include "ccf/odata_error.h" #include "ccf/rust_ffi.h" #include +#include #include #include #include @@ -92,6 +94,20 @@ namespace return value.data != nullptr || value.len == 0; } + bool is_known_http_status(uint16_t status) + { + switch (status) + { +#define XX(code, name, string) \ + case code: \ + return true; + HTTP_STATUS_MAP(XX) +#undef XX + default: + return false; + } + } + std::string to_string(const ccf_rust_slice& value) { if (value.len == 0) @@ -156,6 +172,8 @@ struct ccf_rust_endpoint_context std::unordered_map read_handles; std::unordered_map write_handles; RawMap::Handle::ValueType scratch; + std::optional compacted_version_conflict = + std::nullopt; RawMap::ReadOnlyHandle* read_handle(const std::string& map_name) { @@ -188,6 +206,14 @@ struct ccf_rust_endpoint_context read_handles[map_name] = handle; return handle; } + + void rethrow_compacted_version_conflict() + { + if (compacted_version_conflict.has_value()) + { + throw std::move(compacted_version_conflict.value()); + } + } }; namespace @@ -235,7 +261,9 @@ namespace ctx.rpc_ctx, &ctx.tx, nullptr, {}, {}, {}}; try { - if (state->callback(state->user_data, &rust_ctx) != CCF_RUST_OK) + const auto result = state->callback(state->user_data, &rust_ctx); + rust_ctx.rethrow_compacted_version_conflict(); + if (result != CCF_RUST_OK) { ctx.rpc_ctx->set_error( HTTP_STATUS_INTERNAL_SERVER_ERROR, @@ -243,6 +271,10 @@ namespace "Rust endpoint execution failed"); } } + catch (const ccf::kv::CompactedVersionConflict&) + { + throw; + } catch (const std::exception& e) { ctx.rpc_ctx->set_error( @@ -271,7 +303,9 @@ namespace ctx.rpc_ctx, &ctx.tx, &ctx.tx, {}, {}, {}}; try { - if (state->callback(state->user_data, &rust_ctx) != CCF_RUST_OK) + const auto result = state->callback(state->user_data, &rust_ctx); + rust_ctx.rethrow_compacted_version_conflict(); + if (result != CCF_RUST_OK) { ctx.rpc_ctx->set_error( HTTP_STATUS_INTERNAL_SERVER_ERROR, @@ -279,6 +313,10 @@ namespace "Rust endpoint execution failed"); } } + catch (const ccf::kv::CompactedVersionConflict&) + { + throw; + } catch (const std::exception& e) { ctx.rpc_ctx->set_error( @@ -436,7 +474,7 @@ extern "C" int ccf_rust_response_status(ccf_rust_endpoint_context* ctx, uint16_t status) { - if (ctx == nullptr || status < 100 || status > 599) + if (ctx == nullptr || !is_known_http_status(status)) { return CCF_RUST_INVALID_ARGUMENT; } @@ -496,8 +534,8 @@ extern "C" ccf_rust_slice message) { if ( - ctx == nullptr || status < 400 || status > 599 || !is_valid_utf8(code) || - code.len == 0 || !is_valid_utf8(message)) + ctx == nullptr || status < 400 || !is_known_http_status(status) || + !is_valid_utf8(code) || code.len == 0 || !is_valid_utf8(message)) { return CCF_RUST_INVALID_ARGUMENT; } @@ -539,6 +577,11 @@ extern "C" set_slice(value, ctx->scratch); return CCF_RUST_OK; } + catch (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } catch (...) { return CCF_RUST_INTERNAL_ERROR; @@ -563,6 +606,11 @@ extern "C" ctx->read_handle(to_string(map_name))->has(to_bytes(key)) ? 1 : 0; return CCF_RUST_OK; } + catch (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } catch (...) { return CCF_RUST_INTERNAL_ERROR; @@ -591,6 +639,11 @@ extern "C" handle->put(to_bytes(key), to_bytes(value)); return CCF_RUST_OK; } + catch (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } catch (...) { return CCF_RUST_INTERNAL_ERROR; @@ -616,6 +669,11 @@ extern "C" handle->remove(to_bytes(key)); return CCF_RUST_OK; } + catch (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } catch (...) { return CCF_RUST_INTERNAL_ERROR; diff --git a/tests/ci-buckets.txt b/tests/ci-buckets.txt index c21da141b93a..95cc9ec878b4 100644 --- a/tests/ci-buckets.txt +++ b/tests/ci-buckets.txt @@ -20,6 +20,7 @@ bucket_c: governance_test code_update_test e2e_logging + basic_rust programmability_and_jwt e2e_limits e2e_redirects From 30b12d696dcee06addbd8135bda84719e993f56d Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Wed, 26 Aug 2026 15:41:58 +0100 Subject: [PATCH 11/19] Address Rust interface review feedback Stabilize the C ABI, preserve Cargo dependency tracking, register Rust unit tests, enforce unwind panics, and clarify native application trust semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CMakeLists.txt | 13 ++++ cmake/ccf_app.cmake | 21 +----- cmake/gersemi_definitions.cmake | 8 +- doc/build_apps/example_rust.rst | 19 +++-- include/ccf/rust_ffi.h | 100 +++++++++++++++---------- samples/apps/basic_rust/CMakeLists.txt | 1 - src/rust/app_bridge.cpp | 46 ++++++------ src/rust/ccf-app/src/lib.rs | 5 ++ 8 files changed, 120 insertions(+), 93 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index a55ed5d587b2..23614d73484b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -595,6 +595,19 @@ if(BUILD_TESTS) # Unit tests if(BUILD_UNIT_TESTS) + add_test( + NAME ccf_app_rust_test + COMMAND + ${CMAKE_COMMAND} -E env --unset=CARGO_BUILD_TARGET "CARGO_NET_RETRY=10" + "CARGO_HTTP_TIMEOUT=60" "CARGO_BUILD_RUSTC=${RUSTC}" ${CARGO} test + --manifest-path ${CCF_DIR}/src/rust/ccf-app/Cargo.toml --target-dir + ${CMAKE_BINARY_DIR}/cargo/ccf-app-test --locked + ) + set_tests_properties( + ccf_app_rust_test + PROPERTIES LABELS unit WORKING_DIRECTORY ${CCF_DIR}/src/rust/ccf-app + ) + add_test( NAME verify_uvm_attestation_and_endorsements COMMAND diff --git a/cmake/ccf_app.cmake b/cmake/ccf_app.cmake index 290d5643d543..2635b24be713 100644 --- a/cmake/ccf_app.cmake +++ b/cmake/ccf_app.cmake @@ -53,13 +53,7 @@ function(add_ccf_app name) endfunction() function(add_ccf_rust_app name) - cmake_parse_arguments( - PARSE_ARGV 1 - PARSED_ARGS - "" - "MANIFEST_PATH;PACKAGE" - "DEPS" - ) + cmake_parse_arguments(PARSE_ARGV 1 PARSED_ARGS "" "MANIFEST_PATH;PACKAGE" "") if(NOT PARSED_ARGS_MANIFEST_PATH) message(FATAL_ERROR "add_ccf_rust_app requires MANIFEST_PATH") @@ -95,14 +89,13 @@ function(add_ccf_rust_app name) ${CARGO_TARGET_DIR}/${CARGO_PROFILE_DIR}/lib${RUST_LIB_NAME}.a ) - file(GLOB_RECURSE RUST_APP_SOURCES CONFIGURE_DEPENDS ${MANIFEST_DIR}/src/*.rs) - set( RUSTFLAGS "$ENV{RUSTFLAGS} --remap-path-prefix=${MANIFEST_DIR}=APP --remap-path-prefix=${CCF_DIR}=CCF --remap-path-prefix=$ENV{HOME}/.cargo=CARGO" ) - add_custom_command( - OUTPUT ${RUST_APP_LIB} + add_custom_target( + cargo-build_${name} + BYPRODUCTS ${RUST_APP_LIB} COMMAND ${CMAKE_COMMAND} -E make_directory ${CARGO_TARGET_DIR} COMMAND ${CMAKE_COMMAND} -E env --unset=CARGO_BUILD_TARGET @@ -112,16 +105,10 @@ function(add_ccf_rust_app name) ${PARSED_ARGS_PACKAGE} --manifest-path ${MANIFEST_PATH} --target-dir ${CARGO_TARGET_DIR} ${CARGO_PROFILE_FLAG} --locked WORKING_DIRECTORY ${MANIFEST_DIR} - DEPENDS - ${MANIFEST_PATH} - ${MANIFEST_DIR}/Cargo.lock - ${RUST_APP_SOURCES} - ${PARSED_ARGS_DEPS} COMMENT "Building Rust CCF application ${name}" USES_TERMINAL VERBATIM ) - add_custom_target(cargo-build_${name} DEPENDS ${RUST_APP_LIB}) if(EXISTS "${CCF_DIR}/src/rust/app_bridge.cpp") set(RUST_BRIDGE_SOURCE "${CCF_DIR}/src/rust/app_bridge.cpp") diff --git a/cmake/gersemi_definitions.cmake b/cmake/gersemi_definitions.cmake index d1a9579cade5..98eff8376123 100644 --- a/cmake/gersemi_definitions.cmake +++ b/cmake/gersemi_definitions.cmake @@ -16,13 +16,7 @@ function(add_ccf_app name) endfunction() function(add_ccf_rust_app name) - cmake_parse_arguments( - PARSE_ARGV 1 - PARSED_ARGS - "" - "MANIFEST_PATH;PACKAGE" - "DEPS" - ) + cmake_parse_arguments(PARSE_ARGV 1 PARSED_ARGS "" "MANIFEST_PATH;PACKAGE" "") endfunction() function(add_ccf_static_library name) diff --git a/doc/build_apps/example_rust.rst b/doc/build_apps/example_rust.rst index bb497a41b77a..94d3b0b7f358 100644 --- a/doc/build_apps/example_rust.rst +++ b/doc/build_apps/example_rust.rst @@ -31,9 +31,10 @@ which depends on the source-tree ``src/rust/ccf-app`` crate or the installed The helper maps CMake ``Debug`` builds to Cargo's development profile and all other build types to Cargo's release profile. It also links the generic C++ ABI -bridge, launcher, and CCF libraries. Cargo sources, the manifest, and the lock -file are build dependencies. The application should commit ``Cargo.lock`` and -pin a Rust toolchain for reproducible builds. +bridge, launcher, and CCF libraries. Cargo is invoked on every build and decides +whether the crate is up to date, so Rust source edits do not require CMake to be +reconfigured. The application should commit ``Cargo.lock`` and pin a Rust +toolchain for reproducible builds. The complete records example is in :ccf_repo:`samples/apps/basic_rust`. It exports a registration function with ``ccf_app::export_app!`` and registers @@ -47,8 +48,10 @@ retry a read-write handler when a transaction conflicts, so handlers should be deterministic and should not perform non-transactional side effects. Request, response, transaction, and map values borrow the callback context and -cannot be retained. Rust panics are caught at the ABI boundary and become HTTP -500 errors. C++ exceptions are also contained by the bridge. +cannot be retained. The SDK requires Rust's ``unwind`` panic strategy so that +panics are caught at the ABI boundary and become HTTP 500 errors. Builds using +``panic = "abort"`` are rejected. C++ exceptions are also contained by the +bridge. KV values and keys ------------------ @@ -59,8 +62,10 @@ common interface without prescribing a wire format. Map names retain the standard CCF security semantics. Names beginning with ``public:`` are written to the ledger in plaintext. All other application map -names, such as the sample's ``records`` map, are private and encrypted. The -framework continues to enforce reserved governance and internal map namespaces. +names, such as the sample's ``records`` map, are private and encrypted. Like +native C++ applications, native Rust applications are trusted code: raw map +access does not enforce the namespace restrictions applied to JavaScript +applications for reserved governance and internal maps. Read-only handlers receive only ``ReadOnlyMap``, so write operations are not available at compile time. Errors returned by a handler use the normal CCF diff --git a/include/ccf/rust_ffi.h b/include/ccf/rust_ffi.h index f0e227180791..18aa7e9d1a3e 100644 --- a/include/ccf/rust_ffi.h +++ b/include/ccf/rust_ffi.h @@ -12,88 +12,112 @@ extern "C" static const uint32_t CCF_RUST_ABI_VERSION = 1; - typedef struct ccf_rust_registry ccf_rust_registry; - typedef struct ccf_rust_endpoint_context ccf_rust_endpoint_context; - - typedef struct ccf_rust_slice + struct ccf_rust_registry; + struct ccf_rust_endpoint_context; + struct ccf_rust_slice { const uint8_t* data; size_t len; - } ccf_rust_slice; + }; - typedef enum ccf_rust_result - { - CCF_RUST_OK = 0, - CCF_RUST_NOT_FOUND = 1, - CCF_RUST_INVALID_ARGUMENT = 2, - CCF_RUST_READ_ONLY = 3, - CCF_RUST_INTERNAL_ERROR = 4 - } ccf_rust_result; - - typedef enum ccf_rust_auth - { - CCF_RUST_AUTH_NONE = 0, - CCF_RUST_AUTH_USER_CERT = 1 - } ccf_rust_auth; +#ifdef __cplusplus + using ccf_rust_result = int32_t; + using ccf_rust_auth = int32_t; + using ccf_rust_endpoint_callback = + ccf_rust_result (*)(void* user_data, ccf_rust_endpoint_context* ctx); + using ccf_rust_drop_callback = void (*)(void* user_data); +#else +typedef struct ccf_rust_registry ccf_rust_registry; +typedef struct ccf_rust_endpoint_context ccf_rust_endpoint_context; +typedef struct ccf_rust_slice ccf_rust_slice; +typedef int32_t ccf_rust_result; +typedef int32_t ccf_rust_auth; +typedef ccf_rust_result (*ccf_rust_endpoint_callback)( + void* user_data, ccf_rust_endpoint_context* ctx); +typedef void (*ccf_rust_drop_callback)(void* user_data); +#endif - typedef int (*ccf_rust_endpoint_callback)( - void* user_data, ccf_rust_endpoint_context* ctx); - typedef void (*ccf_rust_drop_callback)(void* user_data); +#ifdef __cplusplus + inline constexpr ccf_rust_result CCF_RUST_OK = 0; + inline constexpr ccf_rust_result CCF_RUST_NOT_FOUND = 1; + inline constexpr ccf_rust_result CCF_RUST_INVALID_ARGUMENT = 2; + inline constexpr ccf_rust_result CCF_RUST_READ_ONLY = 3; + inline constexpr ccf_rust_result CCF_RUST_INTERNAL_ERROR = 4; + + inline constexpr ccf_rust_auth CCF_RUST_AUTH_NONE = 0; + inline constexpr ccf_rust_auth CCF_RUST_AUTH_USER_CERT = 1; +#else +enum +{ + CCF_RUST_OK = 0, + CCF_RUST_NOT_FOUND = 1, + CCF_RUST_INVALID_ARGUMENT = 2, + CCF_RUST_READ_ONLY = 3, + CCF_RUST_INTERNAL_ERROR = 4 +}; + +enum +{ + CCF_RUST_AUTH_NONE = 0, + CCF_RUST_AUTH_USER_CERT = 1 +}; +#endif uint32_t ccf_rust_get_abi_version(void); - int ccf_rust_register_endpoint( + ccf_rust_result ccf_rust_register_endpoint( ccf_rust_registry* registry, ccf_rust_slice path, ccf_rust_slice method, ccf_rust_auth auth, - int read_only, + int32_t read_only, ccf_rust_endpoint_callback callback, ccf_rust_drop_callback drop, void* user_data); - int ccf_rust_request_body( + ccf_rust_result ccf_rust_request_body( ccf_rust_endpoint_context* ctx, ccf_rust_slice* body); - int ccf_rust_request_query( + ccf_rust_result ccf_rust_request_query( ccf_rust_endpoint_context* ctx, ccf_rust_slice* query); - int ccf_rust_request_path_param( + ccf_rust_result ccf_rust_request_path_param( ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value); - int ccf_rust_request_header( + ccf_rust_result ccf_rust_request_header( ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value); - int ccf_rust_response_status(ccf_rust_endpoint_context* ctx, uint16_t status); - int ccf_rust_response_header( + ccf_rust_result ccf_rust_response_status( + ccf_rust_endpoint_context* ctx, uint16_t status); + ccf_rust_result ccf_rust_response_header( ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice value); - int ccf_rust_response_body( + ccf_rust_result ccf_rust_response_body( ccf_rust_endpoint_context* ctx, ccf_rust_slice body); - int ccf_rust_response_error( + ccf_rust_result ccf_rust_response_error( ccf_rust_endpoint_context* ctx, uint16_t status, ccf_rust_slice code, ccf_rust_slice message); - int ccf_rust_kv_get( + ccf_rust_result ccf_rust_kv_get( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key, ccf_rust_slice* value); - int ccf_rust_kv_has( + ccf_rust_result ccf_rust_kv_has( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key, - int* present); - int ccf_rust_kv_put( + int32_t* present); + ccf_rust_result ccf_rust_kv_put( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key, ccf_rust_slice value); - int ccf_rust_kv_remove( + ccf_rust_result ccf_rust_kv_remove( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key); uint32_t ccf_rust_app_abi_version(void); - int ccf_rust_app_register(ccf_rust_registry* registry); + ccf_rust_result ccf_rust_app_register(ccf_rust_registry* registry); #ifdef __cplusplus } diff --git a/samples/apps/basic_rust/CMakeLists.txt b/samples/apps/basic_rust/CMakeLists.txt index 4601379230ff..a2ba27b9f808 100644 --- a/samples/apps/basic_rust/CMakeLists.txt +++ b/samples/apps/basic_rust/CMakeLists.txt @@ -15,5 +15,4 @@ add_ccf_rust_app( basic_rust MANIFEST_PATH ${CMAKE_CURRENT_LIST_DIR}/Cargo.toml PACKAGE ccf-basic-rust - DEPS ${CCF_DIR}/src/rust/ccf-app/src/lib.rs ) diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index ca6885fe386b..2b7681cc6dd2 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -144,9 +144,9 @@ namespace struct CallbackState { - ccf_rust_endpoint_callback callback; - ccf_rust_drop_callback drop; - void* user_data; + ccf_rust_endpoint_callback callback = nullptr; + ccf_rust_drop_callback drop = nullptr; + void* user_data = nullptr; bool owns_user_data = false; ~CallbackState() @@ -166,9 +166,9 @@ struct ccf_rust_registry struct ccf_rust_endpoint_context { - std::shared_ptr rpc; - ccf::kv::ReadOnlyTx* tx; - ccf::kv::Tx* writable_tx; + std::shared_ptr rpc = nullptr; + ccf::kv::ReadOnlyTx* tx = nullptr; + ccf::kv::Tx* writable_tx = nullptr; std::unordered_map read_handles; std::unordered_map write_handles; RawMap::Handle::ValueType scratch; @@ -346,12 +346,12 @@ extern "C" return CCF_RUST_ABI_VERSION; } - int ccf_rust_register_endpoint( + ccf_rust_result ccf_rust_register_endpoint( ccf_rust_registry* registry, ccf_rust_slice path, ccf_rust_slice method, ccf_rust_auth auth, - int read_only, + int32_t read_only, ccf_rust_endpoint_callback callback, ccf_rust_drop_callback drop, void* user_data) @@ -384,7 +384,7 @@ extern "C" } } - int ccf_rust_request_body( + ccf_rust_result ccf_rust_request_body( ccf_rust_endpoint_context* ctx, ccf_rust_slice* body) { if (ctx == nullptr || body == nullptr) @@ -402,7 +402,7 @@ extern "C" } } - int ccf_rust_request_query( + ccf_rust_result ccf_rust_request_query( ccf_rust_endpoint_context* ctx, ccf_rust_slice* query) { if (ctx == nullptr || query == nullptr) @@ -420,7 +420,7 @@ extern "C" } } - int ccf_rust_request_path_param( + ccf_rust_result ccf_rust_request_path_param( ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value) { if (ctx == nullptr || value == nullptr || !is_valid_utf8(name)) @@ -447,7 +447,7 @@ extern "C" } } - int ccf_rust_request_header( + ccf_rust_result ccf_rust_request_header( ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value) { if (ctx == nullptr || value == nullptr || !is_valid_utf8(name)) @@ -472,7 +472,8 @@ extern "C" } } - int ccf_rust_response_status(ccf_rust_endpoint_context* ctx, uint16_t status) + ccf_rust_result ccf_rust_response_status( + ccf_rust_endpoint_context* ctx, uint16_t status) { if (ctx == nullptr || !is_known_http_status(status)) { @@ -489,7 +490,7 @@ extern "C" } } - int ccf_rust_response_header( + ccf_rust_result ccf_rust_response_header( ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice value) { if ( @@ -509,7 +510,7 @@ extern "C" } } - int ccf_rust_response_body( + ccf_rust_result ccf_rust_response_body( ccf_rust_endpoint_context* ctx, ccf_rust_slice body) { if (ctx == nullptr || !is_valid_buffer(body)) @@ -527,7 +528,7 @@ extern "C" } } - int ccf_rust_response_error( + ccf_rust_result ccf_rust_response_error( ccf_rust_endpoint_context* ctx, uint16_t status, ccf_rust_slice code, @@ -553,7 +554,7 @@ extern "C" } } - int ccf_rust_kv_get( + ccf_rust_result ccf_rust_kv_get( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key, @@ -567,8 +568,7 @@ extern "C" } try { - const auto result = - ctx->read_handle(to_string(map_name))->get(to_bytes(key)); + auto result = ctx->read_handle(to_string(map_name))->get(to_bytes(key)); if (!result.has_value()) { return CCF_RUST_NOT_FOUND; @@ -588,11 +588,11 @@ extern "C" } } - int ccf_rust_kv_has( + ccf_rust_result ccf_rust_kv_has( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key, - int* present) + int32_t* present) { if ( ctx == nullptr || present == nullptr || !is_valid_utf8(map_name) || @@ -617,7 +617,7 @@ extern "C" } } - int ccf_rust_kv_put( + ccf_rust_result ccf_rust_kv_put( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key, @@ -650,7 +650,7 @@ extern "C" } } - int ccf_rust_kv_remove( + ccf_rust_result ccf_rust_kv_remove( ccf_rust_endpoint_context* ctx, ccf_rust_slice map_name, ccf_rust_slice key) { if ( diff --git a/src/rust/ccf-app/src/lib.rs b/src/rust/ccf-app/src/lib.rs index ebb766baf7a6..8f31bb49efb1 100644 --- a/src/rust/ccf-app/src/lib.rs +++ b/src/rust/ccf-app/src/lib.rs @@ -7,6 +7,11 @@ //! `Sync`. Request, response, transaction, and map objects are borrowed for one //! callback invocation and cannot be retained. +#[cfg(panic = "abort")] +compile_error!( + "ccf-app requires panic = \"unwind\" because its C ABI catches panics at the boundary" +); + use std::ffi::c_void; use std::marker::PhantomData; use std::panic::{AssertUnwindSafe, catch_unwind}; From 1019c22c1e0949848d5815f898acd466c56e1eef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:52:33 +0000 Subject: [PATCH 12/19] Update 7.0.14 changelog entry and package version Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- CHANGELOG.md | 9 ++++++++- python/pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 364fa47958df..ec0292b567a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [7.0.14] + +[7.0.14]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.14 + +### Added + +- Native CCF applications can now be written in Rust through a minimal API for registering endpoints and accessing raw-byte KV maps (#8200). + ## [7.0.13] [7.0.13]: https://github.com/microsoft/CCF/releases/tag/ccf-7.0.13 @@ -17,7 +25,6 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - C++ endpoints can now use `ccf::endpoints::Endpoint::add_openapi_response()` to document additional HTTP responses in their generated OpenAPI schema without changing the endpoint's primary success response (#8115). - New `ledger.max_transaction_size` node configuration option (default `32MB`), which caps the total serialised size of transactions written to the ledger. The limit covers the whole ledger entry: the fixed 8-byte ledger entry header, the ledger encryption header, public domain size field, public domain and encrypted private domain. It is checked before a transaction is applied, so an oversized transaction is now rejected with `413 Payload Too Large` and error code `TransactionTooLarge`, and subsequent transactions are unaffected, where previously an excessively large transaction could terminate the node. Reserved internal signature transactions are exempt because they must fill their reserved ledger version. The limit applies only to newly serialised non-reserved transactions; deserialising existing entries (including during recovery), historical queries and snapshots are unaffected, so entries written under a larger or unset limit remain readable. It must be smaller than `memory.max_msg_size` by at least the ring-buffer range response overhead, which is validated at node startup and by `--check` (#7992). -- Native CCF applications can now be written in Rust through a minimal API for registering endpoints and accessing raw-byte KV maps (#8200). ### Changed diff --git a/python/pyproject.toml b/python/pyproject.toml index 18462482f034..7529d0383b9b 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ccf" -version = "7.0.13" +version = "7.0.14" authors = [ { name="CCF Team", email="CCF-Sec@microsoft.com" }, ] From 04c7fd1b73d774fd37a8ffbea490e2526516e3b6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:55:27 +0000 Subject: [PATCH 13/19] Align EndpointError status validation with HTTP_STATUS_MAP Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/rust/ccf-app/src/lib.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/rust/ccf-app/src/lib.rs b/src/rust/ccf-app/src/lib.rs index 8f31bb49efb1..92082f5f8b4c 100644 --- a/src/rust/ccf-app/src/lib.rs +++ b/src/rust/ccf-app/src/lib.rs @@ -235,10 +235,29 @@ pub struct EndpointError { pub message: String, } +fn is_known_error_status(status: u16) -> bool { + matches!( + status, + 400..=426 + | 428..=431 + | 440 + | 444 + | 449..=451 + | 460 + | 463 + | 494..=499 + | 500..=511 + | 520..=527 + | 529..=530 + | 561 + | 598..=599 + ) +} + impl EndpointError { pub fn new(status: u16, code: impl Into, message: impl Into) -> Self { Self { - status: if (400..=599).contains(&status) { + status: if is_known_error_status(status) { status } else { 500 @@ -716,6 +735,8 @@ mod tests { fn normalizes_invalid_error_status() { assert_eq!(EndpointError::new(200, "Error", "message").status, 500); assert_eq!(EndpointError::new(404, "Error", "message").status, 404); + assert_eq!(EndpointError::new(432, "Error", "message").status, 500); + assert_eq!(EndpointError::new(600, "Error", "message").status, 500); } #[test] From 005256d5cbfe8550c81ad19f2c232c51c2595ff0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:55:35 +0000 Subject: [PATCH 14/19] Add Rust panic endpoint for app-boundary test coverage Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- samples/apps/basic_rust/src/lib.rs | 4 ++++ tests/basic_rust.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/samples/apps/basic_rust/src/lib.rs b/samples/apps/basic_rust/src/lib.rs index 248904b89ea2..0e3ddf54b11b 100644 --- a/samples/apps/basic_rust/src/lib.rs +++ b/samples/apps/basic_rust/src/lib.rs @@ -41,6 +41,10 @@ fn register(registry: &mut Registry) -> Result<(), BridgeError> { }, )?; + registry.read_only("/panic", "GET", Auth::None, |_| -> EndpointResult { + panic!("test panic") + })?; + registry.read_only("/health", "GET", Auth::None, |context| { context.set_status(200)?; context.set_body(b"OK")?; diff --git a/tests/basic_rust.py b/tests/basic_rust.py index 8dbb4eb5c833..9dd391a6a145 100644 --- a/tests/basic_rust.py +++ b/tests/basic_rust.py @@ -9,11 +9,14 @@ @reqs.description("Exercise Rust application endpoints and KV access") -@reqs.supports_methods("/app/health", "/app/records/{key}") +@reqs.supports_methods("/app/health", "/app/panic", "/app/records/{key}") def test_basic_rust(network, args): primary, _ = network.find_primary() with primary.client() as anonymous: + response = anonymous.get("/app/panic") + assert response.status_code == http.HTTPStatus.INTERNAL_SERVER_ERROR, response + response = anonymous.get("/app/health") assert response.status_code == http.HTTPStatus.OK, response assert response.body.data() == b"OK", response.body From 941258d0e4644787a2349991bc001bc502833053 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:56:59 +0000 Subject: [PATCH 15/19] Add comment linking is_known_error_status to HTTP_STATUS_MAP Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/rust/ccf-app/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/rust/ccf-app/src/lib.rs b/src/rust/ccf-app/src/lib.rs index 92082f5f8b4c..b7eaa3da2963 100644 --- a/src/rust/ccf-app/src/lib.rs +++ b/src/rust/ccf-app/src/lib.rs @@ -235,6 +235,7 @@ pub struct EndpointError { pub message: String, } +// Known 4xx/5xx HTTP error status codes matching HTTP_STATUS_MAP in include/ccf/http_status.h. fn is_known_error_status(status: u16) -> bool { matches!( status, From 7e966d2a0146b2a1ab4c5753ed2d2146732107f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Aug 2026 08:57:24 +0000 Subject: [PATCH 16/19] Finalize Rust panic-boundary validation and comment fix Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/rust/ccf-app/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/rust/ccf-app/src/lib.rs b/src/rust/ccf-app/src/lib.rs index b7eaa3da2963..3965d4426327 100644 --- a/src/rust/ccf-app/src/lib.rs +++ b/src/rust/ccf-app/src/lib.rs @@ -745,7 +745,9 @@ mod tests { let handler = Box::new(Handler::Write(Box::new(|_| panic!("test panic")))); let user_data = Box::into_raw(handler).cast::(); let raw_context = NonNull::::dangling().as_ptr(); - // SAFETY: Both pointers are valid for this direct trampoline test. + // SAFETY: In the test-only FFI stubs, the context pointer is never + // dereferenced. This exercises the panic trampoline without invoking + // any real C++ bridge logic. let result = unsafe { invoke_handler(user_data, raw_context) }; assert_eq!(result, RawResult::InternalError as i32); // SAFETY: The test retains ownership of the handler. From b4dec9656b289b540de0c606f2f8635982844a5e Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Sat, 29 Aug 2026 17:06:10 +0100 Subject: [PATCH 17/19] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- doc/build_apps/example_rust.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/build_apps/example_rust.rst b/doc/build_apps/example_rust.rst index 94d3b0b7f358..014258d95929 100644 --- a/doc/build_apps/example_rust.rst +++ b/doc/build_apps/example_rust.rst @@ -47,11 +47,11 @@ Handlers may run concurrently and must be ``Send`` and ``Sync``. CCF may also retry a read-write handler when a transaction conflicts, so handlers should be deterministic and should not perform non-transactional side effects. -Request, response, transaction, and map values borrow the callback context and -cannot be retained. The SDK requires Rust's ``unwind`` panic strategy so that -panics are caught at the ABI boundary and become HTTP 500 errors. Builds using -``panic = "abort"`` are rejected. C++ exceptions are also contained by the -bridge. +Request and response contexts, transactions, and map handles borrow the callback +context and cannot be retained. Values returned by KV ``get`` are owned copies. +The SDK requires Rust's ``unwind`` panic strategy so that panics are caught at +the ABI boundary and become HTTP 500 errors. Builds using ``panic = "abort"`` +are rejected. C++ exceptions are also contained by the bridge. KV values and keys ------------------ From 71ab22379f51a25a66e6a4535e54a277b0485729 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Wed, 2 Sep 2026 11:31:32 +0100 Subject: [PATCH 18/19] Ignore expected Rust panic-hook stderr in basic_rust test The /app/panic endpoint correctly returns HTTP 500, but Rust's default panic hook writes fixed lines to node stderr, which tests/infra/network.py treats as fatal at shutdown. Narrowly catch NetworkShutdownError and only ignore it when every stderr line matches the exact expected panic-hook output; anything else still fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- tests/basic_rust.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/tests/basic_rust.py b/tests/basic_rust.py index 9dd391a6a145..fb72b7bbc8b1 100644 --- a/tests/basic_rust.py +++ b/tests/basic_rust.py @@ -2,6 +2,7 @@ # Licensed under the Apache 2.0 License. import http +import re import infra.e2e_args import infra.network @@ -40,11 +41,30 @@ def test_basic_rust(network, args): def run(args): - with infra.network.network( - args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb - ) as network: - network.start_and_open(args) - test_basic_rust(network, args) + try: + with infra.network.network( + args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb + ) as network: + network.start_and_open(args) + test_basic_rust(network, args) + except infra.network.NetworkShutdownError as error: + # catch_unwind contains the panic, but Rust's default hook still writes + # the panic report to stderr. + fatal_errors = [ + line.strip() + for node_errors in (error.errors or {}).values() + for line in node_errors + if line.strip() + ] + assert len(fatal_errors) == 3, fatal_errors + assert re.fullmatch( + r"thread ''(?: \(\d+\))? panicked at src/lib\.rs:\d+:\d+:", + fatal_errors[0], + ), fatal_errors + assert fatal_errors[1:] == [ + "test panic", + "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace", + ], fatal_errors if __name__ == "__main__": From 5f9b287ee6829d54ca9748c80cbac7ae4a3cd6a3 Mon Sep 17 00:00:00 2001 From: Amaury Chamayou Date: Wed, 2 Sep 2026 13:09:02 +0100 Subject: [PATCH 19/19] Address Rust interface review findings Preserve test-body failures during expected panic teardown, validate response headers before serialization, and support Cargo library names that differ from package names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmake/ccf_app.cmake | 13 ++++- cmake/gersemi_definitions.cmake | 8 ++- doc/build_apps/example_rust.rst | 4 +- samples/apps/basic_rust/CMakeLists.txt | 1 + samples/apps/basic_rust/Cargo.toml | 1 + samples/apps/basic_rust/src/lib.rs | 18 +++++++ src/rust/app_bridge.cpp | 71 +++++++++++++++++++++++++- tests/basic_rust.py | 21 ++++++-- 8 files changed, 128 insertions(+), 9 deletions(-) diff --git a/cmake/ccf_app.cmake b/cmake/ccf_app.cmake index 2635b24be713..0f328f3b21a4 100644 --- a/cmake/ccf_app.cmake +++ b/cmake/ccf_app.cmake @@ -53,7 +53,13 @@ function(add_ccf_app name) endfunction() function(add_ccf_rust_app name) - cmake_parse_arguments(PARSE_ARGV 1 PARSED_ARGS "" "MANIFEST_PATH;PACKAGE" "") + cmake_parse_arguments( + PARSE_ARGV 1 + PARSED_ARGS + "" + "MANIFEST_PATH;PACKAGE;LIB_NAME" + "" + ) if(NOT PARSED_ARGS_MANIFEST_PATH) message(FATAL_ERROR "add_ccf_rust_app requires MANIFEST_PATH") @@ -61,6 +67,9 @@ function(add_ccf_rust_app name) if(NOT PARSED_ARGS_PACKAGE) set(PARSED_ARGS_PACKAGE ${name}) endif() + if(NOT PARSED_ARGS_LIB_NAME) + set(PARSED_ARGS_LIB_NAME ${PARSED_ARGS_PACKAGE}) + endif() find_program(CARGO NAMES cargo REQUIRED) find_program(RUSTC NAMES rustc REQUIRED) @@ -80,7 +89,7 @@ function(add_ccf_rust_app name) set(CARGO_PROFILE_DIR release) endif() - string(REPLACE "-" "_" RUST_LIB_NAME ${PARSED_ARGS_PACKAGE}) + string(REPLACE "-" "_" RUST_LIB_NAME ${PARSED_ARGS_LIB_NAME}) get_filename_component(MANIFEST_PATH ${PARSED_ARGS_MANIFEST_PATH} ABSOLUTE) get_filename_component(MANIFEST_DIR ${MANIFEST_PATH} DIRECTORY) set(CARGO_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/cargo/${name}) diff --git a/cmake/gersemi_definitions.cmake b/cmake/gersemi_definitions.cmake index ad77b87e3fad..85a8a10fd73f 100644 --- a/cmake/gersemi_definitions.cmake +++ b/cmake/gersemi_definitions.cmake @@ -16,7 +16,13 @@ function(add_ccf_app name) endfunction() function(add_ccf_rust_app name) - cmake_parse_arguments(PARSE_ARGV 1 PARSED_ARGS "" "MANIFEST_PATH;PACKAGE" "") + cmake_parse_arguments( + PARSE_ARGV 1 + PARSED_ARGS + "" + "MANIFEST_PATH;PACKAGE;LIB_NAME" + "" + ) endfunction() function(add_ccf_static_library name) diff --git a/doc/build_apps/example_rust.rst b/doc/build_apps/example_rust.rst index 014258d95929..6681a96b7fba 100644 --- a/doc/build_apps/example_rust.rst +++ b/doc/build_apps/example_rust.rst @@ -33,7 +33,9 @@ The helper maps CMake ``Debug`` builds to Cargo's development profile and all other build types to Cargo's release profile. It also links the generic C++ ABI bridge, launcher, and CCF libraries. Cargo is invoked on every build and decides whether the crate is up to date, so Rust source edits do not require CMake to be -reconfigured. The application should commit ``Cargo.lock`` and pin a Rust +reconfigured. ``LIB_NAME`` defaults to the package name with dashes replaced by +underscores; set it explicitly when the crate's ``[lib] name`` differs from its +package name. The application should commit ``Cargo.lock`` and pin a Rust toolchain for reproducible builds. The complete records example is in :ccf_repo:`samples/apps/basic_rust`. It diff --git a/samples/apps/basic_rust/CMakeLists.txt b/samples/apps/basic_rust/CMakeLists.txt index a2ba27b9f808..5ab68891a035 100644 --- a/samples/apps/basic_rust/CMakeLists.txt +++ b/samples/apps/basic_rust/CMakeLists.txt @@ -15,4 +15,5 @@ add_ccf_rust_app( basic_rust MANIFEST_PATH ${CMAKE_CURRENT_LIST_DIR}/Cargo.toml PACKAGE ccf-basic-rust + LIB_NAME ccf_basic_rust_app ) diff --git a/samples/apps/basic_rust/Cargo.toml b/samples/apps/basic_rust/Cargo.toml index 07c56e3c0431..f000f62b8511 100644 --- a/samples/apps/basic_rust/Cargo.toml +++ b/samples/apps/basic_rust/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2024" [lib] +name = "ccf_basic_rust_app" crate-type = ["staticlib"] [dependencies] diff --git a/samples/apps/basic_rust/src/lib.rs b/samples/apps/basic_rust/src/lib.rs index 0e3ddf54b11b..de083c8e6900 100644 --- a/samples/apps/basic_rust/src/lib.rs +++ b/samples/apps/basic_rust/src/lib.rs @@ -51,6 +51,24 @@ fn register(registry: &mut Registry) -> Result<(), BridgeError> { Ok(()) })?; + registry.read_only("/header-validation", "GET", Auth::None, |context| { + for (name, value) in [ + ("bad name", "value"), + ("bad\r\nname", "value"), + ("x-test", "bad\r\nx-injected: true"), + ("x-test", "bad\u{7f}"), + ] { + if context.set_header(name, value) != Err(BridgeError::InvalidArgument) { + return Err(EndpointError::internal( + "Invalid response header was accepted", + )); + } + } + context.set_header("x-valid", "safe\tvalue")?; + context.set_status(204)?; + Ok(()) + })?; + Ok(()) } diff --git a/src/rust/app_bridge.cpp b/src/rust/app_bridge.cpp index 2b7681cc6dd2..029fe2a8dcc6 100644 --- a/src/rust/app_bridge.cpp +++ b/src/rust/app_bridge.cpp @@ -94,6 +94,73 @@ namespace return value.data != nullptr || value.len == 0; } + bool is_http_header_name_character(uint8_t value) + { + if ( + (value >= '0' && value <= '9') || (value >= 'A' && value <= 'Z') || + (value >= 'a' && value <= 'z')) + { + return true; + } + + switch (value) + { + case '!': + case '#': + case '$': + case '%': + case '&': + case '\'': + case '*': + case '+': + case '-': + case '.': + case '^': + case '_': + case '`': + case '|': + case '~': + return true; + default: + return false; + } + } + + bool is_valid_http_header_name(const ccf_rust_slice& name) + { + if (name.data == nullptr || name.len == 0) + { + return false; + } + + for (size_t i = 0; i < name.len; ++i) + { + if (!is_http_header_name_character(name.data[i])) + { + return false; + } + } + return true; + } + + bool is_valid_http_header_value(const ccf_rust_slice& value) + { + if (!is_valid_utf8(value)) + { + return false; + } + + for (size_t i = 0; i < value.len; ++i) + { + const auto byte = value.data[i]; + if ((byte < 0x20 && byte != '\t') || byte == 0x7f) + { + return false; + } + } + return true; + } + bool is_known_http_status(uint16_t status) { switch (status) @@ -494,8 +561,8 @@ extern "C" ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice value) { if ( - ctx == nullptr || !is_valid_utf8(name) || name.len == 0 || - !is_valid_utf8(value)) + ctx == nullptr || !is_valid_http_header_name(name) || + !is_valid_http_header_value(value)) { return CCF_RUST_INVALID_ARGUMENT; } diff --git a/tests/basic_rust.py b/tests/basic_rust.py index fb72b7bbc8b1..1eb1e2908347 100644 --- a/tests/basic_rust.py +++ b/tests/basic_rust.py @@ -10,7 +10,12 @@ @reqs.description("Exercise Rust application endpoints and KV access") -@reqs.supports_methods("/app/health", "/app/panic", "/app/records/{key}") +@reqs.supports_methods( + "/app/header-validation", + "/app/health", + "/app/panic", + "/app/records/{key}", +) def test_basic_rust(network, args): primary, _ = network.find_primary() @@ -22,6 +27,9 @@ def test_basic_rust(network, args): assert response.status_code == http.HTTPStatus.OK, response assert response.body.data() == b"OK", response.body + response = anonymous.get("/app/header-validation") + assert response.status_code == http.HTTPStatus.NO_CONTENT, response + response = anonymous.get("/app/records/missing") assert response.status_code == http.HTTPStatus.UNAUTHORIZED, response @@ -41,12 +49,17 @@ def test_basic_rust(network, args): def run(args): + test_error = None try: with infra.network.network( args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb ) as network: - network.start_and_open(args) - test_basic_rust(network, args) + try: + network.start_and_open(args) + test_basic_rust(network, args) + except Exception as error: + test_error = error + raise except infra.network.NetworkShutdownError as error: # catch_unwind contains the panic, but Rust's default hook still writes # the panic report to stderr. @@ -65,6 +78,8 @@ def run(args): "test panic", "note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace", ], fatal_errors + if test_error is not None: + raise test_error if __name__ == "__main__":