diff --git a/CHANGELOG.md b/CHANGELOG.md index baf44e462b82..2c1dfa04780a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Native CCF applications can now be written in Rust through a minimal, experimental API for registering endpoints and accessing raw-byte KV maps (#8200). - COSE Sign1 verification now accepts the fully-specified ECDSA algorithm identifiers introduced by [RFC 9864](https://www.rfc-editor.org/rfc/rfc9864.html): `ESP256` (-9), `ESP384` (-51) and `ESP512` (-52), in addition to the deprecated `ES256` (-7), `ES384` (-35) and `ES512` (-36) they replace. `ESn` and `ESPn` are treated as equivalent for the curve they denote, which CCF already requires to match the verification key. Signatures produced by CCF continue to use the `ES` identifiers (#8267). ### Fixed diff --git a/CMakeLists.txt b/CMakeLists.txt index dee27f1f816e..8a4fc6dad627 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -174,6 +174,27 @@ 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/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 + RENAME app_main.cpp +) # Copy and install CCF utilities set(CCF_UTILITIES keygenerator.sh submit_recovery_share.sh) @@ -575,6 +596,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 @@ -1310,6 +1344,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/cmake/ccf_app.cmake b/cmake/ccf_app.cmake index 2f119beae2bf..0f328f3b21a4 100644 --- a/cmake/ccf_app.cmake +++ b/cmake/ccf_app.cmake @@ -52,6 +52,89 @@ function(add_ccf_app name) endif() endfunction() +function(add_ccf_rust_app name) + 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") + endif() + 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) + + 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_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}) + set( + RUST_APP_LIB + ${CARGO_TARGET_DIR}/${CARGO_PROFILE_DIR}/lib${RUST_LIB_NAME}.a + ) + + 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_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 + "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} + COMMENT "Building Rust CCF application ${name}" + USES_TERMINAL + VERBATIM + ) + + 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/cmake/gersemi_definitions.cmake b/cmake/gersemi_definitions.cmake index 471cf7395e16..7c8b8494b0a6 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;LIB_NAME" + "" + ) +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 new file mode 100644 index 000000000000..6681a96b7fba --- /dev/null +++ b/doc/build_apps/example_rust.rst @@ -0,0 +1,74 @@ +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 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 + + 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 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. ``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 +exports a registration function with ``ccf_app::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 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 +------------------ + +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. 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 +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/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/include/ccf/rust_ffi.h b/include/ccf/rust_ffi.h new file mode 100644 index 000000000000..18aa7e9d1a3e --- /dev/null +++ b/include/ccf/rust_ffi.h @@ -0,0 +1,129 @@ +// 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; + + struct ccf_rust_registry; + struct ccf_rust_endpoint_context; + struct ccf_rust_slice + { + const uint8_t* data; + size_t len; + }; + +#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 + +#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); + + ccf_rust_result ccf_rust_register_endpoint( + ccf_rust_registry* registry, + ccf_rust_slice path, + ccf_rust_slice method, + ccf_rust_auth auth, + int32_t read_only, + ccf_rust_endpoint_callback callback, + ccf_rust_drop_callback drop, + void* user_data); + + ccf_rust_result ccf_rust_request_body( + ccf_rust_endpoint_context* ctx, ccf_rust_slice* body); + ccf_rust_result ccf_rust_request_query( + ccf_rust_endpoint_context* ctx, ccf_rust_slice* query); + ccf_rust_result ccf_rust_request_path_param( + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value); + ccf_rust_result ccf_rust_request_header( + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice* value); + + 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); + ccf_rust_result ccf_rust_response_body( + ccf_rust_endpoint_context* ctx, ccf_rust_slice body); + ccf_rust_result ccf_rust_response_error( + ccf_rust_endpoint_context* ctx, + uint16_t status, + ccf_rust_slice code, + ccf_rust_slice message); + + 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); + ccf_rust_result ccf_rust_kv_has( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + 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); + 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); + ccf_rust_result ccf_rust_app_register(ccf_rust_registry* registry); + +#ifdef __cplusplus +} + +namespace ccf +{ + inline constexpr uint32_t rust_abi_version = CCF_RUST_ABI_VERSION; +} +#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..5ab68891a035 --- /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 + LIB_NAME ccf_basic_rust_app +) diff --git a/samples/apps/basic_rust/Cargo.lock b/samples/apps/basic_rust/Cargo.lock new file mode 100644 index 000000000000..80b5c8c99f8c --- /dev/null +++ b/samples/apps/basic_rust/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ccf-app" +version = "0.1.0" + +[[package]] +name = "ccf-basic-rust" +version = "0.1.0" +dependencies = [ + "ccf-app", +] diff --git a/samples/apps/basic_rust/Cargo.toml b/samples/apps/basic_rust/Cargo.toml new file mode 100644 index 000000000000..f000f62b8511 --- /dev/null +++ b/samples/apps/basic_rust/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ccf-basic-rust" +version = "0.1.0" +edition = "2024" + +[lib] +name = "ccf_basic_rust_app" +crate-type = ["staticlib"] + +[dependencies] +ccf-app = { path = "../../../src/rust/ccf-app" } + +[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..de083c8e6900 --- /dev/null +++ b/samples/apps/basic_rust/src/lib.rs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the Apache 2.0 License. + +use ccf_app::{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("/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")?; + 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(()) +} + +ccf_app::export_app!(register); 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 new file mode 100644 index 000000000000..029fe2a8dcc6 --- /dev/null +++ b/src/rust/app_bridge.cpp @@ -0,0 +1,758 @@ +// 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/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 +#include + +namespace +{ + using RawMap = + ccf::kv::RawCopySerialisedMap, std::vector>; + 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; + } + + 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) + { +#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) + { + return {}; + } + return {reinterpret_cast(value.data), value.len}; + } + + RawMap::Handle::KeyType to_bytes(const ccf_rust_slice& value) + { + if (value.len == 0) + { + return {}; + } + return {value.data, value.data + value.len}; + } + + std::vector to_vector(const ccf_rust_slice& value) + { + if (value.len == 0) + { + return {}; + } + return {value.data, value.data + value.len}; + } + + template + void set_slice(ccf_rust_slice* out, const T& value) + { + out->data = reinterpret_cast(value.data()); + out->len = value.size(); + } + + struct CallbackState + { + ccf_rust_endpoint_callback callback = nullptr; + ccf_rust_drop_callback drop = nullptr; + void* user_data = nullptr; + bool owns_user_data = false; + + ~CallbackState() + { + if (owns_user_data && drop != nullptr) + { + drop(user_data); + } + } + }; +} + +struct ccf_rust_registry +{ + RustEndpointRegistry* registry; +}; + +struct ccf_rust_endpoint_context +{ + 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; + std::optional compacted_version_conflict = + std::nullopt; + + 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; + } + + void rethrow_compacted_version_conflict() + { + if (compacted_version_conflict.has_value()) + { + throw std::move(compacted_version_conflict.value()); + } + } +}; + +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 + { + 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, + ccf::errors::InternalError, + "Rust endpoint execution failed"); + } + } + catch (const ccf::kv::CompactedVersionConflict&) + { + throw; + } + 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 + { + 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, + ccf::errors::InternalError, + "Rust endpoint execution failed"); + } + } + catch (const ccf::kv::CompactedVersionConflict&) + { + throw; + } + 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; + } + + ccf_rust_result ccf_rust_register_endpoint( + ccf_rust_registry* registry, + ccf_rust_slice path, + ccf_rust_slice method, + ccf_rust_auth auth, + int32_t 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); + state->owns_user_data = true; + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + ccf_rust_result 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; + } + } + + ccf_rust_result 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; + } + } + + 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)) + { + 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.clear(); + ctx->scratch.insert( + ctx->scratch.end(), it->second.begin(), it->second.end()); + set_slice(value, ctx->scratch); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + 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)) + { + 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.clear(); + ctx->scratch.insert(ctx->scratch.end(), header->begin(), header->end()); + set_slice(value, ctx->scratch); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + ccf_rust_result ccf_rust_response_status( + ccf_rust_endpoint_context* ctx, uint16_t status) + { + if (ctx == nullptr || !is_known_http_status(status)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + ctx->rpc->set_response_status(status); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + ccf_rust_result ccf_rust_response_header( + ccf_rust_endpoint_context* ctx, ccf_rust_slice name, ccf_rust_slice value) + { + if ( + ctx == nullptr || !is_valid_http_header_name(name) || + !is_valid_http_header_value(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; + } + } + + ccf_rust_result 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_vector(body)); + return CCF_RUST_OK; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + ccf_rust_result 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 || !is_known_http_status(status) || + !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; + } + } + + 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) + { + if ( + ctx == nullptr || value == nullptr || !is_valid_utf8(map_name) || + map_name.len == 0 || !is_valid_buffer(key)) + { + return CCF_RUST_INVALID_ARGUMENT; + } + try + { + 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 (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + ccf_rust_result ccf_rust_kv_has( + ccf_rust_endpoint_context* ctx, + ccf_rust_slice map_name, + ccf_rust_slice key, + int32_t* 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 (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + 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) + { + 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 (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } + catch (...) + { + return CCF_RUST_INTERNAL_ERROR; + } + } + + ccf_rust_result 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 (const ccf::kv::CompactedVersionConflict& e) + { + ctx->compacted_version_conflict = e; + return CCF_RUST_INTERNAL_ERROR; + } + 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/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..3965d4426327 --- /dev/null +++ b/src/rust/ccf-app/src/lib.rs @@ -0,0 +1,756 @@ +// 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. + +#[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}; +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, +} + +#[doc(hidden)] +pub const INTERNAL_ERROR_CODE: i32 = RawResult::InternalError as i32; + +#[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, +} + +// 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, + 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 is_known_error_status(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, + _ => $crate::INTERNAL_ERROR_CODE, + } + } + }; +} + +#[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); + assert_eq!(EndpointError::new(432, "Error", "message").status, 500); + assert_eq!(EndpointError::new(600, "Error", "message").status, 500); + } + + #[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: 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. + unsafe { drop_handler(user_data) }; + } +} diff --git a/tests/basic_rust.py b/tests/basic_rust.py new file mode 100644 index 000000000000..1eb1e2908347 --- /dev/null +++ b/tests/basic_rust.py @@ -0,0 +1,89 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the Apache 2.0 License. + +import http +import re + +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/header-validation", + "/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 + + 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 + + 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): + test_error = None + try: + with infra.network.network( + args.nodes, args.binary_dir, args.debug_nodes, pdb=args.pdb + ) as network: + 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. + 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 test_error is not None: + raise test_error + + +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) diff --git a/tests/ci-buckets.txt b/tests/ci-buckets.txt index c4e36f352548..8b6900966a82 100644 --- a/tests/ci-buckets.txt +++ b/tests/ci-buckets.txt @@ -21,6 +21,7 @@ bucket_c: governance_test code_update_test e2e_logging + basic_rust programmability_and_jwt e2e_limits e2e_redirects