diff options
49 files changed, 3508 insertions, 1203 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 0e34b7b..ca15a2f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,16 @@ set(CMAKE_C_STANDARD 17) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS OFF) +option(TINY_CONVMEM_ENABLE_ASAN "Enable AddressSanitizer for project and tests" OFF) +if(TINY_CONVMEM_ENABLE_ASAN) + if(NOT MSVC) + message(FATAL_ERROR "TINY_CONVMEM_ENABLE_ASAN currently supports MSVC only") + endif() + string(REPLACE "/RTC1" "" CMAKE_C_FLAGS_DEBUG "${CMAKE_C_FLAGS_DEBUG}") + add_compile_options(/fsanitize=address /Oy-) + add_link_options(/INCREMENTAL:NO) +endif() + include(CTest) include(cmake/mcpc.cmake) @@ -19,4 +29,3 @@ add_subdirectory(src) if(BUILD_TESTING) add_subdirectory(tests) endif() - diff --git a/CMakePresets.json b/CMakePresets.json index 4ee51a5..d1cd015 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -18,6 +18,16 @@ "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", "VCPKG_TARGET_TRIPLET": "x64-windows" } + }, + { + "name": "windows-vs2026-asan", + "displayName": "Windows x64 - Visual Studio 2026 ASan", + "description": "Debug build with MSVC AddressSanitizer enabled.", + "inherits": "windows-vs2026", + "binaryDir": "${sourceDir}/build/windows-vs2026-asan", + "cacheVariables": { + "TINY_CONVMEM_ENABLE_ASAN": "ON" + } } ], "buildPresets": [ @@ -30,6 +40,11 @@ "name": "windows-vs2026-release", "configurePreset": "windows-vs2026", "configuration": "Release" + }, + { + "name": "windows-vs2026-asan", + "configurePreset": "windows-vs2026-asan", + "configuration": "Debug" } ], "testPresets": [ @@ -48,6 +63,14 @@ "output": { "outputOnFailure": true } + }, + { + "name": "windows-vs2026-asan", + "configurePreset": "windows-vs2026-asan", + "configuration": "Debug", + "output": { + "outputOnFailure": true + } } ] } @@ -29,6 +29,18 @@ cmake --build --preset windows-vs2026-debug ctest --preset windows-vs2026-debug ``` +AddressSanitizer構成は独立した`build/windows-vs2026-asan`を使用します。 + +```powershell +cmake --preset windows-vs2026-asan +cmake --build --preset windows-vs2026-asan +ctest --preset windows-vs2026-asan +``` + +単体テストにはUTF-8/UTF-16、unaligned binary codec、allocation失敗、short I/Oと +操作別I/O失敗が含まれます。自前関数とテストの対応は +[function coverage map](tests/function-coverage.md)を参照してください。 + ## 起動 保存先は必ず明示します。相対パスも使用できます。同じdata directoryを使用する @@ -57,7 +69,7 @@ stdoutはMCP JSON-RPC専用、stderrは診断専用です。 ## 永続化と復旧 `active.log` はversion付きlittle-endianレコード、payload CRC32、単調sequenceを持ちます。 -PUT/DELは `FlushFileBuffers` が成功するまで `OK` を返しません。末尾で中断したレコード +PUT/DELはsys層のdurability flushが成功するまで`OK`を返しません。末尾で中断したレコード だけは正常境界まで自動切詰めし、途中のCRC不一致はデータを保存したまま起動を拒否します。 `rebuild_index` は新しいgeneration markerを持つログを作成後、同じgenerationとログoffsetを @@ -84,7 +96,7 @@ submoduleのソースは変更しません。詳細は[src/mcp/README.md](src/mc Visual Studio同梱のclang-formatを使う場合は、PowerShellで次のように整形できます。 ```powershell -$formatter = 'C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\Llvm\bin\clang-format.exe' +$formatter = 'C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\Llvm\x64\bin\clang-format.exe' $files = rg --files src tests -g '*.c' -g '*.h' & $formatter -i --style=file $files ``` diff --git a/docs/architecture.md b/docs/architecture.md index edf2dfa..9bd7c62 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,7 +3,7 @@ 設計項目ごとの実装・テスト状況は [設計・実装・テスト状況一覧](implementation-status.md)を参照してください。 -`tiny-convmem-mcp` is a single-process Windows C17 MCP server backed by a +`tiny-convmem-mcp` is a single-process C17 MCP server backed by a durable append-only conversation log. The executable owns the stdio dispatch boundary and uses mcpc's bundled mjson parser through a local compatibility layer. @@ -19,7 +19,9 @@ MCP transport and tools -> domain contracts <- storage adapters - `src/domain` owns conversation and message concepts without depending on mcpc or a database. - `src/storage` implements the versioned binary log, recovery, snapshot, and - compaction using Win32 file APIs. + compaction through the platform-neutral `src/sys` API. +- `src/sys` owns UTF-8 path conversion, files, process locks, durable flush, + clocks, and random bytes. Its implementation is the only platform split. ## Implemented prototype contract (v1) @@ -29,7 +31,7 @@ The six public tools are `write_message`, `read_messages`, `search_messages`, where it conflicts with this section, the implemented contract is authoritative. - `write_message` creates the ULID and UTC millisecond timestamp. A PUT or DEL - succeeds only after `FlushFileBuffers` succeeds. + succeeds only after the platform durability flush succeeds. - Deletion is immediately invisible but remains a tombstone until synchronous compaction. `metadata.deleted` is not part of immutable Message data. - Metadata is limited to optional `tool_call_id`, `tags`, and `memory_tier`. diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 9812ac9..80c8f96 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -22,8 +22,9 @@ | 構成 | ビルド | CTest | 実施日 | 結果 | |---|---|---|---|---| -| Windows x64 / MSVC / Debug | ✅ 実施済 | ✅ 実施済 | 2026-06-29 | 5/5成功 | -| Windows x64 / MSVC / Release | ✅ 実施済 | ✅ 実施済 | 2026-06-29 | 5/5成功 | +| Windows x64 / MSVC / Debug | ✅ 実施済 | ✅ 実施済 | 2026-06-29 | 12/12成功 | +| Windows x64 / MSVC / Release | ✅ 実施済 | ✅ 実施済 | 2026-06-29 | 12/12成功 | +| Windows x64 / MSVC / ASan | ✅ 実施済 | ✅ 実施済 | 2026-06-29 | 12/12成功 | | Windows以外 | ⬜ 未実施 | ⬜ 未実施 | ― | v1対象外 | 実行コマンド: @@ -33,6 +34,8 @@ cmake --build --preset windows-vs2026-debug ctest --preset windows-vs2026-debug cmake --build --preset windows-vs2026-release ctest --preset windows-vs2026-release +cmake --build --preset windows-vs2026-asan +ctest --preset windows-vs2026-asan ``` ## アーキテクチャとプロセス @@ -44,10 +47,11 @@ ctest --preset windows-vs2026-release | Domainのmcpc/Win32非依存 | ✅ 実装済 | `message.*`, `search.*` は標準Cのみ | ✅ 作成済 | ✅ Debug/Release | 依存方向の自動検査は未作成 | | Repository function table | ⬜ 未実装 | MCP層へ `tiny_store_t` を直接渡す | ⬜ 未作成 | ⬜ 未実施 | ストレージ差替え契約は未抽象化 | | `--data-dir <path>` 必須化 | ✅ 実装済 | 相対パスを絶対化し、不足時は終了コード2 | ✅ 作成済 | ✅ Debug/Release | `--help` の自動テストは未作成 | -| data directory自動作成 | ✅ 実装済 | `CreateDirectoryW` | 🟡 間接テスト | ✅ Debug/Release | 深い親ディレクトリの再帰作成は未実装 | +| data directory自動作成 | ✅ 実装済 | UTF-8 `tiny_sys_directory_create` | 🟡 間接テスト | ✅ Debug/Release | 深い親ディレクトリの再帰作成は未実装 | | data directory排他ロック | ✅ 実装済 | `store.lock` を共有なしで保持 | ✅ 作成済 | ✅ Debug/Release | 複数writerはv1対象外 | | stdoutをJSON-RPC専用、stderrを診断専用 | ✅ 実装済 | プロトコル応答と起動エラーを分離 | 🟡 間接テスト | ✅ Debug/Release | stderr汚染の専用テストは未作成 | -| Windows x64/MSVC対応 | ✅ 実装済 | Win32 file API、BCrypt、CMake preset | ✅ 作成済 | ✅ Debug/Release | Windows SDK差異のCIマトリクスは未作成 | +| OS依存処理の分離 | ✅ 実装済 | `src/sys/system.c`だけでWin32/POSIXを切替え、公開APIはUTF-8・opaque handle | ✅ 作成済 | ✅ Windows Debug/Release | POSIX backendのCI実行は未作成 | +| Windows x64/MSVC対応 | ✅ 実装済 | sys層のWin32 backendとCMake preset | ✅ 作成済 | ✅ Debug/Release | Windows SDK差異のCIマトリクスは未作成 | ## MCP互換境界と外部契約 @@ -137,7 +141,7 @@ ctest --preset windows-vs2026-release | generation marker | ✅ 実装済 | 🟡 lifecycleで間接確認 | ✅ Debug/Release | marker単独のbinary testは未作成 | | 64-bit file offset API | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 4 GiB超のsparse file試験は未作成 | | record長上限とoverflow防止 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | 巨大length/整数overflow fixtureが必要 | -| PUT/DEL後の `FlushFileBuffers` | ✅ 実装済 | 🟡 API成功経路のみ | ✅ Debug/Release | flush失敗fault injection、実電源断は未実施 | +| PUT/DEL後のdurability flush | ✅ 実装済 | 🟡 sys API成功経路のみ | ✅ Debug/Release | flush失敗fault injection、実電源断は未実施 | | 成功前のメモリ確保 | ✅ 実装済 | ⬜ 未作成 | ⬜ 未実施 | allocation failure注入は未作成 | | OS書込み後・flush前クラッシュ | 🟡 コード上復旧可能 | ⬜ 未作成 | ⬜ 未実施 | プロセス強制終了/電源断試験が必要 | | record offset/lengthだけを保持するIndexEntry | ⬜ 未実装 | ⬜ 未作成 | ⬜ 未実施 | 現在はMessage全体をメモリ保持 | @@ -196,6 +200,8 @@ ctest --preset windows-vs2026-release | `tiny-convmem-mcp.storage` | Unit/Integration | ✅ 作成済 | ✅ 成功 | ✅ 成功 | lock、write/reopen、ULID順、temp失敗、CRC破損拒否 | | `tiny-convmem-mcp.lifecycle` | MCP/Storage E2E | ✅ 作成済 | ✅ 成功 | ✅ 成功 | delete、不可視化、compaction、本文除去、不正snapshot、末尾復旧 | | `tiny-convmem-mcp.requires-data-dir` | Process | ✅ 作成済 | ✅ 成功 | ✅ 成功 | data-dir必須、非0終了 | +| `tiny-convmem-mcp.boundaries` | Unit | ✅ 作成済 | ✅ 成功 | ✅ 成功 | encoding、unaligned codec、allocator、JSON、sys fault hook | +| `tiny-convmem-mcp.audit` | Static | ✅ 作成済 | ✅ 成功 | ✅ 成功 | clang-format、diff check、goto、OS依存隔離 | ## 未実装・テスト不足の優先バックログ @@ -224,4 +230,4 @@ ctest --preset windows-vs2026-release | pagination | ➖ 対象外 | `read_messages` は現状フル履歴契約 | | 複数writer/network server | ➖ 対象外 | data directoryを単一プロセスが排他所有 | | SSD/バックアップの安全消去 | ➖ 対象外 | filesystem compactionだけでは保証不能 | -| Linux/macOS | ➖ 対象外 | Win32 durability/atomic replacementを採用 | +| Linux/macOS | 🟡 backend実装・未保証 | POSIX file/lock/clock/random実装あり。CI・実機検証は未実施 | diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e7768d7..e441c55 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,19 +1,44 @@ +add_library(tiny-convmem-base STATIC base/allocator.c) +target_include_directories(tiny-convmem-base PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_features(tiny-convmem-base PUBLIC c_std_17) + +add_library(tiny-convmem-sys STATIC sys/system.c sys/encoding.c) +target_include_directories(tiny-convmem-sys PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}") +target_compile_features(tiny-convmem-sys PUBLIC c_std_17) +target_link_libraries(tiny-convmem-sys PUBLIC tiny-convmem-base) + +if(WIN32) + target_link_libraries(tiny-convmem-sys PRIVATE bcrypt shell32) +endif() + add_executable(tiny-convmem-mcp app/main.c domain/message.c domain/search.c + storage/codec.c storage/store.c mcp/json.c mcp/server.c + mcp/tools/common/common.c + mcp/tools/common/context.c + mcp/tools/common/registry.c + mcp/tools/delete_message.c + mcp/tools/get_system_health.c + mcp/tools/read_messages.c + mcp/tools/rebuild_index.c + mcp/tools/search_messages.c + mcp/tools/write_message.c ) target_include_directories(tiny-convmem-mcp PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" "${PROJECT_SOURCE_DIR}/third_party/mcpc/src" ) -target_link_libraries(tiny-convmem-mcp PRIVATE mcpc::mcpc bcrypt) +target_link_libraries(tiny-convmem-mcp PRIVATE mcpc::mcpc tiny-convmem-sys) target_compile_features(tiny-convmem-mcp PRIVATE c_std_17) if(MSVC) + target_compile_options(tiny-convmem-base PRIVATE /utf-8 /W4) + target_compile_options(tiny-convmem-sys PRIVATE /utf-8 /W4) target_compile_options(tiny-convmem-mcp PRIVATE /utf-8 /W4) endif() diff --git a/src/app/main.c b/src/app/main.c index ce43b61..f73b314 100644 --- a/src/app/main.c +++ b/src/app/main.c @@ -1,43 +1,41 @@ -#define WIN32_LEAN_AND_MEAN -#include <windows.h> - #include <stdio.h> #include <stdlib.h> -#include <wchar.h> +#include <string.h> #include "mcp/server.h" #include "storage/store.h" +#include "sys/system.h" + +#include "base/allocator.h" static void usage(FILE *stream) { - fwprintf(stream, L"Usage: tiny-convmem-mcp --data-dir <path>\n"); + fprintf(stream, "Usage: tiny-convmem-mcp --data-dir <path>\n"); } -int -wmain(int argc, wchar_t **argv) +static int +run(const tiny_sys_arguments_t *arguments) { - wchar_t *absolute = NULL; - DWORD required; + char *absolute; tiny_store_t *store = NULL; tiny_store_status_t status; int result; - if (argc == 2 && wcscmp(argv[1], L"--help") == 0) + if (arguments->count == 2 && strcmp(arguments->values[1], "--help") == 0) { usage(stdout); return 0; } - if (argc != 3 || wcscmp(argv[1], L"--data-dir") != 0 || argv[2][0] == L'\0') + if (arguments->count != 3 || strcmp(arguments->values[1], "--data-dir") != 0 + || arguments->values[2][0] == '\0') { usage(stderr); return 2; } - required = GetFullPathNameW(argv[2], 0, NULL, NULL); - if (required == 0 || (absolute = malloc((size_t)required * sizeof(wchar_t))) == NULL - || GetFullPathNameW(argv[2], required, absolute, NULL) == 0) + absolute = tiny_sys_path_absolute(arguments->values[2]); + if (absolute == NULL) { fprintf(stderr, "unable to resolve --data-dir\n"); - free(absolute); return 2; } status = tiny_store_open(&store, absolute); @@ -55,3 +53,18 @@ wmain(int argc, wchar_t **argv) } return result; } + +int +main(int argc, char **argv) +{ + tiny_sys_arguments_t arguments; + int result; + if (!tiny_sys_arguments_init(&arguments, argc, argv)) + { + fprintf(stderr, "unable to read process arguments\n"); + return 2; + } + result = run(&arguments); + tiny_sys_arguments_free(&arguments); + return result; +} diff --git a/src/base/allocator.c b/src/base/allocator.c new file mode 100644 index 0000000..3a5c515 --- /dev/null +++ b/src/base/allocator.c @@ -0,0 +1,107 @@ +#define TINY_ALLOCATOR_IMPLEMENTATION +#include "base/allocator.h" + +#include <stdint.h> +#include <stdlib.h> + +static size_t fail_index = SIZE_MAX; +static size_t calls; +static size_t outstanding; + +static int +should_fail(void) +{ + return calls++ == fail_index; +} + +void * +tiny_alloc(size_t size) +{ + void *result; + if (should_fail()) + { + return NULL; + } + result = malloc(size); + if (result != NULL) + { + outstanding++; + } + return result; +} + +void * +tiny_calloc(size_t count, size_t size) +{ + void *result; + if ((size != 0 && count > SIZE_MAX / size) || should_fail()) + { + return NULL; + } + result = calloc(count, size); + if (result != NULL) + { + outstanding++; + } + return result; +} + +void * +tiny_realloc(void *memory, size_t size) +{ + void *result; + if (size == 0) + { + tiny_free(memory); + return NULL; + } + if (should_fail()) + { + return NULL; + } + result = realloc(memory, size); + if (result != NULL && memory == NULL) + { + outstanding++; + } + return result; +} + +void +tiny_free(void *memory) +{ + if (memory != NULL) + { + free(memory); + if (outstanding > 0) + { + outstanding--; + } + } +} + +void +tiny_allocator_reset(void) +{ + fail_index = SIZE_MAX; + calls = 0; +} + +void +tiny_allocator_fail_after(size_t allocation_index) +{ + fail_index = allocation_index; + calls = 0; +} + +size_t +tiny_allocator_call_count(void) +{ + return calls; +} + +size_t +tiny_allocator_outstanding(void) +{ + return outstanding; +} diff --git a/src/base/allocator.h b/src/base/allocator.h new file mode 100644 index 0000000..626e294 --- /dev/null +++ b/src/base/allocator.h @@ -0,0 +1,23 @@ +#ifndef TINY_CONVMEM_BASE_ALLOCATOR_H +#define TINY_CONVMEM_BASE_ALLOCATOR_H + +#include <stddef.h> + +void *tiny_alloc(size_t size); +void *tiny_calloc(size_t count, size_t size); +void *tiny_realloc(void *memory, size_t size); +void tiny_free(void *memory); + +void tiny_allocator_reset(void); +void tiny_allocator_fail_after(size_t allocation_index); +size_t tiny_allocator_call_count(void); +size_t tiny_allocator_outstanding(void); + +#ifndef TINY_ALLOCATOR_IMPLEMENTATION +#define malloc tiny_alloc +#define calloc tiny_calloc +#define realloc tiny_realloc +#define free tiny_free +#endif + +#endif diff --git a/src/domain/message.c b/src/domain/message.c index d973be4..b1db3e1 100644 --- a/src/domain/message.c +++ b/src/domain/message.c @@ -3,6 +3,8 @@ #include <stdlib.h> #include <string.h> +#include "base/allocator.h" + static char * duplicate_string(const char *value) { @@ -15,9 +17,20 @@ duplicate_string(const char *value) return copy; } +static int +copy_failed(tiny_message_t *message) +{ + tiny_message_free(message); + return -1; +} + void tiny_message_init(tiny_message_t *message) { + if (message == NULL) + { + return; + } memset(message, 0, sizeof(*message)); message->metadata.memory_tier = TINY_TIER_SHORT; } @@ -26,6 +39,10 @@ void tiny_message_free(tiny_message_t *message) { size_t index; + if (message == NULL) + { + return; + } free(message->session_id); free(message->content); free(message->metadata.tool_call_id); @@ -41,6 +58,12 @@ int tiny_message_copy(tiny_message_t *destination, const tiny_message_t *source) { size_t index; + if (destination == NULL || source == NULL || destination == source || source->session_id == NULL + || source->content == NULL + || (source->metadata.tag_count != 0 && source->metadata.tags == NULL)) + { + return -1; + } tiny_message_init(destination); memcpy(destination->message_id, source->message_id, sizeof(destination->message_id)); destination->timestamp_ms = source->timestamp_ms; @@ -50,14 +73,14 @@ tiny_message_copy(tiny_message_t *destination, const tiny_message_t *source) destination->content = duplicate_string(source->content); if (destination->session_id == NULL || destination->content == NULL) { - goto failure; + return copy_failed(destination); } if (source->metadata.tool_call_id != NULL) { destination->metadata.tool_call_id = duplicate_string(source->metadata.tool_call_id); if (destination->metadata.tool_call_id == NULL) { - goto failure; + return copy_failed(destination); } } if (source->metadata.tag_count > 0) @@ -65,23 +88,19 @@ tiny_message_copy(tiny_message_t *destination, const tiny_message_t *source) destination->metadata.tags = calloc(source->metadata.tag_count, sizeof(char *)); if (destination->metadata.tags == NULL) { - goto failure; + return copy_failed(destination); } for (index = 0; index < source->metadata.tag_count; ++index) { destination->metadata.tags[index] = duplicate_string(source->metadata.tags[index]); if (destination->metadata.tags[index] == NULL) { - goto failure; + return copy_failed(destination); } destination->metadata.tag_count++; } } return 0; - -failure: - tiny_message_free(destination); - return -1; } const char * @@ -103,6 +122,10 @@ tiny_role_name(tiny_role_t role) bool tiny_role_parse(const char *value, tiny_role_t *role) { + if (value == NULL || role == NULL) + { + return false; + } if (strcmp(value, "user") == 0) { *role = TINY_ROLE_USER; @@ -143,6 +166,10 @@ tiny_tier_name(tiny_memory_tier_t tier) bool tiny_tier_parse(const char *value, tiny_memory_tier_t *tier) { + if (value == NULL || tier == NULL) + { + return false; + } if (strcmp(value, "short") == 0) { *tier = TINY_TIER_SHORT; @@ -170,6 +197,10 @@ bool tiny_utf8_valid(const char *value, size_t length) { size_t index = 0; + if (value == NULL) + { + return length == 0; + } while (index < length) { const unsigned char first = (unsigned char)value[index++]; @@ -240,7 +271,7 @@ tiny_message_id_valid(const char *value) { static const char alphabet[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; size_t index; - if (strlen(value) != TINY_MESSAGE_ID_LENGTH || value[0] > '7') + if (value == NULL || strlen(value) != TINY_MESSAGE_ID_LENGTH || value[0] > '7') { return false; } diff --git a/src/domain/search.c b/src/domain/search.c index a24b667..b0ac440 100644 --- a/src/domain/search.c +++ b/src/domain/search.c @@ -4,6 +4,8 @@ #include <stdlib.h> #include <string.h> +#include "base/allocator.h" + typedef struct gram_bucket { uint64_t hash; @@ -25,9 +27,25 @@ struct tiny_search_index static bool decode_codepoints(const char *text, uint32_t **output, size_t *count) { - const size_t bytes = strlen(text); + size_t bytes; size_t offset = 0, used = 0; - uint32_t *points = malloc((bytes + 1) * sizeof(uint32_t)); + uint32_t *points; + if (output == NULL || count == NULL) + { + return false; + } + *output = NULL; + *count = 0; + if (text == NULL) + { + return false; + } + bytes = strlen(text); + if (!tiny_utf8_valid(text, bytes) || bytes > SIZE_MAX / sizeof(uint32_t) - 1) + { + return false; + } + points = malloc((bytes + 1) * sizeof(uint32_t)); if (points == NULL) { return false; @@ -198,6 +216,12 @@ tiny_search_index_add(tiny_search_index_t *index, size_t message_index) { size_t tag; + if (index == NULL || message == NULL || message->content == NULL + || (message->metadata.tag_count != 0 && message->metadata.tags == NULL) + || message_index == SIZE_MAX) + { + return false; + } if (!index_text(index, message->content, message_index)) { return false; @@ -226,8 +250,16 @@ tiny_search_index_candidates(const tiny_search_index_t *index, size_t point_count = 0, width, offset, used = 0; bool *seen; size_t *items; + if (output == NULL || count == NULL) + { + return false; + } *output = NULL; *count = 0; + if (index == NULL || query == NULL || index->message_count > SIZE_MAX / sizeof(*items)) + { + return false; + } if (!decode_codepoints(query, &points, &point_count)) { return false; @@ -283,10 +315,20 @@ tiny_search_score(const char *query, const tiny_message_t *message) size_t query_count = 0, message_count = 0, width, query_offset, message_offset, matches = 0, total, tag; double score = 0.0; - if (!decode_codepoints(query, &query_points, &query_count) - || !decode_codepoints(message->content, &message_points, &message_count) || query_count == 0) + if (query == NULL || message == NULL || message->content == NULL + || (message->metadata.tag_count != 0 && message->metadata.tags == NULL)) + { + return 0.0; + } + if (!decode_codepoints(query, &query_points, &query_count) || query_count == 0) + { + free(query_points); + return 0.0; + } + if (!decode_codepoints(message->content, &message_points, &message_count)) { - goto done; + free(query_points); + return 0.0; } width = query_count >= 3 ? 3 : (query_count >= 2 ? 2 : 1); total = query_count - width + 1; @@ -343,7 +385,6 @@ tiny_search_score(const char *query, const tiny_message_t *message) } } } -done: free(query_points); free(message_points); return score; diff --git a/src/mcp/README.md b/src/mcp/README.md index dc7ac46..ed008a0 100644 --- a/src/mcp/README.md +++ b/src/mcp/README.md @@ -1,14 +1,14 @@ -# MCP compatibility boundary +# MCP adapter -The pinned mcpc revision cannot describe required versus optional properties, -nested objects/arrays, 64-bit values, or application callback context. The -project therefore keeps mcpc immutable and implements the stdio dispatch and -tool schemas in `server.c`, using mcpc's bundled `mjson` parser for raw JSON. +`server.c` owns only the mcpc server lifecycle. Tool schemas and callbacks live +under `tools/`, one source file per memory tool. Shared registration, callback +context, and response helpers live under `tools/common/`. mcpc owns stdio +framing, MCP initialization, dispatch, and result envelopes. -This boundary supports the MCP 2024-11-05 initialize, tools/list, and tools/call -flow needed by the server. Tool success and application errors are returned as -JSON serialized into TextContent; application errors also set `isError`. +The pinned mcpc API supports integer and UTF-8 string properties, but not nested +or optional properties. Consequently every advertised property must be sent. +Use an empty `session_id` where that filter is optional, and pass write metadata +as a JSON object string in `metadata_json` (`{}` when no metadata is needed). -If mcpc is upgraded, this boundary should be removed only after its integration -tests demonstrate equivalent optional/object/array schema and callback-state -behavior. +Tool success and application errors are JSON serialized into MCP TextContent; +application errors also set `isError`. diff --git a/src/mcp/json.c b/src/mcp/json.c index dcef91b..62f31c9 100644 --- a/src/mcp/json.c +++ b/src/mcp/json.c @@ -4,11 +4,17 @@ #include <stdlib.h> #include <string.h> +#include "base/allocator.h" + static bool reserve(tiny_json_buffer_t *buffer, size_t addition) { size_t needed, capacity; char *resized; + if (buffer == NULL) + { + return false; + } if (buffer->failed || addition > SIZE_MAX - buffer->length - 1) { return false; @@ -42,11 +48,18 @@ reserve(tiny_json_buffer_t *buffer, size_t addition) void tiny_json_init(tiny_json_buffer_t *buffer) { - memset(buffer, 0, sizeof(*buffer)); + if (buffer != NULL) + { + memset(buffer, 0, sizeof(*buffer)); + } } void tiny_json_free(tiny_json_buffer_t *buffer) { + if (buffer == NULL) + { + return; + } free(buffer->data); tiny_json_init(buffer); } @@ -54,7 +67,7 @@ tiny_json_free(tiny_json_buffer_t *buffer) bool tiny_json_raw_n(tiny_json_buffer_t *buffer, const char *value, size_t length) { - if (!reserve(buffer, length)) + if ((value == NULL && length != 0) || !reserve(buffer, length)) { return false; } @@ -66,14 +79,14 @@ tiny_json_raw_n(tiny_json_buffer_t *buffer, const char *value, size_t length) bool tiny_json_raw(tiny_json_buffer_t *buffer, const char *value) { - return tiny_json_raw_n(buffer, value, strlen(value)); + return value != NULL && tiny_json_raw_n(buffer, value, strlen(value)); } bool tiny_json_string(tiny_json_buffer_t *buffer, const char *value) { const unsigned char *position = (const unsigned char *)value; - if (!tiny_json_raw(buffer, "\"")) + if (value == NULL || !tiny_json_raw(buffer, "\"")) { return false; } @@ -85,36 +98,60 @@ tiny_json_string(tiny_json_buffer_t *buffer, const char *value) escaped[0] = '\\'; escaped[1] = (char)*position; escaped[2] = '\0'; - tiny_json_raw(buffer, escaped); + if (!tiny_json_raw(buffer, escaped)) + { + return false; + } } else if (*position == '\b') { - tiny_json_raw(buffer, "\\b"); + if (!tiny_json_raw(buffer, "\\b")) + { + return false; + } } else if (*position == '\f') { - tiny_json_raw(buffer, "\\f"); + if (!tiny_json_raw(buffer, "\\f")) + { + return false; + } } else if (*position == '\n') { - tiny_json_raw(buffer, "\\n"); + if (!tiny_json_raw(buffer, "\\n")) + { + return false; + } } else if (*position == '\r') { - tiny_json_raw(buffer, "\\r"); + if (!tiny_json_raw(buffer, "\\r")) + { + return false; + } } else if (*position == '\t') { - tiny_json_raw(buffer, "\\t"); + if (!tiny_json_raw(buffer, "\\t")) + { + return false; + } } else if (*position < 0x20) { snprintf(escaped, sizeof(escaped), "\\u%04x", *position); - tiny_json_raw(buffer, escaped); + if (!tiny_json_raw(buffer, escaped)) + { + return false; + } } else { - tiny_json_raw_n(buffer, (const char *)position, 1); + if (!tiny_json_raw_n(buffer, (const char *)position, 1)) + { + return false; + } } ++position; } diff --git a/src/mcp/server.c b/src/mcp/server.c index 80d8307..a0ff436 100644 --- a/src/mcp/server.c +++ b/src/mcp/server.c @@ -1,757 +1,37 @@ #include "mcp/server.h" -#include "domain/search.h" -#include "mcp/json.h" -#include "mjson.h" +#include "mcp/tools/common/tools.h" -#include <math.h> -#include <stdint.h> -#include <stdlib.h> -#include <string.h> - -#define MCP_PROTOCOL_VERSION "2024-11-05" - -typedef struct result_item -{ - const tiny_store_entry_t *entry; - double score; -} result_item_t; - -static char * -read_line(FILE *input) -{ - size_t length = 0, capacity = 4096; - int character; - char *line = malloc(capacity); - if (!line) - { - return NULL; - } - while ((character = fgetc(input)) != EOF) - { - if (length + 1 >= capacity) - { - char *r; - capacity *= 2; - r = realloc(line, capacity); - if (!r) - { - free(line); - return NULL; - } - line = r; - } - line[length++] = (char)character; - if (character == '\n') - { - break; - } - } - if (length == 0 && character == EOF) - { - free(line); - return NULL; - } - line[length] = '\0'; - return line; -} - -static char * -json_string_value( - const char *json, int length, const char *path, size_t maximum, bool required, bool *valid) -{ - const char *token = NULL; - int token_length = 0; - int type = mjson_find(json, length, path, &token, &token_length); - char *value; - int decoded; - if (type == MJSON_TOK_INVALID) - { - if (required) - { - *valid = false; - } - return NULL; - } - if (type != MJSON_TOK_STRING || token_length < 2 || (size_t)token_length > maximum * 6 + 2) - { - *valid = false; - return NULL; - } - value = malloc(maximum + 1); - if (!value) - { - *valid = false; - return NULL; - } - decoded = mjson_get_string(json, length, path, value, (int)maximum + 1); - if (decoded < 0 || (size_t)decoded > maximum || !tiny_utf8_valid(value, (size_t)decoded)) - { - free(value); - *valid = false; - return NULL; - } - value[decoded] = '\0'; - return value; -} - -static bool -object_has_only(const char *json, int length, const char *const *allowed, size_t allowed_count) -{ - int offset = 0, koff, klen, voff, vlen, vtype; - while ((offset = mjson_next(json, length, offset, &koff, &klen, &voff, &vlen, &vtype)) != 0) - { - char key[64]; - int decoded; - size_t index; - if (klen <= 0) - { - return false; - } - decoded = mjson_get_string(json + koff, klen, "$", key, sizeof(key)); - if (decoded <= 0 || decoded >= (int)sizeof(key)) - { - return false; - } - key[decoded] = '\0'; - for (index = 0; index < allowed_count; ++index) - { - if (strcmp(key, allowed[index]) == 0) - { - break; - } - } - if (index == allowed_count) - { - return false; - } - } - return true; -} - -static void -free_input_message(tiny_message_t *message) -{ - tiny_message_free(message); -} - -static bool -parse_tags(const char *json, int length, tiny_message_t *message) -{ - const char *array = NULL; - int array_length = 0; - int type = mjson_find(json, length, "$.metadata.tags", &array, &array_length); - int offset = 0, koff, klen, voff, vlen, vtype; - if (type == MJSON_TOK_INVALID) - { - return true; - } - if (type != MJSON_TOK_ARRAY) - { - return false; - } - while ((offset = mjson_next(array, array_length, offset, &koff, &klen, &voff, &vlen, &vtype)) - != 0) - { - char *tag; - int decoded; - char **resized; - if (vtype != MJSON_TOK_STRING || message->metadata.tag_count >= TINY_TAG_COUNT_MAX) - { - return false; - } - tag = malloc(TINY_TAG_MAX + 1); - if (!tag) - { - return false; - } - decoded = mjson_get_string(array + voff, vlen, "$", tag, TINY_TAG_MAX + 1); - if (decoded <= 0 || decoded > TINY_TAG_MAX || !tiny_utf8_valid(tag, (size_t)decoded)) - { - free(tag); - return false; - } - tag[decoded] = '\0'; - resized = realloc(message->metadata.tags, (message->metadata.tag_count + 1) * sizeof(char *)); - if (!resized) - { - free(tag); - return false; - } - message->metadata.tags = resized; - message->metadata.tags[message->metadata.tag_count++] = tag; - } - return true; -} - -static bool -parse_write(const char *args, int length, tiny_message_t *message) -{ - static const char *const top_keys[] = { "session_id", "role", "content", "metadata" }; - static const char *const metadata_keys[] = { "tool_call_id", "tags", "memory_tier" }; - bool valid = true; - char *role = NULL, *tier = NULL; - const char *metadata = NULL; - int metadata_length = 0, metadata_type; - tiny_message_init(message); - if (!object_has_only(args, length, top_keys, sizeof(top_keys) / sizeof(top_keys[0]))) - { - valid = false; - } - metadata_type = mjson_find(args, length, "$.metadata", &metadata, &metadata_length); - if (metadata_type != MJSON_TOK_INVALID - && (metadata_type != MJSON_TOK_OBJECT - || !object_has_only(metadata, - metadata_length, - metadata_keys, - sizeof(metadata_keys) / sizeof(metadata_keys[0])))) - { - valid = false; - } - message->session_id - = json_string_value(args, length, "$.session_id", TINY_SESSION_ID_MAX, true, &valid); - role = json_string_value(args, length, "$.role", 16, true, &valid); - message->content = json_string_value(args, length, "$.content", TINY_CONTENT_MAX, true, &valid); - message->metadata.tool_call_id = json_string_value( - args, length, "$.metadata.tool_call_id", TINY_TOOL_CALL_ID_MAX, false, &valid); - tier = json_string_value(args, length, "$.metadata.memory_tier", 16, false, &valid); - if (!valid || !message->session_id || !message->content || !role || message->session_id[0] == '\0' - || message->content[0] == '\0' || !tiny_role_parse(role, &message->role) - || (tier && !tiny_tier_parse(tier, &message->metadata.memory_tier)) - || !parse_tags(args, length, message)) - { - valid = false; - } - free(role); - free(tier); - if (!valid) - { - free_input_message(message); - } - return valid; -} - -static void -append_message(tiny_json_buffer_t *json, const tiny_message_t *message) -{ - size_t index; - tiny_json_raw(json, "{\"message_id\":"); - tiny_json_string(json, message->message_id); - tiny_json_raw(json, ",\"session_id\":"); - tiny_json_string(json, message->session_id); - tiny_json_raw(json, ",\"timestamp\":"); - tiny_json_i64(json, message->timestamp_ms); - tiny_json_raw(json, ",\"role\":"); - tiny_json_string(json, tiny_role_name(message->role)); - tiny_json_raw(json, ",\"content\":"); - tiny_json_string(json, message->content); - tiny_json_raw(json, ",\"metadata\":{"); - if (message->metadata.tool_call_id) - { - tiny_json_raw(json, "\"tool_call_id\":"); - tiny_json_string(json, message->metadata.tool_call_id); - tiny_json_raw(json, ","); - } - tiny_json_raw(json, "\"tags\":["); - for (index = 0; index < message->metadata.tag_count; ++index) - { - if (index) - { - tiny_json_raw(json, ","); - } - tiny_json_string(json, message->metadata.tags[index]); - } - tiny_json_raw(json, "],\"memory_tier\":"); - tiny_json_string(json, tiny_tier_name(message->metadata.memory_tier)); - tiny_json_raw(json, "}}"); -} - -static void -send_json_result( - FILE *output, const char *id, int id_length, tiny_json_buffer_t *inner, bool is_error) -{ - tiny_json_buffer_t response; - tiny_json_init(&response); - tiny_json_raw(&response, "{\"jsonrpc\":\"2.0\",\"id\":"); - tiny_json_raw_n(&response, id, (size_t)id_length); - tiny_json_raw(&response, ",\"result\":{\"content\":[{\"type\":\"text\",\"text\":"); - tiny_json_string(&response, inner->data ? inner->data : "{}"); - tiny_json_raw(&response, "}]"); - if (is_error) - { - tiny_json_raw(&response, ",\"isError\":true"); - } - tiny_json_raw(&response, "}}\n"); - if (!response.failed) - { - fwrite(response.data, 1, response.length, output); - fflush(output); - } - tiny_json_free(&response); -} - -static void -send_error(FILE *output, const char *id, int id_length, const char *code, const char *message) -{ - tiny_json_buffer_t inner; - tiny_json_init(&inner); - tiny_json_raw(&inner, "{\"status\":\"ERROR\",\"error\":{\"code\":"); - tiny_json_string(&inner, code); - tiny_json_raw(&inner, ",\"message\":"); - tiny_json_string(&inner, message); - tiny_json_raw(&inner, "}}"); - send_json_result(output, id, id_length, &inner, true); - tiny_json_free(&inner); -} - -static int -compare_history(const void *left, const void *right) -{ - const tiny_store_entry_t *a = *(const tiny_store_entry_t *const *)left, - *b = *(const tiny_store_entry_t *const *)right; - if (a->message.timestamp_ms < b->message.timestamp_ms) - { - return -1; - } - if (a->message.timestamp_ms > b->message.timestamp_ms) - { - return 1; - } - return strcmp(a->message.message_id, b->message.message_id); -} - -static int -compare_results(const void *l, const void *r) +int +tiny_convmem_server_run(FILE *input, FILE *output, tiny_store_t *store) { - const result_item_t *a = l, *b = r; - if (a->score > b->score) - { - return -1; - } - if (a->score < b->score) + tiny_mcp_tools_context_t *context; + mcpc_server_t *server; + mcpc_errcode_t status; + if (!input || !output || !store) { return 1; } - if (a->entry->message.timestamp_ms > b->entry->message.timestamp_ms) - { - return -1; - } - if (a->entry->message.timestamp_ms < b->entry->message.timestamp_ms) + context = tiny_mcp_tools_context_new(store); + if (!context) { return 1; } - return strcmp(a->entry->message.message_id, b->entry->message.message_id); -} - -static tiny_search_index_t * -build_search_index(const tiny_store_t *store) -{ - tiny_search_index_t *index = tiny_search_index_create(); - size_t position; - if (!index) - { - return NULL; - } - for (position = 0; position < tiny_store_entry_count(store); ++position) + server = mcpc_server_new_iostrm(input, output); + if (!server || mcpc_server_set_nament(server, (const char8_t *)"tiny-convmem-mcp") != MCPC_EC_0 + || mcpc_server_capa_enable_tool(server) != MCPC_EC_0 || !tiny_mcp_tools_register(server) + || !tiny_mcp_tools_context_activate(context)) { - if (!tiny_search_index_add(index, &tiny_store_entry(store, position)->message, position)) - { - tiny_search_index_free(index); - return NULL; - } - } - return index; -} - -static void -handle_call(FILE *output, - const char *id, - int id_length, - const char *name, - const char *args, - int args_length, - tiny_store_t *store, - tiny_search_index_t **search_index) -{ - tiny_json_buffer_t result; - tiny_store_status_t status; - tiny_json_init(&result); - if (strcmp(name, "write_message") == 0) - { - tiny_message_t input, written; - if (!parse_write(args, args_length, &input)) - { - send_error(output, id, id_length, "VALIDATION_ERROR", "invalid write_message arguments"); - return; - } - tiny_message_init(&written); - status = tiny_store_write(store, &input, &written); - tiny_message_free(&input); - if (status != TINY_STORE_OK) - { - send_error( - output, id, id_length, tiny_store_status_name(status), "message was not written"); - return; - } - if (*search_index - && !tiny_search_index_add(*search_index, &written, tiny_store_entry_count(store) - 1)) - { - tiny_search_index_free(*search_index); - *search_index = NULL; - } - tiny_json_raw(&result, "{\"status\":\"OK\",\"message\":"); - append_message(&result, &written); - tiny_json_raw(&result, "}"); - tiny_message_free(&written); - } - else if (strcmp(name, "read_messages") == 0) - { - static const char *const keys[] = { "session_id" }; - bool valid = object_has_only(args, args_length, keys, 1); - char *session - = json_string_value(args, args_length, "$.session_id", TINY_SESSION_ID_MAX, true, &valid); - size_t i, count = 0; - const tiny_store_entry_t **items; - if (!valid || !session || !session[0]) - { - free(session); - send_error(output, id, id_length, "VALIDATION_ERROR", "session_id is required"); - return; - } - items = malloc(tiny_store_entry_count(store) * sizeof(*items)); - if (!items) - { - free(session); - send_error(output, id, id_length, "INTERNAL_ERROR", "out of memory"); - return; - } - for (i = 0; i < tiny_store_entry_count(store); ++i) - { - const tiny_store_entry_t *e = tiny_store_entry(store, i); - if (!e->deleted && strcmp(e->message.session_id, session) == 0) - { - items[count++] = e; - } - } - qsort(items, count, sizeof(*items), compare_history); - tiny_json_raw(&result, "{\"history\":["); - for (i = 0; i < count; ++i) - { - if (i) - { - tiny_json_raw(&result, ","); - } - append_message(&result, &items[i]->message); - } - tiny_json_raw(&result, "]}"); - free(items); - free(session); - } - else if (strcmp(name, "search_messages") == 0) - { - static const char *const keys[] = { "query", "session_id", "limit" }; - bool valid = object_has_only(args, args_length, keys, 3); - char *query = json_string_value(args, args_length, "$.query", TINY_QUERY_MAX, true, &valid); - char *session = json_string_value( - args, args_length, "$.session_id", TINY_SESSION_ID_MAX, false, &valid); - double number = 20; - size_t limit = 20, i, count = 0, candidate_count = 0, *candidates = NULL; - result_item_t *items; - if (mjson_find(args, args_length, "$.limit", NULL, NULL) != MJSON_TOK_INVALID) - { - if (!mjson_get_number(args, args_length, "$.limit", &number) || number < 1 || number > 100 - || floor(number) != number) - { - valid = false; - } - else - { - limit = (size_t)number; - } - } - if (!valid || !query || !query[0]) - { - free(query); - free(session); - send_error(output, id, id_length, "VALIDATION_ERROR", "query or limit is invalid"); - return; - } - if (!*search_index) + if (server) { - *search_index = build_search_index(store); + mcpc_server_close(server); } - if (!*search_index - || !tiny_search_index_candidates(*search_index, query, &candidates, &candidate_count)) - { - free(query); - free(session); - send_error(output, id, id_length, "INTERNAL_ERROR", "search index allocation failed"); - return; - } - items = malloc((candidate_count ? candidate_count : 1) * sizeof(*items)); - if (!items) - { - free(candidates); - free(query); - free(session); - send_error(output, id, id_length, "INTERNAL_ERROR", "out of memory"); - return; - } - for (i = 0; i < candidate_count; ++i) - { - const tiny_store_entry_t *e = tiny_store_entry(store, candidates[i]); - double score; - if (!e || e->deleted || (session && strcmp(e->message.session_id, session) != 0)) - { - continue; - } - score = tiny_search_score(query, &e->message); - if (score > 0) - { - items[count].entry = e; - items[count].score = score; - count++; - } - } - qsort(items, count, sizeof(*items), compare_results); - if (count > limit) - { - count = limit; - } - tiny_json_raw(&result, "{\"results\":["); - for (i = 0; i < count; ++i) - { - if (i) - { - tiny_json_raw(&result, ","); - } - append_message(&result, &items[i].entry->message); - } - tiny_json_raw(&result, "]}"); - free(candidates); - free(items); - free(query); - free(session); - } - else if (strcmp(name, "delete_message") == 0) - { - static const char *const keys[] = { "message_id", "session_id" }; - bool valid = object_has_only(args, args_length, keys, 2); - char *mid = json_string_value( - args, args_length, "$.message_id", TINY_MESSAGE_ID_LENGTH, true, &valid); - char *session = json_string_value( - args, args_length, "$.session_id", TINY_SESSION_ID_MAX, false, &valid); - if (!valid || !mid || !tiny_message_id_valid(mid)) - { - free(mid); - free(session); - send_error(output, - id, - id_length, - "VALIDATION_ERROR", - "message_id must be a canonical 26-character ULID"); - return; - } - status = tiny_store_delete(store, mid, session); - free(mid); - free(session); - if (status != TINY_STORE_OK && status != TINY_STORE_NOT_FOUND) - { - send_error(output, - id, - id_length, - tiny_store_status_name(status), - "delete record was not written"); - return; - } - tiny_json_raw(&result, - status == TINY_STORE_OK ? "{\"status\":\"OK\"}" : "{\"status\":\"NOT_FOUND\"}"); - } - else if (strcmp(name, "rebuild_index") == 0) - { - tiny_rebuild_report_t report; - tiny_search_index_t *rebuilt; - if (!object_has_only(args, args_length, NULL, 0)) - { - send_error(output, id, id_length, "VALIDATION_ERROR", "rebuild_index takes no arguments"); - return; - } - status = tiny_store_rebuild(store, &report); - if (status != TINY_STORE_OK) - { - send_error(output, id, id_length, tiny_store_status_name(status), "index rebuild failed"); - return; - } - rebuilt = build_search_index(store); - if (!rebuilt) - { - tiny_search_index_free(*search_index); - *search_index = NULL; - send_error(output, - id, - id_length, - "INTERNAL_ERROR", - "log was compacted but the search index could not be allocated"); - return; - } - tiny_search_index_free(*search_index); - *search_index = rebuilt; - tiny_json_raw(&result, "{\"status\":\"OK\",\"messages_indexed\":"); - tiny_json_u64(&result, report.messages_indexed); - tiny_json_raw(&result, ",\"records_removed\":"); - tiny_json_u64(&result, report.records_removed); - tiny_json_raw(&result, ",\"bytes_before\":"); - tiny_json_u64(&result, report.bytes_before); - tiny_json_raw(&result, ",\"bytes_after\":"); - tiny_json_u64(&result, report.bytes_after); - tiny_json_raw(&result, "}"); - } - else if (strcmp(name, "get_system_health") == 0) - { - tiny_store_health_t health; - if (!object_has_only(args, args_length, NULL, 0)) - { - send_error( - output, id, id_length, "VALIDATION_ERROR", "get_system_health takes no arguments"); - return; - } - tiny_store_health(store, &health); - tiny_json_raw(&result, "{\"status\":"); - tiny_json_string(&result, health.last_error[0] ? "DEGRADED" : "OK"); - tiny_json_raw(&result, ",\"format_version\":1,\"log_size\":"); - tiny_json_u64(&result, health.log_size); - tiny_json_raw(&result, ",\"snapshot_size\":"); - tiny_json_u64(&result, health.snapshot_size); - tiny_json_raw(&result, ",\"active_messages\":"); - tiny_json_u64(&result, health.active_messages); - tiny_json_raw(&result, ",\"deleted_messages\":"); - tiny_json_u64(&result, health.deleted_messages); - tiny_json_raw(&result, ",\"last_sequence\":"); - tiny_json_u64(&result, health.last_sequence); - tiny_json_raw(&result, ",\"recovered_tail\":"); - tiny_json_raw(&result, health.recovered_tail ? "true" : "false"); - tiny_json_raw(&result, ",\"snapshot_valid\":"); - tiny_json_raw(&result, health.snapshot_valid ? "true" : "false"); - tiny_json_raw(&result, ",\"last_error\":"); - tiny_json_string(&result, health.last_error); - tiny_json_raw(&result, "}"); - } - else - { - send_error(output, id, id_length, "VALIDATION_ERROR", "unknown tool"); - return; - } - if (result.failed) - { - send_error(output, id, id_length, "INTERNAL_ERROR", "response allocation failed"); - } - else - { - send_json_result(output, id, id_length, &result, false); - } - tiny_json_free(&result); -} - -static const char *tools_json - = "{\"tools\":[" - "{\"name\":\"write_message\",\"description\":\"Durably append a conversation " - "message\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"session_id\":{\"type\":" - "\"string\",\"maxLength\":256},\"role\":{\"type\":\"string\",\"enum\":[\"user\"," - "\"assistant\",\"system\"]},\"content\":{\"type\":\"string\",\"maxLength\":1048576}," - "\"metadata\":{\"type\":\"object\",\"properties\":{\"tool_call_id\":{\"type\":\"string\"," - "\"maxLength\":256},\"tags\":{\"type\":\"array\",\"maxItems\":32,\"items\":{\"type\":" - "\"string\",\"maxLength\":128}},\"memory_tier\":{\"type\":\"string\",\"enum\":[\"short\"," - "\"mid\",\"long\",\"discard\"]}},\"additionalProperties\":false}},\"required\":[\"session_" - "id\",\"role\",\"content\"],\"additionalProperties\":false}}," - "{\"name\":\"read_messages\",\"description\":\"Read a session in chronological " - "order\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"session_id\":{\"type\":" - "\"string\",\"maxLength\":256}},\"required\":[\"session_id\"],\"additionalProperties\":false}" - "}," - "{\"name\":\"search_messages\",\"description\":\"Search message content and " - "tags\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"," - "\"maxLength\":4096},\"session_id\":{\"type\":\"string\",\"maxLength\":256},\"limit\":{" - "\"type\":\"integer\",\"minimum\":1,\"maximum\":100}},\"required\":[\"query\"]," - "\"additionalProperties\":false}}," - "{\"name\":\"delete_message\",\"description\":\"Durably tombstone a " - "message\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"message_id\":{\"type\":" - "\"string\",\"minLength\":26,\"maxLength\":26},\"session_id\":{\"type\":\"string\"," - "\"maxLength\":256}},\"required\":[\"message_id\"],\"additionalProperties\":false}}," - "{\"name\":\"rebuild_index\",\"description\":\"Compact the log and rebuild the " - "snapshot\",\"inputSchema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":" - "false}}," - "{\"name\":\"get_system_health\",\"description\":\"Report persistence health and " - "counters\",\"inputSchema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":" - "false}}]}"; - -int -tiny_convmem_server_run(FILE *input, FILE *output, tiny_store_t *store) -{ - char *line; - tiny_search_index_t *search_index = build_search_index(store); - if (!search_index) - { + tiny_mcp_tools_context_free(context); return 1; } - while ((line = read_line(input)) != NULL) - { - int length = (int)strlen(line); - const char *method = NULL, *id = NULL, *params = NULL; - int ml = 0, il = 0, pl = 0; - if (mjson_find(line, length, "$.method", &method, &ml) != MJSON_TOK_STRING) - { - free(line); - continue; - } - mjson_find(line, length, "$.id", &id, &il); - mjson_find(line, length, "$.params", ¶ms, &pl); - { - char method_value[64]; - int decoded - = mjson_get_string(line, length, "$.method", method_value, sizeof(method_value)); - if (decoded < 0) - { - free(line); - continue; - } - method_value[decoded] = '\0'; - if (strcmp(method_value, "initialize") == 0 && id) - { - fprintf(output, - "{\"jsonrpc\":\"2.0\",\"id\":%.*s,\"result\":{\"protocolVersion\":\"%s\"," - "\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"tiny-convmem-mcp\"," - "\"version\":\"0.1.0\"}}}\n", - il, - id, - MCP_PROTOCOL_VERSION); - } - else if (strcmp(method_value, "tools/list") == 0 && id) - { - fprintf( - output, "{\"jsonrpc\":\"2.0\",\"id\":%.*s,\"result\":%s}\n", il, id, tools_json); - } - else if (strcmp(method_value, "tools/call") == 0 && id && params) - { - char name[64]; - const char *args = NULL; - int al = 0; - int n = mjson_get_string(params, pl, "$.name", name, sizeof(name)); - if (n > 0) - { - name[n] = '\0'; - if (mjson_find(params, pl, "$.arguments", &args, &al) == MJSON_TOK_OBJECT) - { - handle_call(output, id, il, name, args, al, store, &search_index); - } - else - { - send_error(output, id, il, "VALIDATION_ERROR", "arguments must be an object"); - } - } - } - fflush(output); - } - free(line); - } - tiny_search_index_free(search_index); - return 0; + status = mcpc_server_start(server); + tiny_mcp_tools_context_deactivate(context); + mcpc_server_close(server); + tiny_mcp_tools_context_free(context); + return status == MCPC_EC_0 ? 0 : 1; } diff --git a/src/mcp/tools/common/common.c b/src/mcp/tools/common/common.c new file mode 100644 index 0000000..71b6f68 --- /dev/null +++ b/src/mcp/tools/common/common.c @@ -0,0 +1,117 @@ +#include "mcp/tools/common/common.h" + +#include <stdlib.h> + +#include "base/allocator.h" + +void +tiny_mcp_tool_add_error(mcpc_ucbr_t *result, const char *code, const char *message) +{ + tiny_json_buffer_t json; + tiny_json_init(&json); + tiny_json_raw(&json, "{\"status\":\"ERROR\",\"error\":{\"code\":"); + tiny_json_string(&json, code); + tiny_json_raw(&json, ",\"message\":"); + tiny_json_string(&json, message); + tiny_json_raw(&json, "}}"); + mcpc_toolcall_result_add_errmsg_printf8( + result, (const char8_t *)"%s", json.data ? json.data : "{}"); + tiny_json_free(&json); +} + +void +tiny_mcp_tool_add_json(mcpc_ucbr_t *result, tiny_json_buffer_t *json) +{ + if (json->failed) + { + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "response allocation failed"); + return; + } + mcpc_toolcall_result_add_text_printf8( + result, (const char8_t *)"%s", json->data ? json->data : "{}"); +} + +char * +tiny_mcp_tool_get_string(const mcpc_tool_t *tool, const char *name, size_t maximum) +{ + char *value = malloc(maximum + 1); + size_t length = 0; + if (!value + || mcpc_tool_get_tpropval_u8str( + tool, (const char8_t *)name, (char8_t *)value, maximum + 1, &length) + != MCPC_EC_0 + || length > maximum) + { + free(value); + return NULL; + } + value[length] = '\0'; + if (!tiny_utf8_valid(value, length)) + { + free(value); + return NULL; + } + return value; +} + +void +tiny_mcp_tool_append_message(tiny_json_buffer_t *json, const tiny_message_t *message) +{ + size_t index; + tiny_json_raw(json, "{\"message_id\":"); + tiny_json_string(json, message->message_id); + tiny_json_raw(json, ",\"session_id\":"); + tiny_json_string(json, message->session_id); + tiny_json_raw(json, ",\"timestamp\":"); + tiny_json_i64(json, message->timestamp_ms); + tiny_json_raw(json, ",\"role\":"); + tiny_json_string(json, tiny_role_name(message->role)); + tiny_json_raw(json, ",\"content\":"); + tiny_json_string(json, message->content); + tiny_json_raw(json, ",\"metadata\":{"); + if (message->metadata.tool_call_id) + { + tiny_json_raw(json, "\"tool_call_id\":"); + tiny_json_string(json, message->metadata.tool_call_id); + tiny_json_raw(json, ","); + } + tiny_json_raw(json, "\"tags\":["); + for (index = 0; index < message->metadata.tag_count; ++index) + { + if (index) + { + tiny_json_raw(json, ","); + } + tiny_json_string(json, message->metadata.tags[index]); + } + tiny_json_raw(json, "],\"memory_tier\":"); + tiny_json_string(json, tiny_tier_name(message->metadata.memory_tier)); + tiny_json_raw(json, "}}"); +} + +mcpc_tool_t * +tiny_mcp_tool_new(const char *name, const char *description, mcpc_tcallcb_t callback) +{ + mcpc_tool_t *tool = mcpc_tool_new2((const char8_t *)name, (const char8_t *)description); + if (!tool || mcpc_tool_set_call_cb(tool, callback) != MCPC_EC_0) + { + return NULL; + } + return tool; +} + +bool +tiny_mcp_tool_add_property(mcpc_tool_t *tool, + const char *name, + const char *description, + mcpc_anytype_t type) +{ + mcpc_toolprop_t *property + = mcpc_toolprop_new2((const char8_t *)name, (const char8_t *)description, type); + if (!property) + { + return false; + } + mcpc_tool_add_toolprop(tool, property); + return true; +} diff --git a/src/mcp/tools/common/common.h b/src/mcp/tools/common/common.h new file mode 100644 index 0000000..6b1cc96 --- /dev/null +++ b/src/mcp/tools/common/common.h @@ -0,0 +1,19 @@ +#ifndef TINY_CONVMEM_MCP_TOOLS_COMMON_H +#define TINY_CONVMEM_MCP_TOOLS_COMMON_H + +#include "domain/message.h" +#include "mcp/json.h" +#include <mcpc/mcpc.h> + +void tiny_mcp_tool_add_error(mcpc_ucbr_t *result, const char *code, const char *message); +void tiny_mcp_tool_add_json(mcpc_ucbr_t *result, tiny_json_buffer_t *json); +char *tiny_mcp_tool_get_string(const mcpc_tool_t *tool, const char *name, size_t maximum); +void tiny_mcp_tool_append_message(tiny_json_buffer_t *json, const tiny_message_t *message); + +mcpc_tool_t *tiny_mcp_tool_new(const char *name, const char *description, mcpc_tcallcb_t callback); +bool tiny_mcp_tool_add_property(mcpc_tool_t *tool, + const char *name, + const char *description, + mcpc_anytype_t type); + +#endif diff --git a/src/mcp/tools/common/context.c b/src/mcp/tools/common/context.c new file mode 100644 index 0000000..1a469e0 --- /dev/null +++ b/src/mcp/tools/common/context.c @@ -0,0 +1,88 @@ +#include "mcp/tools/common/context.h" + +#include <stdlib.h> + +#include "base/allocator.h" + +/* mcpc's callback API has no user-data parameter. The stdio server is single-instance. */ +static tiny_mcp_tools_context_t *active_context; + +tiny_search_index_t * +tiny_mcp_tools_build_search_index(const tiny_store_t *store) +{ + tiny_search_index_t *index = tiny_search_index_create(); + size_t position; + if (!index) + { + return NULL; + } + for (position = 0; position < tiny_store_entry_count(store); ++position) + { + if (!tiny_search_index_add(index, &tiny_store_entry(store, position)->message, position)) + { + tiny_search_index_free(index); + return NULL; + } + } + return index; +} + +tiny_mcp_tools_context_t * +tiny_mcp_tools_context_new(tiny_store_t *store) +{ + tiny_mcp_tools_context_t *context; + if (!store) + { + return NULL; + } + context = malloc(sizeof(*context)); + if (!context) + { + return NULL; + } + context->store = store; + context->search_index = tiny_mcp_tools_build_search_index(store); + if (!context->search_index) + { + free(context); + return NULL; + } + return context; +} + +void +tiny_mcp_tools_context_free(tiny_mcp_tools_context_t *context) +{ + if (!context) + { + return; + } + tiny_search_index_free(context->search_index); + free(context); +} + +bool +tiny_mcp_tools_context_activate(tiny_mcp_tools_context_t *context) +{ + if (!context || active_context) + { + return false; + } + active_context = context; + return true; +} + +void +tiny_mcp_tools_context_deactivate(tiny_mcp_tools_context_t *context) +{ + if (active_context == context) + { + active_context = NULL; + } +} + +tiny_mcp_tools_context_t * +tiny_mcp_tools_context_current(void) +{ + return active_context; +} diff --git a/src/mcp/tools/common/context.h b/src/mcp/tools/common/context.h new file mode 100644 index 0000000..0847d4b --- /dev/null +++ b/src/mcp/tools/common/context.h @@ -0,0 +1,20 @@ +#ifndef TINY_CONVMEM_MCP_TOOLS_CONTEXT_H +#define TINY_CONVMEM_MCP_TOOLS_CONTEXT_H + +#include "domain/search.h" +#include "storage/store.h" + +typedef struct tiny_mcp_tools_context +{ + tiny_store_t *store; + tiny_search_index_t *search_index; +} tiny_mcp_tools_context_t; + +tiny_mcp_tools_context_t *tiny_mcp_tools_context_new(tiny_store_t *store); +void tiny_mcp_tools_context_free(tiny_mcp_tools_context_t *context); +bool tiny_mcp_tools_context_activate(tiny_mcp_tools_context_t *context); +void tiny_mcp_tools_context_deactivate(tiny_mcp_tools_context_t *context); +tiny_mcp_tools_context_t *tiny_mcp_tools_context_current(void); +tiny_search_index_t *tiny_mcp_tools_build_search_index(const tiny_store_t *store); + +#endif diff --git a/src/mcp/tools/common/registry.c b/src/mcp/tools/common/registry.c new file mode 100644 index 0000000..9aa1dff --- /dev/null +++ b/src/mcp/tools/common/registry.c @@ -0,0 +1,11 @@ +#include "mcp/tools/common/tools.h" + +#include "mcp/tools/common/registry.h" + +bool +tiny_mcp_tools_register(mcpc_server_t *server) +{ + return tiny_mcp_register_write_message(server) && tiny_mcp_register_read_messages(server) + && tiny_mcp_register_search_messages(server) && tiny_mcp_register_delete_message(server) + && tiny_mcp_register_rebuild_index(server) && tiny_mcp_register_get_system_health(server); +} diff --git a/src/mcp/tools/common/registry.h b/src/mcp/tools/common/registry.h new file mode 100644 index 0000000..bbc68d0 --- /dev/null +++ b/src/mcp/tools/common/registry.h @@ -0,0 +1,13 @@ +#ifndef TINY_CONVMEM_MCP_TOOLS_REGISTRY_H +#define TINY_CONVMEM_MCP_TOOLS_REGISTRY_H + +#include <mcpc/mcpc.h> + +bool tiny_mcp_register_write_message(mcpc_server_t *server); +bool tiny_mcp_register_read_messages(mcpc_server_t *server); +bool tiny_mcp_register_search_messages(mcpc_server_t *server); +bool tiny_mcp_register_delete_message(mcpc_server_t *server); +bool tiny_mcp_register_rebuild_index(mcpc_server_t *server); +bool tiny_mcp_register_get_system_health(mcpc_server_t *server); + +#endif diff --git a/src/mcp/tools/common/tools.h b/src/mcp/tools/common/tools.h new file mode 100644 index 0000000..b06321c --- /dev/null +++ b/src/mcp/tools/common/tools.h @@ -0,0 +1,10 @@ +#ifndef TINY_CONVMEM_MCP_TOOLS_H +#define TINY_CONVMEM_MCP_TOOLS_H + +#include "mcp/tools/common/context.h" +#define MCPC_C23PTCH_UCHAR2 1 +#include <mcpc/mcpc.h> + +bool tiny_mcp_tools_register(mcpc_server_t *server); + +#endif diff --git a/src/mcp/tools/delete_message.c b/src/mcp/tools/delete_message.c new file mode 100644 index 0000000..ae924d7 --- /dev/null +++ b/src/mcp/tools/delete_message.c @@ -0,0 +1,50 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" + +#include <stdlib.h> + +#include "base/allocator.h" + +static void +call_delete_message(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + char *message_id = tiny_mcp_tool_get_string(tool, "message_id", TINY_MESSAGE_ID_LENGTH); + char *session = tiny_mcp_tool_get_string(tool, "session_id", TINY_SESSION_ID_MAX); + tiny_store_status_t status; + if (!context || !message_id || !session || !tiny_message_id_valid(message_id)) + { + tiny_mcp_tool_add_error( + result, "VALIDATION_ERROR", "message_id must be a canonical 26-character ULID"); + } + else + { + status = tiny_store_delete(context->store, message_id, session[0] ? session : NULL); + if (status != TINY_STORE_OK && status != TINY_STORE_NOT_FOUND) + { + tiny_mcp_tool_add_error( + result, tiny_store_status_name(status), "delete record was not written"); + } + else + { + mcpc_toolcall_result_add_text_printf8( + result, + (const char8_t *)(status == TINY_STORE_OK ? "{\"status\":\"OK\"}" + : "{\"status\":\"NOT_FOUND\"}")); + } + } + free(message_id); + free(session); +} + +bool +tiny_mcp_register_delete_message(mcpc_server_t *server) +{ + mcpc_tool_t *tool = tiny_mcp_tool_new("delete_message", "Delete a message", call_delete_message); + return tool && tiny_mcp_tool_add_property(tool, "message_id", "Message ULID", MCPC_U8STR) + && tiny_mcp_tool_add_property( + tool, "session_id", "Session ID; empty if omitted", MCPC_U8STR) + && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/mcp/tools/get_system_health.c b/src/mcp/tools/get_system_health.c new file mode 100644 index 0000000..eb02090 --- /dev/null +++ b/src/mcp/tools/get_system_health.c @@ -0,0 +1,49 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" + +static void +call_get_system_health(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + tiny_store_health_t health; + tiny_json_buffer_t json; + (void)tool; + if (!context) + { + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "tool context is unavailable"); + return; + } + tiny_store_health(context->store, &health); + tiny_json_init(&json); + tiny_json_raw(&json, "{\"status\":"); + tiny_json_string(&json, health.last_error[0] ? "DEGRADED" : "OK"); + tiny_json_raw(&json, ",\"format_version\":1,\"log_size\":"); + tiny_json_u64(&json, health.log_size); + tiny_json_raw(&json, ",\"snapshot_size\":"); + tiny_json_u64(&json, health.snapshot_size); + tiny_json_raw(&json, ",\"active_messages\":"); + tiny_json_u64(&json, health.active_messages); + tiny_json_raw(&json, ",\"deleted_messages\":"); + tiny_json_u64(&json, health.deleted_messages); + tiny_json_raw(&json, ",\"last_sequence\":"); + tiny_json_u64(&json, health.last_sequence); + tiny_json_raw(&json, ",\"recovered_tail\":"); + tiny_json_raw(&json, health.recovered_tail ? "true" : "false"); + tiny_json_raw(&json, ",\"snapshot_valid\":"); + tiny_json_raw(&json, health.snapshot_valid ? "true" : "false"); + tiny_json_raw(&json, ",\"last_error\":"); + tiny_json_string(&json, health.last_error); + tiny_json_raw(&json, "}"); + tiny_mcp_tool_add_json(result, &json); + tiny_json_free(&json); +} + +bool +tiny_mcp_register_get_system_health(mcpc_server_t *server) +{ + mcpc_tool_t *tool + = tiny_mcp_tool_new("get_system_health", "Report storage health", call_get_system_health); + return tool && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/mcp/tools/read_messages.c b/src/mcp/tools/read_messages.c new file mode 100644 index 0000000..55696d6 --- /dev/null +++ b/src/mcp/tools/read_messages.c @@ -0,0 +1,80 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" + +#include <stdlib.h> +#include <string.h> + +#include "base/allocator.h" + +static int +compare_history(const void *left, const void *right) +{ + const tiny_store_entry_t *a = *(const tiny_store_entry_t *const *)left; + const tiny_store_entry_t *b = *(const tiny_store_entry_t *const *)right; + if (a->message.timestamp_ms < b->message.timestamp_ms) + { + return -1; + } + if (a->message.timestamp_ms > b->message.timestamp_ms) + { + return 1; + } + return strcmp(a->message.message_id, b->message.message_id); +} + +static void +call_read_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + char *session = tiny_mcp_tool_get_string(tool, "session_id", TINY_SESSION_ID_MAX); + const tiny_store_entry_t **items = NULL; + size_t index, count = 0; + tiny_json_buffer_t json; + if (!context || !session || !session[0]) + { + free(session); + tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "session_id is required"); + return; + } + items = malloc((tiny_store_entry_count(context->store) + 1) * sizeof(*items)); + if (!items) + { + free(session); + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "out of memory"); + return; + } + for (index = 0; index < tiny_store_entry_count(context->store); ++index) + { + const tiny_store_entry_t *entry = tiny_store_entry(context->store, index); + if (!entry->deleted && strcmp(entry->message.session_id, session) == 0) + { + items[count++] = entry; + } + } + qsort(items, count, sizeof(*items), compare_history); + tiny_json_init(&json); + tiny_json_raw(&json, "{\"history\":["); + for (index = 0; index < count; ++index) + { + if (index) + { + tiny_json_raw(&json, ","); + } + tiny_mcp_tool_append_message(&json, &items[index]->message); + } + tiny_json_raw(&json, "]}"); + tiny_mcp_tool_add_json(result, &json); + tiny_json_free(&json); + free(items); + free(session); +} + +bool +tiny_mcp_register_read_messages(mcpc_server_t *server) +{ + mcpc_tool_t *tool = tiny_mcp_tool_new("read_messages", "Read a session", call_read_messages); + return tool && tiny_mcp_tool_add_property(tool, "session_id", "Session ID", MCPC_U8STR) + && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/mcp/tools/rebuild_index.c b/src/mcp/tools/rebuild_index.c new file mode 100644 index 0000000..69f8341 --- /dev/null +++ b/src/mcp/tools/rebuild_index.c @@ -0,0 +1,57 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" + +#include "base/allocator.h" + +static void +call_rebuild_index(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + tiny_rebuild_report_t report; + tiny_search_index_t *rebuilt; + tiny_store_status_t status; + tiny_json_buffer_t json; + (void)tool; + if (!context) + { + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "tool context is unavailable"); + return; + } + status = tiny_store_rebuild(context->store, &report); + if (status != TINY_STORE_OK) + { + tiny_mcp_tool_add_error(result, tiny_store_status_name(status), "index rebuild failed"); + return; + } + rebuilt = tiny_mcp_tools_build_search_index(context->store); + if (!rebuilt) + { + tiny_search_index_free(context->search_index); + context->search_index = NULL; + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "search index allocation failed"); + return; + } + tiny_search_index_free(context->search_index); + context->search_index = rebuilt; + tiny_json_init(&json); + tiny_json_raw(&json, "{\"status\":\"OK\",\"messages_indexed\":"); + tiny_json_u64(&json, report.messages_indexed); + tiny_json_raw(&json, ",\"records_removed\":"); + tiny_json_u64(&json, report.records_removed); + tiny_json_raw(&json, ",\"bytes_before\":"); + tiny_json_u64(&json, report.bytes_before); + tiny_json_raw(&json, ",\"bytes_after\":"); + tiny_json_u64(&json, report.bytes_after); + tiny_json_raw(&json, "}"); + tiny_mcp_tool_add_json(result, &json); + tiny_json_free(&json); +} + +bool +tiny_mcp_register_rebuild_index(mcpc_server_t *server) +{ + mcpc_tool_t *tool = tiny_mcp_tool_new("rebuild_index", "Compact and rebuild", call_rebuild_index); + return tool && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/mcp/tools/search_messages.c b/src/mcp/tools/search_messages.c new file mode 100644 index 0000000..166c167 --- /dev/null +++ b/src/mcp/tools/search_messages.c @@ -0,0 +1,134 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" + +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +#include "base/allocator.h" + +typedef struct result_item +{ + const tiny_store_entry_t *entry; + double score; +} result_item_t; + +static int +compare_results(const void *left, const void *right) +{ + const result_item_t *a = left; + const result_item_t *b = right; + if (a->score > b->score) + { + return -1; + } + if (a->score < b->score) + { + return 1; + } + if (a->entry->message.timestamp_ms > b->entry->message.timestamp_ms) + { + return -1; + } + if (a->entry->message.timestamp_ms < b->entry->message.timestamp_ms) + { + return 1; + } + return strcmp(a->entry->message.message_id, b->entry->message.message_id); +} + +static void +call_search_messages(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + char *query = tiny_mcp_tool_get_string(tool, "query", TINY_QUERY_MAX); + char *session = tiny_mcp_tool_get_string(tool, "session_id", TINY_SESSION_ID_MAX); + int32_t requested_limit = 0; + size_t limit, index, count = 0, candidate_count = 0, *candidates = NULL; + result_item_t *items = NULL; + tiny_json_buffer_t json; + bool valid + = context && query && query[0] && session + && mcpc_tool_get_tpropval_i32(tool, (const char8_t *)"limit", &requested_limit) == MCPC_EC_0 + && requested_limit >= 1 && requested_limit <= 100; + if (!valid) + { + tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "query or limit is invalid"); + } + else + { + limit = (size_t)requested_limit; + if (!context->search_index) + { + context->search_index = tiny_mcp_tools_build_search_index(context->store); + } + if (!context->search_index + || !tiny_search_index_candidates( + context->search_index, query, &candidates, &candidate_count)) + { + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "search index allocation failed"); + } + else + { + items = malloc((candidate_count + 1) * sizeof(*items)); + if (!items) + { + tiny_mcp_tool_add_error(result, "INTERNAL_ERROR", "out of memory"); + } + else + { + for (index = 0; index < candidate_count; ++index) + { + const tiny_store_entry_t *entry + = tiny_store_entry(context->store, candidates[index]); + double score; + if (!entry || entry->deleted + || (session[0] && strcmp(entry->message.session_id, session) != 0)) + { + continue; + } + score = tiny_search_score(query, &entry->message); + if (score > 0) + { + items[count].entry = entry; + items[count++].score = score; + } + } + qsort(items, count, sizeof(*items), compare_results); + if (count > limit) + { + count = limit; + } + tiny_json_init(&json); + tiny_json_raw(&json, "{\"results\":["); + for (index = 0; index < count; ++index) + { + if (index) + { + tiny_json_raw(&json, ","); + } + tiny_mcp_tool_append_message(&json, &items[index].entry->message); + } + tiny_json_raw(&json, "]}"); + tiny_mcp_tool_add_json(result, &json); + tiny_json_free(&json); + } + } + } + free(items); + free(candidates); + free(query); + free(session); +} + +bool +tiny_mcp_register_search_messages(mcpc_server_t *server) +{ + mcpc_tool_t *tool = tiny_mcp_tool_new("search_messages", "Search messages", call_search_messages); + return tool && tiny_mcp_tool_add_property(tool, "query", "Search text", MCPC_U8STR) + && tiny_mcp_tool_add_property(tool, "session_id", "Session ID; empty for all", MCPC_U8STR) + && tiny_mcp_tool_add_property(tool, "limit", "Result limit from 1 to 100", MCPC_I32) + && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/mcp/tools/write_message.c b/src/mcp/tools/write_message.c new file mode 100644 index 0000000..1d7ece2 --- /dev/null +++ b/src/mcp/tools/write_message.c @@ -0,0 +1,213 @@ +#include "mcp/tools/common/registry.h" + +#include "mcp/tools/common/common.h" +#include "mcp/tools/common/context.h" +#include "mjson.h" + +#include <stdlib.h> +#include <string.h> + +#include "base/allocator.h" + +static bool +metadata_has_only_known_fields(const char *json, int length) +{ + int offset = 0, key_offset, key_length, value_offset, value_length, value_type; + while ((offset = mjson_next(json, + length, + offset, + &key_offset, + &key_length, + &value_offset, + &value_length, + &value_type)) + != 0) + { + char key[32]; + int decoded = mjson_get_string(json + key_offset, key_length, "$", key, sizeof(key)); + (void)value_offset; + (void)value_length; + (void)value_type; + if (decoded <= 0 || decoded >= (int)sizeof(key)) + { + return false; + } + key[decoded] = '\0'; + if (strcmp(key, "tool_call_id") != 0 && strcmp(key, "tags") != 0 + && strcmp(key, "memory_tier") != 0) + { + return false; + } + } + return true; +} + +static bool +parse_metadata(const char *json, tiny_message_t *message) +{ + const char *tool_call = NULL, *tier = NULL, *tags = NULL; + int tool_call_len = 0, tier_len = 0, tags_len = 0; + int offset = 0, key_offset, key_length, value_offset, value_length, value_type; + int length = (int)strlen(json); + int tool_call_type, tier_type, tags_type; + if (strcmp(json, "{}") == 0) + { + return true; + } + if (mjson_find(json, length, "$", NULL, NULL) != MJSON_TOK_OBJECT + || !metadata_has_only_known_fields(json, length)) + { + return false; + } + tool_call_type = mjson_find(json, length, "$.tool_call_id", &tool_call, &tool_call_len); + tier_type = mjson_find(json, length, "$.memory_tier", &tier, &tier_len); + tags_type = mjson_find(json, length, "$.tags", &tags, &tags_len); + if ((tool_call_type != MJSON_TOK_INVALID && tool_call_type != MJSON_TOK_STRING) + || (tier_type != MJSON_TOK_INVALID && tier_type != MJSON_TOK_STRING) + || (tags_type != MJSON_TOK_INVALID && tags_type != MJSON_TOK_ARRAY)) + { + return false; + } + if (tool_call_type == MJSON_TOK_STRING) + { + int decoded; + message->metadata.tool_call_id = malloc(TINY_TOOL_CALL_ID_MAX + 1); + if (!message->metadata.tool_call_id) + { + return false; + } + decoded = mjson_get_string(json, + length, + "$.tool_call_id", + message->metadata.tool_call_id, + TINY_TOOL_CALL_ID_MAX + 1); + if (decoded < 0 || decoded > TINY_TOOL_CALL_ID_MAX + || !tiny_utf8_valid(message->metadata.tool_call_id, (size_t)decoded)) + { + return false; + } + message->metadata.tool_call_id[decoded] = '\0'; + } + if (tier_type == MJSON_TOK_STRING) + { + char tier_value[16]; + int decoded = mjson_get_string(json, length, "$.memory_tier", tier_value, sizeof(tier_value)); + if (decoded < 0 || decoded >= (int)sizeof(tier_value)) + { + return false; + } + tier_value[decoded] = '\0'; + if (!tiny_tier_parse(tier_value, &message->metadata.memory_tier)) + { + return false; + } + } + if (tags_type == MJSON_TOK_ARRAY) + { + while ((offset = mjson_next(tags, + tags_len, + offset, + &key_offset, + &key_length, + &value_offset, + &value_length, + &value_type)) + != 0) + { + char *tag; + char **resized; + int decoded; + if (value_type != MJSON_TOK_STRING || message->metadata.tag_count >= TINY_TAG_COUNT_MAX) + { + return false; + } + tag = malloc(TINY_TAG_MAX + 1); + if (!tag) + { + return false; + } + decoded = mjson_get_string(tags + value_offset, value_length, "$", tag, TINY_TAG_MAX + 1); + if (decoded <= 0 || decoded > TINY_TAG_MAX || !tiny_utf8_valid(tag, (size_t)decoded)) + { + free(tag); + return false; + } + tag[decoded] = '\0'; + resized = realloc(message->metadata.tags, + (message->metadata.tag_count + 1) * sizeof(*message->metadata.tags)); + if (!resized) + { + free(tag); + return false; + } + message->metadata.tags = resized; + message->metadata.tags[message->metadata.tag_count++] = tag; + } + } + return true; +} + +static void +call_write_message(const mcpc_tool_t *tool, mcpc_ucbr_t *result) +{ + tiny_mcp_tools_context_t *context = tiny_mcp_tools_context_current(); + tiny_message_t input, written; + tiny_store_status_t status; + char *role = NULL, *metadata = NULL; + tiny_json_buffer_t json; + tiny_message_init(&input); + tiny_message_init(&written); + input.session_id = tiny_mcp_tool_get_string(tool, "session_id", TINY_SESSION_ID_MAX); + role = tiny_mcp_tool_get_string(tool, "role", 16); + input.content = tiny_mcp_tool_get_string(tool, "content", TINY_CONTENT_MAX); + metadata = tiny_mcp_tool_get_string(tool, "metadata_json", TINY_CONTENT_MAX); + bool valid = context && input.session_id && input.session_id[0] && role && input.content + && input.content[0] && metadata && tiny_role_parse(role, &input.role) + && parse_metadata(metadata, &input); + if (!valid) + { + tiny_mcp_tool_add_error(result, "VALIDATION_ERROR", "invalid write_message arguments"); + } + else + { + status = tiny_store_write(context->store, &input, &written); + if (status != TINY_STORE_OK) + { + tiny_mcp_tool_add_error( + result, tiny_store_status_name(status), "message was not written"); + } + else + { + if (context->search_index + && !tiny_search_index_add( + context->search_index, &written, tiny_store_entry_count(context->store) - 1)) + { + tiny_search_index_free(context->search_index); + context->search_index = NULL; + } + tiny_json_init(&json); + tiny_json_raw(&json, "{\"status\":\"OK\",\"message\":"); + tiny_mcp_tool_append_message(&json, &written); + tiny_json_raw(&json, "}"); + tiny_mcp_tool_add_json(result, &json); + tiny_json_free(&json); + } + } + free(role); + free(metadata); + tiny_message_free(&input); + tiny_message_free(&written); +} + +bool +tiny_mcp_register_write_message(mcpc_server_t *server) +{ + mcpc_tool_t *tool + = tiny_mcp_tool_new("write_message", "Append a conversation message", call_write_message); + return tool && tiny_mcp_tool_add_property(tool, "session_id", "Session ID", MCPC_U8STR) + && tiny_mcp_tool_add_property(tool, "role", "user, assistant, or system", MCPC_U8STR) + && tiny_mcp_tool_add_property(tool, "content", "Message text", MCPC_U8STR) + && tiny_mcp_tool_add_property( + tool, "metadata_json", "Metadata object JSON; use {} when empty", MCPC_U8STR) + && mcpc_server_add_tool(server, tool) == MCPC_EC_0; +} diff --git a/src/storage/README.md b/src/storage/README.md index 3c03dba..79874b0 100644 --- a/src/storage/README.md +++ b/src/storage/README.md @@ -2,7 +2,8 @@ The storage adapter owns `active.log`, `snapshot.bin`, the data-directory lock, in-memory live/deleted state, synchronous PUT/DEL writes, startup replay, and -compaction. It uses Win32 64-bit file APIs and `FlushFileBuffers`. +compaction. It depends only on the UTF-8, opaque-handle API in `src/sys`; OS +file and durability primitives do not leak into this layer. The binary format is private but versioned. Invalid complete records are never skipped; only an incomplete EOF record is truncated. Snapshot generation must diff --git a/src/storage/codec.c b/src/storage/codec.c new file mode 100644 index 0000000..af061ce --- /dev/null +++ b/src/storage/codec.c @@ -0,0 +1,76 @@ +#include "storage/codec.h" + +void +tiny_codec_put_u32(unsigned char *output, uint32_t value) +{ + if (output == NULL) + { + return; + } + output[0] = (unsigned char)value; + output[1] = (unsigned char)(value >> 8); + output[2] = (unsigned char)(value >> 16); + output[3] = (unsigned char)(value >> 24); +} + +void +tiny_codec_put_u64(unsigned char *output, uint64_t value) +{ + unsigned index; + if (output == NULL) + { + return; + } + for (index = 0; index < 8; ++index) + { + output[index] = (unsigned char)(value >> (index * 8)); + } +} + +uint32_t +tiny_codec_get_u32(const unsigned char *input) +{ + if (input == NULL) + { + return 0; + } + return (uint32_t)input[0] | ((uint32_t)input[1] << 8) | ((uint32_t)input[2] << 16) + | ((uint32_t)input[3] << 24); +} + +uint64_t +tiny_codec_get_u64(const unsigned char *input) +{ + uint64_t value = 0; + int index; + if (input == NULL) + { + return 0; + } + for (index = 7; index >= 0; --index) + { + value = (value << 8) | input[index]; + } + return value; +} + +uint32_t +tiny_codec_crc32(const unsigned char *data, size_t length) +{ + uint32_t crc = 0xffffffffU; + size_t index; + if (data == NULL && length != 0) + { + return 0; + } + for (index = 0; index < length; ++index) + { + unsigned bit; + crc ^= data[index]; + for (bit = 0; bit < 8; ++bit) + { + crc = (crc >> 1) ^ (0xedb88320U & (uint32_t)-(int32_t)(crc & 1)); + } + } + return ~crc; +} diff --git a/src/storage/codec.h b/src/storage/codec.h new file mode 100644 index 0000000..aafd174 --- /dev/null +++ b/src/storage/codec.h @@ -0,0 +1,13 @@ +#ifndef TINY_CONVMEM_STORAGE_CODEC_H +#define TINY_CONVMEM_STORAGE_CODEC_H + +#include <stddef.h> +#include <stdint.h> + +void tiny_codec_put_u32(unsigned char *output, uint32_t value); +void tiny_codec_put_u64(unsigned char *output, uint64_t value); +uint32_t tiny_codec_get_u32(const unsigned char *input); +uint64_t tiny_codec_get_u64(const unsigned char *input); +uint32_t tiny_codec_crc32(const unsigned char *data, size_t length); + +#endif diff --git a/src/storage/store.c b/src/storage/store.c index 9d26e0f..296f4d0 100644 --- a/src/storage/store.c +++ b/src/storage/store.c @@ -1,13 +1,13 @@ -#define WIN32_LEAN_AND_MEAN -#include <windows.h> -#include <bcrypt.h> - #include "storage/store.h" +#include "storage/codec.h" +#include "sys/system.h" #include <stdio.h> #include <stdlib.h> #include <string.h> +#include "base/allocator.h" + #define RECORD_HEADER_SIZE 24U #define RECORD_VERSION 1U #define RECORD_PUT 1U @@ -18,11 +18,11 @@ struct tiny_store { - HANDLE lock; - HANDLE log; - wchar_t *directory; - wchar_t *log_path; - wchar_t *snapshot_path; + tiny_sys_file_t lock; + tiny_sys_file_t log; + char *directory; + char *log_path; + char *snapshot_path; tiny_store_entry_t *entries; size_t entry_count; size_t entry_capacity; @@ -37,89 +37,19 @@ struct tiny_store }; static void -put_u32(unsigned char *p, uint32_t v) -{ - p[0] = (unsigned char)v; - p[1] = (unsigned char)(v >> 8); - p[2] = (unsigned char)(v >> 16); - p[3] = (unsigned char)(v >> 24); -} -static void -put_u64(unsigned char *p, uint64_t v) -{ - unsigned i; - for (i = 0; i < 8; ++i) - { - p[i] = (unsigned char)(v >> (i * 8)); - } -} -static uint32_t -get_u32(const unsigned char *p) -{ - return (uint32_t)p[0] | ((uint32_t)p[1] << 8) | ((uint32_t)p[2] << 16) | ((uint32_t)p[3] << 24); -} -static uint64_t -get_u64(const unsigned char *p) -{ - uint64_t v = 0; - int i; - for (i = 7; i >= 0; --i) - { - v = (v << 8) | p[i]; - } - return v; -} - -static uint32_t -crc32_bytes(const unsigned char *data, size_t length) -{ - uint32_t crc = 0xffffffffU; - size_t i; - for (i = 0; i < length; ++i) - { - unsigned bit; - crc ^= data[i]; - for (bit = 0; bit < 8; ++bit) - { - crc = (crc >> 1) ^ (0xedb88320U & (uint32_t)-(int32_t)(crc & 1)); - } - } - return ~crc; -} - -static wchar_t * -join_path(const wchar_t *directory, const wchar_t *name) -{ - const size_t left = wcslen(directory), right = wcslen(name); - const bool separator = left > 0 && directory[left - 1] != L'\\' && directory[left - 1] != L'/'; - wchar_t *result = malloc((left + right + (separator ? 2 : 1)) * sizeof(wchar_t)); - if (result == NULL) - { - return NULL; - } - wcscpy_s(result, left + right + (separator ? 2 : 1), directory); - if (separator) - { - wcscat_s(result, left + right + 2, L"\\"); - } - wcscat_s(result, left + right + (separator ? 2 : 1), name); - return result; -} - -static void set_error(tiny_store_t *store, const char *message) { - strncpy_s(store->last_error, sizeof(store->last_error), message, _TRUNCATE); + snprintf(store->last_error, sizeof(store->last_error), "%s", message); } static bool -write_all(HANDLE file, const void *data, uint32_t length) +write_all(tiny_sys_file_t *file, const void *data, uint32_t length) { const unsigned char *position = data; while (length > 0) { - DWORD written = 0; - if (!WriteFile(file, position, length, &written, NULL) || written == 0) + uint32_t written = 0; + if (!tiny_sys_file_write(file, position, length, &written) || written == 0) { return false; } @@ -130,14 +60,14 @@ write_all(HANDLE file, const void *data, uint32_t length) } static bool -read_all(HANDLE file, void *data, uint32_t length, uint32_t *actual) +read_all(tiny_sys_file_t *file, void *data, uint32_t length, uint32_t *actual) { unsigned char *position = data; uint32_t total = 0; while (total < length) { - DWORD count = 0; - if (!ReadFile(file, position + total, length - total, &count, NULL)) + uint32_t count = 0; + if (!tiny_sys_file_read(file, position + total, length - total, &count)) { return false; } @@ -154,12 +84,7 @@ read_all(HANDLE file, void *data, uint32_t length, uint32_t *actual) static uint64_t now_milliseconds(void) { - FILETIME ft; - ULARGE_INTEGER ticks; - GetSystemTimeAsFileTime(&ft); - ticks.LowPart = ft.dwLowDateTime; - ticks.HighPart = ft.dwHighDateTime; - return ticks.QuadPart / 10000ULL - 11644473600000ULL; + return tiny_sys_now_milliseconds(); } static void @@ -171,11 +96,9 @@ make_ulid(tiny_store_t *store, uint64_t timestamp, char output[27]) if (timestamp > store->last_ulid_ms) { store->last_ulid_ms = timestamp; - if (BCryptGenRandom( - NULL, store->last_random, sizeof(store->last_random), BCRYPT_USE_SYSTEM_PREFERRED_RNG) - != 0) + if (!tiny_sys_random_bytes(store->last_random, sizeof(store->last_random))) { - uint64_t fallback = GetTickCount64() ^ timestamp; + uint64_t fallback = tiny_sys_monotonic_milliseconds() ^ timestamp; for (index = 0; index < sizeof(store->last_random); ++index) { store->last_random[index] = (unsigned char)(fallback >> ((index % 8) * 8)); @@ -274,19 +197,19 @@ serialize_put(const tiny_message_t *message, unsigned char **payload, uint32_t * return false; } offset = 0; - put_u64(data + offset, (uint64_t)message->timestamp_ms); + tiny_codec_put_u64(data + offset, (uint64_t)message->timestamp_ms); offset += 8; memcpy(data + offset, message->message_id, 26); offset += 26; data[offset++] = (unsigned char)message->role; data[offset++] = (unsigned char)message->metadata.memory_tier; - put_u32(data + offset, (uint32_t)session_len); + tiny_codec_put_u32(data + offset, (uint32_t)session_len); offset += 4; - put_u32(data + offset, (uint32_t)content_len); + tiny_codec_put_u32(data + offset, (uint32_t)content_len); offset += 4; - put_u32(data + offset, (uint32_t)tool_len); + tiny_codec_put_u32(data + offset, (uint32_t)tool_len); offset += 4; - put_u32(data + offset, (uint32_t)message->metadata.tag_count); + tiny_codec_put_u32(data + offset, (uint32_t)message->metadata.tag_count); offset += 4; memcpy(data + offset, message->session_id, session_len); offset += session_len; @@ -300,7 +223,7 @@ serialize_put(const tiny_message_t *message, unsigned char **payload, uint32_t * for (index = 0; index < message->metadata.tag_count; ++index) { const size_t tag_len = strlen(message->metadata.tags[index]); - put_u32(data + offset, (uint32_t)tag_len); + tiny_codec_put_u32(data + offset, (uint32_t)tag_len); offset += 4; memcpy(data + offset, message->metadata.tags[index], tag_len); offset += tag_len; @@ -323,6 +246,13 @@ copy_field(const unsigned char *data, size_t length) } static bool +deserialize_failed(tiny_message_t *message) +{ + tiny_message_free(message); + return false; +} + +static bool deserialize_put(const unsigned char *data, uint32_t length, tiny_message_t *message) { uint32_t session_len, content_len, tool_len, tag_count; @@ -332,20 +262,20 @@ deserialize_put(const unsigned char *data, uint32_t length, tiny_message_t *mess { return false; } - message->timestamp_ms = (int64_t)get_u64(data + offset); + message->timestamp_ms = (int64_t)tiny_codec_get_u64(data + offset); offset += 8; memcpy(message->message_id, data + offset, 26); message->message_id[26] = '\0'; offset += 26; message->role = (tiny_role_t)data[offset++]; message->metadata.memory_tier = (tiny_memory_tier_t)data[offset++]; - session_len = get_u32(data + offset); + session_len = tiny_codec_get_u32(data + offset); offset += 4; - content_len = get_u32(data + offset); + content_len = tiny_codec_get_u32(data + offset); offset += 4; - tool_len = get_u32(data + offset); + tool_len = tiny_codec_get_u32(data + offset); offset += 4; - tag_count = get_u32(data + offset); + tag_count = tiny_codec_get_u32(data + offset); offset += 4; if (!tiny_message_id_valid(message->message_id) || (message->role < TINY_ROLE_USER || message->role > TINY_ROLE_SYSTEM) @@ -369,33 +299,33 @@ deserialize_put(const unsigned char *data, uint32_t length, tiny_message_t *mess if (message->session_id == NULL || message->content == NULL || (tool_len && !message->metadata.tool_call_id)) { - goto fail; + return deserialize_failed(message); } if (tag_count > 0) { message->metadata.tags = calloc(tag_count, sizeof(char *)); if (message->metadata.tags == NULL) { - goto fail; + return deserialize_failed(message); } for (index = 0; index < tag_count; ++index) { uint32_t tag_len; if (offset + 4 > length) { - goto fail; + return deserialize_failed(message); } - tag_len = get_u32(data + offset); + tag_len = tiny_codec_get_u32(data + offset); offset += 4; if (tag_len == 0 || tag_len > TINY_TAG_MAX || offset + tag_len > length) { - goto fail; + return deserialize_failed(message); } message->metadata.tags[index] = copy_field(data + offset, tag_len); offset += tag_len; if (message->metadata.tags[index] == NULL) { - goto fail; + return deserialize_failed(message); } message->metadata.tag_count++; } @@ -403,16 +333,13 @@ deserialize_put(const unsigned char *data, uint32_t length, tiny_message_t *mess if (offset != length || !tiny_utf8_valid(message->session_id, session_len) || !tiny_utf8_valid(message->content, content_len)) { - goto fail; + return deserialize_failed(message); } return true; -fail: - tiny_message_free(message); - return false; } static tiny_store_status_t -append_record_to(HANDLE file, +append_record_to(tiny_sys_file_t *file, uint8_t type, uint64_t sequence, const unsigned char *payload, @@ -420,16 +347,14 @@ append_record_to(HANDLE file, bool flush) { unsigned char header[RECORD_HEADER_SIZE] = { 0 }; - LARGE_INTEGER end; memcpy(header, "TCMR", 4); header[4] = RECORD_VERSION; header[5] = type; - put_u32(header + 8, payload_length); - put_u64(header + 12, sequence); - put_u32(header + 20, crc32_bytes(payload, payload_length)); - end.QuadPart = 0; - if (!SetFilePointerEx(file, end, NULL, FILE_END) || !write_all(file, header, sizeof(header)) - || !write_all(file, payload, payload_length) || (flush && !FlushFileBuffers(file))) + tiny_codec_put_u32(header + 8, payload_length); + tiny_codec_put_u64(header + 12, sequence); + tiny_codec_put_u32(header + 20, tiny_codec_crc32(payload, payload_length)); + if (!tiny_sys_file_seek(file, 0, TINY_SYS_SEEK_END) || !write_all(file, header, sizeof(header)) + || !write_all(file, payload, payload_length) || (flush && !tiny_sys_file_flush(file))) { return TINY_STORE_IO_ERROR; } @@ -469,38 +394,51 @@ clear_entries(tiny_store_t *store) } static tiny_store_status_t +recover_torn_tail(tiny_store_t *store, uint64_t boundary) +{ + if (boundary > INT64_MAX + || !tiny_sys_file_seek(&store->log, (int64_t)boundary, TINY_SYS_SEEK_BEGIN) + || !tiny_sys_file_truncate(&store->log) || !tiny_sys_file_flush(&store->log)) + { + return TINY_STORE_IO_ERROR; + } + store->recovered_tail = true; + set_error(store, "an incomplete trailing record was truncated during recovery"); + return TINY_STORE_OK; +} + +static tiny_store_status_t replay_log(tiny_store_t *store, uint64_t starting_offset) { - LARGE_INTEGER start, file_size, boundary; - start.QuadPart = (LONGLONG)starting_offset; - boundary = start; - if (!SetFilePointerEx(store->log, start, NULL, FILE_BEGIN) - || !GetFileSizeEx(store->log, &file_size)) + uint64_t file_size, boundary = starting_offset; + if (starting_offset > INT64_MAX + || !tiny_sys_file_seek(&store->log, (int64_t)starting_offset, TINY_SYS_SEEK_BEGIN) + || !tiny_sys_file_size(&store->log, &file_size)) { return TINY_STORE_IO_ERROR; } - while (boundary.QuadPart < file_size.QuadPart) + while (boundary < file_size) { unsigned char header[RECORD_HEADER_SIZE], *payload = NULL; uint32_t actual = 0, payload_length, expected_crc; uint64_t sequence; tiny_store_status_t status = TINY_STORE_OK; - if (!read_all(store->log, header, sizeof(header), &actual)) + if (!read_all(&store->log, header, sizeof(header), &actual)) { return TINY_STORE_IO_ERROR; } if (actual < sizeof(header)) { - goto torn_tail; + return recover_torn_tail(store, boundary); } if (memcmp(header, "TCMR", 4) != 0 || header[4] != RECORD_VERSION || (header[5] != RECORD_PUT && header[5] != RECORD_DEL && header[5] != RECORD_MARKER)) { return TINY_STORE_CORRUPT; } - payload_length = get_u32(header + 8); - sequence = get_u64(header + 12); - expected_crc = get_u32(header + 20); + payload_length = tiny_codec_get_u32(header + 8); + sequence = tiny_codec_get_u64(header + 12); + expected_crc = tiny_codec_get_u32(header + 20); if (payload_length > RECORD_PAYLOAD_MAX || (header[5] != RECORD_MARKER && sequence <= store->last_sequence)) { @@ -511,7 +449,7 @@ replay_log(tiny_store_t *store, uint64_t starting_offset) { return TINY_STORE_NO_MEMORY; } - if (!read_all(store->log, payload, payload_length, &actual)) + if (!read_all(&store->log, payload, payload_length, &actual)) { free(payload); return TINY_STORE_IO_ERROR; @@ -519,53 +457,62 @@ replay_log(tiny_store_t *store, uint64_t starting_offset) if (actual < payload_length) { free(payload); - goto torn_tail; + return recover_torn_tail(store, boundary); } - if (crc32_bytes(payload, payload_length) != expected_crc) + if (tiny_codec_crc32(payload, payload_length) != expected_crc) { free(payload); return TINY_STORE_CORRUPT; } - if (header[5] == RECORD_MARKER) + switch (header[5]) { - if (boundary.QuadPart != 0 || sequence != 0 - || payload_length != sizeof(store->generation)) + case RECORD_MARKER: + if (boundary != 0 || sequence != 0 || payload_length != sizeof(store->generation)) { free(payload); return TINY_STORE_CORRUPT; } memcpy(store->generation, payload, sizeof(store->generation)); store->has_generation = true; - } - else if (header[5] == RECORD_PUT) - { - tiny_message_t message; - if (!deserialize_put(payload, payload_length, &message)) - { - free(payload); - return TINY_STORE_CORRUPT; - } - status = apply_put(store, &message, sequence); - tiny_message_free(&message); - } - else - { - char id[27]; - tiny_store_entry_t *entry; - if (payload_length != 34) - { - free(payload); - return TINY_STORE_CORRUPT; - } - memcpy(id, payload, 26); - id[26] = '\0'; - entry = find_entry(store, id); - if (entry == NULL || entry->deleted || !tiny_message_id_valid(id)) - { - free(payload); - return TINY_STORE_CORRUPT; - } - entry->deleted = true; + break; + + case RECORD_PUT: + { + tiny_message_t message; + if (!deserialize_put(payload, payload_length, &message)) + { + free(payload); + return TINY_STORE_CORRUPT; + } + status = apply_put(store, &message, sequence); + tiny_message_free(&message); + } + break; + + case RECORD_DEL: + { + char id[27]; + tiny_store_entry_t *entry; + if (payload_length != 34) + { + free(payload); + return TINY_STORE_CORRUPT; + } + memcpy(id, payload, 26); + id[26] = '\0'; + entry = find_entry(store, id); + if (entry == NULL || entry->deleted || !tiny_message_id_valid(id)) + { + free(payload); + return TINY_STORE_CORRUPT; + } + entry->deleted = true; + } + break; + + default: + free(payload); + return TINY_STORE_CORRUPT; } free(payload); if (status != TINY_STORE_OK) @@ -576,49 +523,43 @@ replay_log(tiny_store_t *store, uint64_t starting_offset) { store->last_sequence = sequence; } - boundary.QuadPart += RECORD_HEADER_SIZE + payload_length; + boundary += RECORD_HEADER_SIZE + payload_length; } return TINY_STORE_OK; - -torn_tail: - if (!SetFilePointerEx(store->log, boundary, NULL, FILE_BEGIN) || !SetEndOfFile(store->log) - || !FlushFileBuffers(store->log)) - { - return TINY_STORE_IO_ERROR; - } - store->recovered_tail = true; - set_error(store, "an incomplete trailing record was truncated during recovery"); - return TINY_STORE_OK; } static tiny_store_status_t write_snapshot(tiny_store_t *store) { - wchar_t *temporary = join_path(store->directory, L"snapshot.tmp"); - HANDLE file; + char *temporary = tiny_sys_path_join(store->directory, "snapshot.tmp"); + tiny_sys_file_t file; unsigned char data[SNAPSHOT_HEADER_SIZE] = { 0 }; - LARGE_INTEGER size; + uint64_t size; bool ok; size_t index; if (temporary == NULL) { return TINY_STORE_NO_MEMORY; } - file = CreateFileW(temporary, GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); - if (file == INVALID_HANDLE_VALUE) + if (tiny_sys_file_open(&file, temporary, TINY_SYS_FILE_WRITE_NEW) != TINY_SYS_OK) { free(temporary); return TINY_STORE_IO_ERROR; } - GetFileSizeEx(store->log, &size); + if (!tiny_sys_file_size(&store->log, &size)) + { + tiny_sys_file_close(&file); + free(temporary); + return TINY_STORE_IO_ERROR; + } memcpy(data, "TCMS", 4); data[4] = 1; memcpy(data + 8, store->generation, 16); - put_u64(data + 24, store->last_sequence); - put_u64(data + 32, (uint64_t)size.QuadPart); - put_u64(data + 40, (uint64_t)store->entry_count); - put_u32(data + 48, crc32_bytes(data, 48)); - ok = write_all(file, data, sizeof(data)); + tiny_codec_put_u64(data + 24, store->last_sequence); + tiny_codec_put_u64(data + 32, size); + tiny_codec_put_u64(data + 40, (uint64_t)store->entry_count); + tiny_codec_put_u32(data + 48, tiny_codec_crc32(data, 48)); + ok = write_all(&file, data, sizeof(data)); for (index = 0; ok && index < store->entry_count; ++index) { unsigned char prefix[16], *payload = NULL; @@ -628,22 +569,20 @@ write_snapshot(tiny_store_t *store) ok = false; break; } - put_u64(prefix, store->entries[index].sequence); - put_u32(prefix + 8, length); - put_u32(prefix + 12, crc32_bytes(payload, length)); - ok = write_all(file, prefix, sizeof(prefix)) && write_all(file, payload, length); + tiny_codec_put_u64(prefix, store->entries[index].sequence); + tiny_codec_put_u32(prefix + 8, length); + tiny_codec_put_u32(prefix + 12, tiny_codec_crc32(payload, length)); + ok = write_all(&file, prefix, sizeof(prefix)) && write_all(&file, payload, length); free(payload); } if (ok) { - ok = FlushFileBuffers(file) != 0; + ok = tiny_sys_file_flush(&file); } - CloseHandle(file); + tiny_sys_file_close(&file); if (ok) { - ok = MoveFileExW( - temporary, store->snapshot_path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) - != 0; + ok = tiny_sys_path_replace(temporary, store->snapshot_path); } free(temporary); store->snapshot_valid = ok; @@ -655,13 +594,12 @@ log_generation_matches(tiny_store_t *store, const unsigned char generation[16]) { unsigned char header[RECORD_HEADER_SIZE], payload[16]; uint32_t actual = 0; - LARGE_INTEGER zero = { 0 }; - if (!SetFilePointerEx(store->log, zero, NULL, FILE_BEGIN) - || !read_all(store->log, header, sizeof(header), &actual) || actual != sizeof(header) + if (!tiny_sys_file_seek(&store->log, 0, TINY_SYS_SEEK_BEGIN) + || !read_all(&store->log, header, sizeof(header), &actual) || actual != sizeof(header) || memcmp(header, "TCMR", 4) != 0 || header[4] != RECORD_VERSION || header[5] != RECORD_MARKER - || get_u64(header + 12) != 0 || get_u32(header + 8) != 16 - || !read_all(store->log, payload, sizeof(payload), &actual) || actual != sizeof(payload) - || get_u32(header + 20) != crc32_bytes(payload, sizeof(payload))) + || tiny_codec_get_u64(header + 12) != 0 || tiny_codec_get_u32(header + 8) != 16 + || !read_all(&store->log, payload, sizeof(payload), &actual) || actual != sizeof(payload) + || tiny_codec_get_u32(header + 20) != tiny_codec_crc32(payload, sizeof(payload))) { return false; } @@ -669,36 +607,43 @@ log_generation_matches(tiny_store_t *store, const unsigned char generation[16]) } static bool +snapshot_invalid(tiny_store_t *store, tiny_sys_file_t *file, bool clear_loaded_entries) +{ + if (clear_loaded_entries) + { + clear_entries(store); + store->has_generation = false; + } + set_error(store, "snapshot validation failed; the active log was replayed from the beginning"); + tiny_sys_file_close(file); + return false; +} + +static bool load_snapshot(tiny_store_t *store, uint64_t *log_offset) { - HANDLE file = CreateFileW(store->snapshot_path, - GENERIC_READ, - FILE_SHARE_READ, - NULL, - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - NULL); + tiny_sys_file_t file; unsigned char header[SNAPSHOT_HEADER_SIZE]; uint32_t actual = 0; uint64_t count, index, expected_sequence, offset; - LARGE_INTEGER log_size; - if (file == INVALID_HANDLE_VALUE) + uint64_t log_size; + if (tiny_sys_file_open(&file, store->snapshot_path, TINY_SYS_FILE_READ) != TINY_SYS_OK) { return false; } - if (!read_all(file, header, sizeof(header), &actual) || actual != sizeof(header) + if (!read_all(&file, header, sizeof(header), &actual) || actual != sizeof(header) || memcmp(header, "TCMS", 4) != 0 || header[4] != 1 - || get_u32(header + 48) != crc32_bytes(header, 48) || !GetFileSizeEx(store->log, &log_size)) + || tiny_codec_get_u32(header + 48) != tiny_codec_crc32(header, 48) + || !tiny_sys_file_size(&store->log, &log_size)) { - goto invalid; + return snapshot_invalid(store, &file, false); } - expected_sequence = get_u64(header + 24); - offset = get_u64(header + 32); - count = get_u64(header + 40); - if (offset > (uint64_t)log_size.QuadPart || count > SIZE_MAX - || !log_generation_matches(store, header + 8)) + expected_sequence = tiny_codec_get_u64(header + 24); + offset = tiny_codec_get_u64(header + 32); + count = tiny_codec_get_u64(header + 40); + if (offset > log_size || count > SIZE_MAX || !log_generation_matches(store, header + 8)) { - goto invalid; + return snapshot_invalid(store, &file, false); } memcpy(store->generation, header + 8, 16); store->has_generation = true; @@ -708,99 +653,102 @@ load_snapshot(tiny_store_t *store, uint64_t *log_offset) uint32_t length, crc; tiny_message_t message; tiny_store_status_t status; - if (!read_all(file, prefix, sizeof(prefix), &actual) || actual != sizeof(prefix)) + if (!read_all(&file, prefix, sizeof(prefix), &actual) || actual != sizeof(prefix)) { - goto invalid_entries; + return snapshot_invalid(store, &file, true); } - length = get_u32(prefix + 8); - crc = get_u32(prefix + 12); + length = tiny_codec_get_u32(prefix + 8); + crc = tiny_codec_get_u32(prefix + 12); if (length > RECORD_PAYLOAD_MAX) { - goto invalid_entries; + return snapshot_invalid(store, &file, true); } payload = malloc(length ? length : 1); if (!payload) { - goto invalid_entries; + return snapshot_invalid(store, &file, true); } - if (!read_all(file, payload, length, &actual) || actual != length - || crc32_bytes(payload, length) != crc || !deserialize_put(payload, length, &message)) + if (!read_all(&file, payload, length, &actual) || actual != length + || tiny_codec_crc32(payload, length) != crc + || !deserialize_put(payload, length, &message)) { free(payload); - goto invalid_entries; + return snapshot_invalid(store, &file, true); } free(payload); - status = apply_put(store, &message, get_u64(prefix)); + status = apply_put(store, &message, tiny_codec_get_u64(prefix)); tiny_message_free(&message); if (status != TINY_STORE_OK) { - goto invalid_entries; + return snapshot_invalid(store, &file, true); } - store->last_sequence = get_u64(prefix); + store->last_sequence = tiny_codec_get_u64(prefix); } if (store->last_sequence != expected_sequence) { - goto invalid_entries; + return snapshot_invalid(store, &file, true); } - CloseHandle(file); + tiny_sys_file_close(&file); *log_offset = offset; store->snapshot_valid = true; return true; -invalid_entries: - clear_entries(store); - store->has_generation = false; -invalid: - set_error(store, "snapshot validation failed; the active log was replayed from the beginning"); - CloseHandle(file); - return false; +} + +static tiny_store_status_t +store_open_failed(tiny_store_t *store, char *lock_path, tiny_store_status_t status) +{ + free(lock_path); + tiny_store_close(store); + return status; } tiny_store_status_t -tiny_store_open(tiny_store_t **output, const wchar_t *directory) +tiny_store_open(tiny_store_t **output, const char *directory) { - tiny_store_t *store = calloc(1, sizeof(*store)); - wchar_t *lock_path = NULL; + tiny_store_t *store; + char *lock_path = NULL; + tiny_sys_status_t sys_status; tiny_store_status_t status; + if (output == NULL) + { + return TINY_STORE_IO_ERROR; + } + *output = NULL; + if (directory == NULL || directory[0] == '\0') + { + return TINY_STORE_IO_ERROR; + } + store = calloc(1, sizeof(*store)); if (store == NULL) { return TINY_STORE_NO_MEMORY; } - store->lock = INVALID_HANDLE_VALUE; - store->log = INVALID_HANDLE_VALUE; - store->directory = _wcsdup(directory); - store->log_path = join_path(directory, L"active.log"); - store->snapshot_path = join_path(directory, L"snapshot.bin"); - lock_path = join_path(directory, L"store.lock"); + tiny_sys_file_init(&store->lock); + tiny_sys_file_init(&store->log); + store->directory = copy_field((const unsigned char *)directory, strlen(directory)); + store->log_path = tiny_sys_path_join(directory, "active.log"); + store->snapshot_path = tiny_sys_path_join(directory, "snapshot.bin"); + lock_path = tiny_sys_path_join(directory, "store.lock"); if (!store->directory || !store->log_path || !store->snapshot_path || !lock_path) { - status = TINY_STORE_NO_MEMORY; - goto fail; + return store_open_failed(store, lock_path, TINY_STORE_NO_MEMORY); } - if (!CreateDirectoryW(directory, NULL) && GetLastError() != ERROR_ALREADY_EXISTS) + if (tiny_sys_directory_create(directory) != TINY_SYS_OK) { - status = TINY_STORE_IO_ERROR; - goto fail; + return store_open_failed(store, lock_path, TINY_STORE_IO_ERROR); } - store->lock = CreateFileW( - lock_path, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); + sys_status = tiny_sys_file_open(&store->lock, lock_path, TINY_SYS_FILE_LOCK); free(lock_path); lock_path = NULL; - if (store->lock == INVALID_HANDLE_VALUE) + if (sys_status != TINY_SYS_OK) { - status = GetLastError() == ERROR_SHARING_VIOLATION ? TINY_STORE_BUSY : TINY_STORE_IO_ERROR; - goto fail; + status = sys_status == TINY_SYS_BUSY ? TINY_STORE_BUSY : TINY_STORE_IO_ERROR; + return store_open_failed(store, lock_path, status); } - store->log = CreateFileW(store->log_path, - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ, - NULL, - OPEN_ALWAYS, - FILE_ATTRIBUTE_NORMAL, - NULL); - if (store->log == INVALID_HANDLE_VALUE) + if (tiny_sys_file_open(&store->log, store->log_path, TINY_SYS_FILE_READ_WRITE_CREATE) + != TINY_SYS_OK) { - status = TINY_STORE_IO_ERROR; - goto fail; + return store_open_failed(store, lock_path, TINY_STORE_IO_ERROR); } { uint64_t replay_offset = 0; @@ -809,14 +757,10 @@ tiny_store_open(tiny_store_t **output, const wchar_t *directory) } if (status != TINY_STORE_OK) { - goto fail; + return store_open_failed(store, lock_path, status); } *output = store; return TINY_STORE_OK; -fail: - free(lock_path); - tiny_store_close(store); - return status; } void @@ -827,14 +771,8 @@ tiny_store_close(tiny_store_t *store) { return; } - if (store->log != INVALID_HANDLE_VALUE) - { - CloseHandle(store->log); - } - if (store->lock != INVALID_HANDLE_VALUE) - { - CloseHandle(store->lock); - } + tiny_sys_file_close(&store->log); + tiny_sys_file_close(&store->lock); for (index = 0; index < store->entry_count; ++index) { tiny_message_free(&store->entries[index].message); @@ -873,6 +811,15 @@ tiny_store_write(tiny_store_t *store, const tiny_message_t *input, tiny_message_ unsigned char *payload = NULL; uint32_t length = 0; tiny_store_status_t status; + if (written != NULL) + { + tiny_message_init(written); + } + if (store == NULL || input == NULL || written == NULL || input->session_id == NULL + || input->content == NULL) + { + return TINY_STORE_IO_ERROR; + } tiny_message_init(&message); tiny_message_init(&indexed); if (tiny_message_copy(&message, input) != 0) @@ -890,7 +837,7 @@ tiny_store_write(tiny_store_t *store, const tiny_message_t *input, tiny_message_ return TINY_STORE_NO_MEMORY; } status - = append_record_to(store->log, RECORD_PUT, store->last_sequence + 1, payload, length, true); + = append_record_to(&store->log, RECORD_PUT, store->last_sequence + 1, payload, length, true); free(payload); if (status != TINY_STORE_OK) { @@ -911,18 +858,23 @@ tiny_store_write(tiny_store_t *store, const tiny_message_t *input, tiny_message_ tiny_store_status_t tiny_store_delete(tiny_store_t *store, const char *message_id, const char *session_id) { - tiny_store_entry_t *entry = find_entry(store, message_id); + tiny_store_entry_t *entry; unsigned char payload[34]; tiny_store_status_t status; + if (store == NULL || message_id == NULL || !tiny_message_id_valid(message_id)) + { + return TINY_STORE_NOT_FOUND; + } + entry = find_entry(store, message_id); if (!entry || entry->deleted || (session_id && strcmp(entry->message.session_id, session_id) != 0)) { return TINY_STORE_NOT_FOUND; } memcpy(payload, message_id, 26); - put_u64(payload + 26, now_milliseconds()); + tiny_codec_put_u64(payload + 26, now_milliseconds()); status = append_record_to( - store->log, RECORD_DEL, store->last_sequence + 1, payload, sizeof(payload), true); + &store->log, RECORD_DEL, store->last_sequence + 1, payload, sizeof(payload), true); if (status != TINY_STORE_OK) { set_error(store, "failed to append and flush a DEL record"); @@ -936,35 +888,45 @@ tiny_store_delete(tiny_store_t *store, const char *message_id, const char *sessi tiny_store_status_t tiny_store_rebuild(tiny_store_t *store, tiny_rebuild_report_t *report) { - wchar_t *temporary = join_path(store->directory, L"active.tmp"); - HANDLE file; - LARGE_INTEGER before, after; + char *temporary; + tiny_sys_file_t file; + uint64_t before = 0, after = 0; size_t read_index, write_index = 0; uint64_t removed = 0, max_sequence = 0; tiny_store_status_t status = TINY_STORE_OK; + unsigned char previous_generation[sizeof(store->generation)]; + bool previous_has_generation; + bool log_replaced = false; + if (report != NULL) + { + memset(report, 0, sizeof(*report)); + } + if (store == NULL || report == NULL) + { + return TINY_STORE_IO_ERROR; + } + memcpy(previous_generation, store->generation, sizeof(previous_generation)); + previous_has_generation = store->has_generation; + temporary = tiny_sys_path_join(store->directory, "active.tmp"); if (!temporary) { return TINY_STORE_NO_MEMORY; } - GetFileSizeEx(store->log, &before); - file = CreateFileW( - temporary, GENERIC_WRITE | GENERIC_READ, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); - if (file == INVALID_HANDLE_VALUE) + if (!tiny_sys_file_size(&store->log, &before) + || tiny_sys_file_open(&file, temporary, TINY_SYS_FILE_WRITE_NEW) != TINY_SYS_OK) { free(temporary); return TINY_STORE_IO_ERROR; } - if (BCryptGenRandom( - NULL, store->generation, sizeof(store->generation), BCRYPT_USE_SYSTEM_PREFERRED_RNG) - != 0) + if (!tiny_sys_random_bytes(store->generation, sizeof(store->generation))) { - CloseHandle(file); + tiny_sys_file_close(&file); free(temporary); return TINY_STORE_IO_ERROR; } store->has_generation = true; status = append_record_to( - file, RECORD_MARKER, 0, store->generation, sizeof(store->generation), false); + &file, RECORD_MARKER, 0, store->generation, sizeof(store->generation), false); for (read_index = 0; read_index < store->entry_count; ++read_index) { if (status == TINY_STORE_OK && !store->entries[read_index].deleted) @@ -977,7 +939,7 @@ tiny_store_rebuild(tiny_store_t *store, tiny_rebuild_report_t *report) break; } status = append_record_to( - file, RECORD_PUT, store->entries[read_index].sequence, payload, length, false); + &file, RECORD_PUT, store->entries[read_index].sequence, payload, length, false); free(payload); if (status != TINY_STORE_OK) { @@ -993,28 +955,24 @@ tiny_store_rebuild(tiny_store_t *store, tiny_rebuild_report_t *report) removed++; } } - if (status == TINY_STORE_OK && !FlushFileBuffers(file)) + if (status == TINY_STORE_OK && !tiny_sys_file_flush(&file)) { status = TINY_STORE_IO_ERROR; } - CloseHandle(file); + tiny_sys_file_close(&file); if (status == TINY_STORE_OK) { - CloseHandle(store->log); - store->log = INVALID_HANDLE_VALUE; - if (!MoveFileExW( - temporary, store->log_path, MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH)) + tiny_sys_file_close(&store->log); + if (!tiny_sys_path_replace(temporary, store->log_path)) { status = TINY_STORE_IO_ERROR; } - store->log = CreateFileW(store->log_path, - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ, - NULL, - OPEN_ALWAYS, - FILE_ATTRIBUTE_NORMAL, - NULL); - if (store->log == INVALID_HANDLE_VALUE) + else + { + log_replaced = true; + } + if (tiny_sys_file_open(&store->log, store->log_path, TINY_SYS_FILE_READ_WRITE_CREATE) + != TINY_SYS_OK) { status = TINY_STORE_IO_ERROR; } @@ -1038,15 +996,20 @@ tiny_store_rebuild(tiny_store_t *store, tiny_rebuild_report_t *report) } store->entry_count = write_index; store->last_sequence = max_sequence; - GetFileSizeEx(store->log, &after); + (void)tiny_sys_file_size(&store->log, &after); report->messages_indexed = write_index; report->records_removed = removed; - report->bytes_before = (uint64_t)before.QuadPart; - report->bytes_after = (uint64_t)after.QuadPart; + report->bytes_before = before; + report->bytes_after = after; status = write_snapshot(store); } if (status != TINY_STORE_OK) { + if (!log_replaced) + { + memcpy(store->generation, previous_generation, sizeof(store->generation)); + store->has_generation = previous_has_generation; + } set_error(store, "compaction did not complete; the previous valid log is retained when replacement " "had not begun"); @@ -1058,28 +1021,30 @@ tiny_store_rebuild(tiny_store_t *store, tiny_rebuild_report_t *report) void tiny_store_health(const tiny_store_t *store, tiny_store_health_t *health) { - LARGE_INTEGER size; + uint64_t size; size_t index; + if (health == NULL) + { + return; + } memset(health, 0, sizeof(*health)); - if (GetFileSizeEx(store->log, &size)) + if (store == NULL) + { + return; + } + if (tiny_sys_file_size(&store->log, &size)) { - health->log_size = (uint64_t)size.QuadPart; + health->log_size = size; } { - HANDLE f = CreateFileW(store->snapshot_path, - GENERIC_READ, - FILE_SHARE_READ, - NULL, - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - NULL); - if (f != INVALID_HANDLE_VALUE) + tiny_sys_file_t file; + if (tiny_sys_file_open(&file, store->snapshot_path, TINY_SYS_FILE_READ) == TINY_SYS_OK) { - if (GetFileSizeEx(f, &size)) + if (tiny_sys_file_size(&file, &size)) { - health->snapshot_size = (uint64_t)size.QuadPart; + health->snapshot_size = size; } - CloseHandle(f); + tiny_sys_file_close(&file); } } for (index = 0; index < store->entry_count; ++index) @@ -1096,16 +1061,16 @@ tiny_store_health(const tiny_store_t *store, tiny_store_health_t *health) health->last_sequence = store->last_sequence; health->recovered_tail = store->recovered_tail; health->snapshot_valid = store->snapshot_valid; - strncpy_s(health->last_error, sizeof(health->last_error), store->last_error, _TRUNCATE); + snprintf(health->last_error, sizeof(health->last_error), "%s", store->last_error); } size_t tiny_store_entry_count(const tiny_store_t *store) { - return store->entry_count; + return store == NULL ? 0 : store->entry_count; } const tiny_store_entry_t * tiny_store_entry(const tiny_store_t *store, size_t index) { - return index < store->entry_count ? &store->entries[index] : NULL; + return store != NULL && index < store->entry_count ? &store->entries[index] : NULL; } diff --git a/src/storage/store.h b/src/storage/store.h index cc14a80..619f86f 100644 --- a/src/storage/store.h +++ b/src/storage/store.h @@ -4,7 +4,6 @@ #include <stdbool.h> #include <stddef.h> #include <stdint.h> -#include <wchar.h> #include "domain/message.h" @@ -47,7 +46,7 @@ typedef struct tiny_store_health char last_error[160]; } tiny_store_health_t; -tiny_store_status_t tiny_store_open(tiny_store_t **store, const wchar_t *directory); +tiny_store_status_t tiny_store_open(tiny_store_t **store, const char *directory); void tiny_store_close(tiny_store_t *store); const char *tiny_store_status_name(tiny_store_status_t status); diff --git a/src/sys/README.md b/src/sys/README.md new file mode 100644 index 0000000..22802d5 --- /dev/null +++ b/src/sys/README.md @@ -0,0 +1,21 @@ +# System abstraction + +`system.h` is the only OS-facing API used by the application and storage +layers. Paths and process arguments are UTF-8 strings on every platform. + +The API owns these responsibilities: + +- process argument conversion and absolute/path-join operations; +- directory creation, temporary directories, and path removal/replacement; +- opaque file handles, locking, read/write/seek/truncate/flush operations; +- wall-clock and monotonic milliseconds; +- cryptographically secure random bytes. + +Returned strings are heap allocated and must be released with `free()`. Files +must be initialized or opened through this API and closed with +`tiny_sys_file_close()`. + +Platform selection is intentionally confined to the single conditional block +in `system.c`. Windows uses wide Win32 calls internally; non-Windows builds use +the POSIX implementation. No Win32 or POSIX type is exposed by `system.h`. + diff --git a/src/sys/encoding.c b/src/sys/encoding.c new file mode 100644 index 0000000..ccdcc0a --- /dev/null +++ b/src/sys/encoding.c @@ -0,0 +1,208 @@ +#include "sys/encoding.h" + +#include "base/allocator.h" + +#include <stdbool.h> +#include <string.h> + +static bool +utf8_codepoint(const unsigned char *input, size_t length, size_t *used, uint32_t *codepoint) +{ + const unsigned char first = input[0]; + size_t width, index; + uint32_t value; + if (first < 0x80) + { + width = 1; + value = first; + } + else if (first >= 0xc2 && first <= 0xdf) + { + width = 2; + value = first & 0x1f; + } + else if (first >= 0xe0 && first <= 0xef) + { + width = 3; + value = first & 0x0f; + } + else if (first >= 0xf0 && first <= 0xf4) + { + width = 4; + value = first & 0x07; + } + else + { + return false; + } + if (width > length) + { + return false; + } + for (index = 1; index < width; ++index) + { + if ((input[index] & 0xc0) != 0x80) + { + return false; + } + value = (value << 6) | (input[index] & 0x3f); + } + if ((width == 3 && ((first == 0xe0 && input[1] < 0xa0) || (first == 0xed && input[1] >= 0xa0))) + || (width == 4 + && ((first == 0xf0 && input[1] < 0x90) || (first == 0xf4 && input[1] >= 0x90)))) + { + return false; + } + *used = width; + *codepoint = value; + return true; +} + +tiny_encoding_status_t +tiny_utf8_to_utf16(const char *input, size_t input_length, uint16_t **output, size_t *output_length) +{ + size_t offset = 0, units = 0; + uint16_t *result; + if (output == NULL || output_length == NULL) + { + return TINY_ENCODING_INVALID; + } + *output = NULL; + *output_length = 0; + if (input == NULL && input_length != 0) + { + return TINY_ENCODING_INVALID; + } + while (offset < input_length) + { + size_t used; + uint32_t point; + if (!utf8_codepoint( + (const unsigned char *)input + offset, input_length - offset, &used, &point)) + { + return TINY_ENCODING_INVALID; + } + if (units > SIZE_MAX - (point >= 0x10000 ? 2U : 1U)) + { + return TINY_ENCODING_OVERFLOW; + } + units += point >= 0x10000 ? 2U : 1U; + offset += used; + } + if (units == SIZE_MAX || units + 1 > SIZE_MAX / sizeof(*result)) + { + return TINY_ENCODING_OVERFLOW; + } + result = malloc((units + 1) * sizeof(*result)); + if (result == NULL) + { + return TINY_ENCODING_NO_MEMORY; + } + offset = 0; + units = 0; + while (offset < input_length) + { + size_t used; + uint32_t point; + (void)utf8_codepoint( + (const unsigned char *)input + offset, input_length - offset, &used, &point); + if (point < 0x10000) + { + result[units++] = (uint16_t)point; + } + else + { + point -= 0x10000; + result[units++] = (uint16_t)(0xd800 + (point >> 10)); + result[units++] = (uint16_t)(0xdc00 + (point & 0x3ff)); + } + offset += used; + } + result[units] = 0; + *output = result; + *output_length = units; + return TINY_ENCODING_OK; +} + +tiny_encoding_status_t +tiny_utf16_to_utf8(const uint16_t *input, size_t input_length, char **output, size_t *output_length) +{ + size_t index = 0, bytes = 0; + char *result; + if (output == NULL || output_length == NULL) + { + return TINY_ENCODING_INVALID; + } + *output = NULL; + *output_length = 0; + if (input == NULL && input_length != 0) + { + return TINY_ENCODING_INVALID; + } + while (index < input_length) + { + uint32_t point = input[index++]; + if (point >= 0xd800 && point <= 0xdbff) + { + if (index >= input_length || input[index] < 0xdc00 || input[index] > 0xdfff) + { + return TINY_ENCODING_INVALID; + } + point = 0x10000 + ((point - 0xd800) << 10) + (input[index++] - 0xdc00); + } + else if (point >= 0xdc00 && point <= 0xdfff) + { + return TINY_ENCODING_INVALID; + } + if (bytes > SIZE_MAX - (point < 0x80 ? 1U : point < 0x800 ? 2U : point < 0x10000 ? 3U : 4U)) + { + return TINY_ENCODING_OVERFLOW; + } + bytes += point < 0x80 ? 1U : point < 0x800 ? 2U : point < 0x10000 ? 3U : 4U; + } + if (bytes == SIZE_MAX) + { + return TINY_ENCODING_OVERFLOW; + } + result = malloc(bytes + 1); + if (result == NULL) + { + return TINY_ENCODING_NO_MEMORY; + } + index = 0; + bytes = 0; + while (index < input_length) + { + uint32_t point = input[index++]; + if (point >= 0xd800 && point <= 0xdbff) + { + point = 0x10000 + ((point - 0xd800) << 10) + (input[index++] - 0xdc00); + } + if (point < 0x80) + { + result[bytes++] = (char)point; + } + else if (point < 0x800) + { + result[bytes++] = (char)(0xc0 | (point >> 6)); + result[bytes++] = (char)(0x80 | (point & 0x3f)); + } + else if (point < 0x10000) + { + result[bytes++] = (char)(0xe0 | (point >> 12)); + result[bytes++] = (char)(0x80 | ((point >> 6) & 0x3f)); + result[bytes++] = (char)(0x80 | (point & 0x3f)); + } + else + { + result[bytes++] = (char)(0xf0 | (point >> 18)); + result[bytes++] = (char)(0x80 | ((point >> 12) & 0x3f)); + result[bytes++] = (char)(0x80 | ((point >> 6) & 0x3f)); + result[bytes++] = (char)(0x80 | (point & 0x3f)); + } + } + result[bytes] = '\0'; + *output = result; + *output_length = bytes; + return TINY_ENCODING_OK; +} diff --git a/src/sys/encoding.h b/src/sys/encoding.h new file mode 100644 index 0000000..386d319 --- /dev/null +++ b/src/sys/encoding.h @@ -0,0 +1,24 @@ +#ifndef TINY_CONVMEM_SYS_ENCODING_H +#define TINY_CONVMEM_SYS_ENCODING_H + +#include <stddef.h> +#include <stdint.h> + +typedef enum tiny_encoding_status +{ + TINY_ENCODING_OK = 0, + TINY_ENCODING_INVALID, + TINY_ENCODING_NO_MEMORY, + TINY_ENCODING_OVERFLOW +} tiny_encoding_status_t; + +tiny_encoding_status_t tiny_utf8_to_utf16(const char *input, + size_t input_length, + uint16_t **output, + size_t *output_length); +tiny_encoding_status_t tiny_utf16_to_utf8(const uint16_t *input, + size_t input_length, + char **output, + size_t *output_length); + +#endif diff --git a/src/sys/system.c b/src/sys/system.c new file mode 100644 index 0000000..63d8297 --- /dev/null +++ b/src/sys/system.c @@ -0,0 +1,890 @@ +#include "sys/system.h" +#include "sys/encoding.h" + +#include <errno.h> +#include <stdio.h> +#include <stdlib.h> +#include <string.h> + +#include "base/allocator.h" + +typedef struct tiny_sys_test_state +{ + size_t calls[TINY_SYS_TEST_OPERATION_COUNT]; + size_t failures[TINY_SYS_TEST_OPERATION_COUNT]; + uint32_t max_transfer; +} tiny_sys_test_state_t; + +static tiny_sys_test_state_t tiny_sys_test_state; + +void +tiny_sys_test_reset(void) +{ + memset(&tiny_sys_test_state, 0, sizeof(tiny_sys_test_state)); +} + +bool +tiny_sys_test_fail_after(tiny_sys_test_operation_t operation, size_t call_index) +{ + if (operation < TINY_SYS_TEST_OPEN || operation >= TINY_SYS_TEST_OPERATION_COUNT + || call_index == 0) + { + return false; + } + tiny_sys_test_state.calls[operation] = 0; + tiny_sys_test_state.failures[operation] = call_index; + return true; +} + +void +tiny_sys_test_max_transfer(uint32_t length) +{ + tiny_sys_test_state.max_transfer = length; +} + +static bool +tiny_sys_test_should_fail(tiny_sys_test_operation_t operation) +{ + ++tiny_sys_test_state.calls[operation]; + return tiny_sys_test_state.failures[operation] != 0 + && tiny_sys_test_state.calls[operation] == tiny_sys_test_state.failures[operation]; +} + +static uint32_t +tiny_sys_test_transfer_length(uint32_t length) +{ + return tiny_sys_test_state.max_transfer != 0 && length > tiny_sys_test_state.max_transfer + ? tiny_sys_test_state.max_transfer + : length; +} + +#if defined(_WIN32) + +#define WIN32_LEAN_AND_MEAN +#include <windows.h> +#include <bcrypt.h> +#include <shellapi.h> + +static wchar_t * +utf8_to_wide(const char *value) +{ + uint16_t *result; + size_t length; + _Static_assert(sizeof(wchar_t) == sizeof(uint16_t), "Windows wchar_t must be UTF-16"); + if (value == NULL) + { + return NULL; + } + if (tiny_utf8_to_utf16(value, strlen(value), &result, &length) != TINY_ENCODING_OK) + { + return NULL; + } + return (wchar_t *)result; +} + +static char * +wide_to_utf8(const wchar_t *value) +{ + char *result; + size_t length; + if (value == NULL) + { + return NULL; + } + if (tiny_utf16_to_utf8((const uint16_t *)value, wcslen(value), &result, &length) + != TINY_ENCODING_OK) + { + return NULL; + } + return result; +} + +static HANDLE +native_file(const tiny_sys_file_t *file) +{ + return (HANDLE)file->native; +} + +bool +tiny_sys_arguments_init(tiny_sys_arguments_t *arguments, int argc, char **argv) +{ + wchar_t **wide_arguments; + int wide_count, index; + (void)argc; + (void)argv; + if (arguments == NULL) + { + return false; + } + memset(arguments, 0, sizeof(*arguments)); + wide_arguments = CommandLineToArgvW(GetCommandLineW(), &wide_count); + if (wide_arguments == NULL) + { + return false; + } + arguments->values = calloc((size_t)wide_count, sizeof(*arguments->values)); + if (arguments->values == NULL) + { + LocalFree(wide_arguments); + return false; + } + arguments->count = wide_count; + for (index = 0; index < wide_count; ++index) + { + arguments->values[index] = wide_to_utf8(wide_arguments[index]); + if (arguments->values[index] == NULL) + { + LocalFree(wide_arguments); + tiny_sys_arguments_free(arguments); + return false; + } + } + LocalFree(wide_arguments); + return true; +} + +char * +tiny_sys_path_absolute(const char *path) +{ + wchar_t *wide_path = utf8_to_wide(path); + wchar_t *wide_result; + char *result; + DWORD required; + if (wide_path == NULL) + { + return NULL; + } + required = GetFullPathNameW(wide_path, 0, NULL, NULL); + if (required == 0) + { + free(wide_path); + return NULL; + } + wide_result = malloc((size_t)required * sizeof(*wide_result)); + if (wide_result == NULL || GetFullPathNameW(wide_path, required, wide_result, NULL) == 0) + { + free(wide_path); + free(wide_result); + return NULL; + } + result = wide_to_utf8(wide_result); + free(wide_path); + free(wide_result); + return result; +} + +char * +tiny_sys_path_join(const char *directory, const char *name) +{ + size_t left, right, required; + if (directory == NULL || name == NULL) + { + return NULL; + } + left = strlen(directory); + right = strlen(name); + const bool separator = left > 0 && directory[left - 1] != '\\' && directory[left - 1] != '/'; + if (left > SIZE_MAX - right - (separator ? 2u : 1u)) + { + return NULL; + } + required = left + right + (separator ? 2u : 1u); + char *result = malloc(required); + if (result == NULL) + { + return NULL; + } + memcpy(result, directory, left); + if (separator) + { + result[left] = '\\'; + } + memcpy(result + left + (separator ? 1 : 0), name, right + 1); + return result; +} + +tiny_sys_status_t +tiny_sys_directory_create(const char *path) +{ + wchar_t *wide_path = utf8_to_wide(path); + DWORD error; + if (wide_path == NULL) + { + return TINY_SYS_ERROR; + } + if (CreateDirectoryW(wide_path, NULL)) + { + free(wide_path); + return TINY_SYS_OK; + } + error = GetLastError(); + free(wide_path); + return error == ERROR_ALREADY_EXISTS ? TINY_SYS_OK : TINY_SYS_ERROR; +} + +tiny_sys_status_t +tiny_sys_file_open(tiny_sys_file_t *file, const char *path, tiny_sys_file_mode_t mode) +{ + wchar_t *wide_path = utf8_to_wide(path); + DWORD access = 0, share = 0, creation = OPEN_EXISTING, error; + HANDLE handle; + if (file == NULL) + { + free(wide_path); + return TINY_SYS_ERROR; + } + tiny_sys_file_init(file); + if (wide_path == NULL || tiny_sys_test_should_fail(TINY_SYS_TEST_OPEN)) + { + free(wide_path); + return TINY_SYS_ERROR; + } + switch (mode) + { + case TINY_SYS_FILE_READ: + access = GENERIC_READ; + share = FILE_SHARE_READ; + break; + case TINY_SYS_FILE_WRITE_NEW: + access = GENERIC_WRITE; + creation = CREATE_ALWAYS; + break; + case TINY_SYS_FILE_READ_WRITE: + access = GENERIC_READ | GENERIC_WRITE; + share = FILE_SHARE_READ; + break; + case TINY_SYS_FILE_READ_WRITE_CREATE: + access = GENERIC_READ | GENERIC_WRITE; + share = FILE_SHARE_READ; + creation = OPEN_ALWAYS; + break; + case TINY_SYS_FILE_LOCK: + access = GENERIC_READ | GENERIC_WRITE; + creation = OPEN_ALWAYS; + break; + default: + free(wide_path); + return TINY_SYS_ERROR; + } + handle = CreateFileW(wide_path, access, share, NULL, creation, FILE_ATTRIBUTE_NORMAL, NULL); + free(wide_path); + if (handle != INVALID_HANDLE_VALUE) + { + file->native = (intptr_t)handle; + return TINY_SYS_OK; + } + error = GetLastError(); + if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) + { + return TINY_SYS_NOT_FOUND; + } + if (error == ERROR_SHARING_VIOLATION || error == ERROR_LOCK_VIOLATION) + { + return TINY_SYS_BUSY; + } + return TINY_SYS_ERROR; +} + +void +tiny_sys_file_init(tiny_sys_file_t *file) +{ + if (file != NULL) + { + file->native = 0; + } +} + +bool +tiny_sys_file_valid(const tiny_sys_file_t *file) +{ + return file != NULL && file->native != 0; +} + +void +tiny_sys_file_close(tiny_sys_file_t *file) +{ + if (tiny_sys_file_valid(file)) + { + CloseHandle(native_file(file)); + tiny_sys_file_init(file); + } +} + +bool +tiny_sys_file_read(tiny_sys_file_t *file, void *data, uint32_t length, uint32_t *actual) +{ + DWORD count = 0; + bool result; + if (actual == NULL) + { + return false; + } + *actual = 0; + if (!tiny_sys_file_valid(file) || (data == NULL && length != 0) + || tiny_sys_test_should_fail(TINY_SYS_TEST_READ)) + { + return false; + } + length = tiny_sys_test_transfer_length(length); + result = ReadFile(native_file(file), data, length, &count, NULL) != 0; + *actual = count; + return result; +} + +bool +tiny_sys_file_write(tiny_sys_file_t *file, const void *data, uint32_t length, uint32_t *actual) +{ + DWORD count = 0; + bool result; + if (actual == NULL) + { + return false; + } + *actual = 0; + if (!tiny_sys_file_valid(file) || (data == NULL && length != 0) + || tiny_sys_test_should_fail(TINY_SYS_TEST_WRITE)) + { + return false; + } + length = tiny_sys_test_transfer_length(length); + result = WriteFile(native_file(file), data, length, &count, NULL) != 0; + *actual = count; + return result; +} + +bool +tiny_sys_file_seek(tiny_sys_file_t *file, int64_t offset, tiny_sys_seek_origin_t origin) +{ + LARGE_INTEGER distance; + DWORD method; + if (!tiny_sys_file_valid(file) || tiny_sys_test_should_fail(TINY_SYS_TEST_SEEK)) + { + return false; + } + distance.QuadPart = offset; + switch (origin) + { + case TINY_SYS_SEEK_BEGIN: + method = FILE_BEGIN; + break; + case TINY_SYS_SEEK_CURRENT: + method = FILE_CURRENT; + break; + case TINY_SYS_SEEK_END: + method = FILE_END; + break; + default: + return false; + } + return SetFilePointerEx(native_file(file), distance, NULL, method) != 0; +} + +bool +tiny_sys_file_size(const tiny_sys_file_t *file, uint64_t *size) +{ + LARGE_INTEGER native_size; + if (size == NULL) + { + return false; + } + *size = 0; + if (!tiny_sys_file_valid(file) || tiny_sys_test_should_fail(TINY_SYS_TEST_SIZE) + || !GetFileSizeEx(native_file(file), &native_size)) + { + return false; + } + *size = (uint64_t)native_size.QuadPart; + return true; +} + +bool +tiny_sys_file_truncate(tiny_sys_file_t *file) +{ + return tiny_sys_file_valid(file) && !tiny_sys_test_should_fail(TINY_SYS_TEST_TRUNCATE) + && SetEndOfFile(native_file(file)) != 0; +} + +bool +tiny_sys_file_flush(tiny_sys_file_t *file) +{ + return tiny_sys_file_valid(file) && !tiny_sys_test_should_fail(TINY_SYS_TEST_FLUSH) + && FlushFileBuffers(native_file(file)) != 0; +} + +bool +tiny_sys_path_replace(const char *source, const char *destination) +{ + wchar_t *wide_source = utf8_to_wide(source); + wchar_t *wide_destination = utf8_to_wide(destination); + bool result = false; + if (wide_source != NULL && wide_destination != NULL + && !tiny_sys_test_should_fail(TINY_SYS_TEST_REPLACE)) + { + result = MoveFileExW(wide_source, + wide_destination, + MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH) + != 0; + } + free(wide_source); + free(wide_destination); + return result; +} + +bool +tiny_sys_file_remove(const char *path) +{ + wchar_t *wide_path = utf8_to_wide(path); + bool result = wide_path != NULL && DeleteFileW(wide_path) != 0; + free(wide_path); + return result; +} + +bool +tiny_sys_directory_remove(const char *path) +{ + wchar_t *wide_path = utf8_to_wide(path); + bool result = wide_path != NULL && RemoveDirectoryW(wide_path) != 0; + free(wide_path); + return result; +} + +char * +tiny_sys_temp_directory_create(const char *prefix) +{ + wchar_t temporary[MAX_PATH], candidate[MAX_PATH], wide_prefix[4] = L"tcm"; + char *result; + size_t index; + if (prefix == NULL) + { + return NULL; + } + for (index = 0; index < 3 && prefix[index] != '\0'; ++index) + { + wide_prefix[index] = (wchar_t)(unsigned char)prefix[index]; + } + if (GetTempPathW(MAX_PATH, temporary) == 0 + || GetTempFileNameW(temporary, wide_prefix, 0, candidate) == 0 || !DeleteFileW(candidate) + || !CreateDirectoryW(candidate, NULL)) + { + return NULL; + } + result = wide_to_utf8(candidate); + if (result == NULL) + { + RemoveDirectoryW(candidate); + } + return result; +} + +uint64_t +tiny_sys_now_milliseconds(void) +{ + FILETIME time; + ULARGE_INTEGER ticks; + GetSystemTimeAsFileTime(&time); + ticks.LowPart = time.dwLowDateTime; + ticks.HighPart = time.dwHighDateTime; + return ticks.QuadPart / 10000ULL - 11644473600000ULL; +} + +uint64_t +tiny_sys_monotonic_milliseconds(void) +{ + return GetTickCount64(); +} + +bool +tiny_sys_random_bytes(void *data, size_t length) +{ + return (data != NULL || length == 0) && !tiny_sys_test_should_fail(TINY_SYS_TEST_RANDOM) + && length <= ULONG_MAX + && BCryptGenRandom(NULL, data, (ULONG)length, BCRYPT_USE_SYSTEM_PREFERRED_RNG) == 0; +} + +#else + +#include <fcntl.h> +#include <sys/file.h> +#include <sys/stat.h> +#include <time.h> +#include <unistd.h> + +static char * +duplicate_string(const char *value) +{ + const size_t length = strlen(value); + char *copy = malloc(length + 1); + if (copy != NULL) + { + memcpy(copy, value, length + 1); + } + return copy; +} + +static int +native_file(const tiny_sys_file_t *file) +{ + return (int)file->native; +} + +bool +tiny_sys_arguments_init(tiny_sys_arguments_t *arguments, int argc, char **argv) +{ + int index; + if (arguments == NULL || argc < 0 || (argc != 0 && argv == NULL)) + { + return false; + } + memset(arguments, 0, sizeof(*arguments)); + arguments->values = calloc((size_t)argc, sizeof(*arguments->values)); + if (arguments->values == NULL) + { + return false; + } + arguments->count = argc; + for (index = 0; index < argc; ++index) + { + arguments->values[index] = duplicate_string(argv[index]); + if (arguments->values[index] == NULL) + { + tiny_sys_arguments_free(arguments); + return false; + } + } + return true; +} + +char * +tiny_sys_path_join(const char *directory, const char *name) +{ + size_t left, right, required; + if (directory == NULL || name == NULL) + { + return NULL; + } + left = strlen(directory); + right = strlen(name); + const bool separator = left > 0 && directory[left - 1] != '/'; + if (left > SIZE_MAX - right - (separator ? 2u : 1u)) + { + return NULL; + } + required = left + right + (separator ? 2u : 1u); + char *result = malloc(required); + if (result == NULL) + { + return NULL; + } + memcpy(result, directory, left); + if (separator) + { + result[left] = '/'; + } + memcpy(result + left + (separator ? 1 : 0), name, right + 1); + return result; +} + +char * +tiny_sys_path_absolute(const char *path) +{ + char *working_directory, *result; + if (path == NULL) + { + return NULL; + } + if (path[0] == '/') + { + return duplicate_string(path); + } + working_directory = getcwd(NULL, 0); + if (working_directory == NULL) + { + return NULL; + } + result = tiny_sys_path_join(working_directory, path); + free(working_directory); + return result; +} + +tiny_sys_status_t +tiny_sys_directory_create(const char *path) +{ + return mkdir(path, 0700) == 0 || errno == EEXIST ? TINY_SYS_OK : TINY_SYS_ERROR; +} + +void +tiny_sys_file_init(tiny_sys_file_t *file) +{ + if (file != NULL) + { + file->native = -1; + } +} + +bool +tiny_sys_file_valid(const tiny_sys_file_t *file) +{ + return file != NULL && file->native >= 0; +} + +tiny_sys_status_t +tiny_sys_file_open(tiny_sys_file_t *file, const char *path, tiny_sys_file_mode_t mode) +{ + int flags, descriptor; + if (file == NULL) + { + return TINY_SYS_ERROR; + } + tiny_sys_file_init(file); + if (path == NULL || tiny_sys_test_should_fail(TINY_SYS_TEST_OPEN)) + { + return TINY_SYS_ERROR; + } + switch (mode) + { + case TINY_SYS_FILE_READ: + flags = O_RDONLY; + break; + case TINY_SYS_FILE_WRITE_NEW: + flags = O_WRONLY | O_CREAT | O_TRUNC; + break; + case TINY_SYS_FILE_READ_WRITE: + flags = O_RDWR; + break; + case TINY_SYS_FILE_READ_WRITE_CREATE: + case TINY_SYS_FILE_LOCK: + flags = O_RDWR | O_CREAT; + break; + default: + return TINY_SYS_ERROR; + } + descriptor = open(path, flags, 0600); + if (descriptor < 0) + { + return errno == ENOENT ? TINY_SYS_NOT_FOUND : TINY_SYS_ERROR; + } + if (mode == TINY_SYS_FILE_LOCK && flock(descriptor, LOCK_EX | LOCK_NB) != 0) + { + close(descriptor); + return errno == EWOULDBLOCK ? TINY_SYS_BUSY : TINY_SYS_ERROR; + } + file->native = descriptor; + return TINY_SYS_OK; +} + +void +tiny_sys_file_close(tiny_sys_file_t *file) +{ + if (tiny_sys_file_valid(file)) + { + close(native_file(file)); + tiny_sys_file_init(file); + } +} + +bool +tiny_sys_file_read(tiny_sys_file_t *file, void *data, uint32_t length, uint32_t *actual) +{ + ssize_t count; + if (actual == NULL) + { + return false; + } + *actual = 0; + if (!tiny_sys_file_valid(file) || (data == NULL && length != 0) + || tiny_sys_test_should_fail(TINY_SYS_TEST_READ)) + { + return false; + } + length = tiny_sys_test_transfer_length(length); + do + { + count = read(native_file(file), data, length); + } + while (count < 0 && errno == EINTR); + *actual = count > 0 ? (uint32_t)count : 0; + return count >= 0; +} + +bool +tiny_sys_file_write(tiny_sys_file_t *file, const void *data, uint32_t length, uint32_t *actual) +{ + ssize_t count; + if (actual == NULL) + { + return false; + } + *actual = 0; + if (!tiny_sys_file_valid(file) || (data == NULL && length != 0) + || tiny_sys_test_should_fail(TINY_SYS_TEST_WRITE)) + { + return false; + } + length = tiny_sys_test_transfer_length(length); + do + { + count = write(native_file(file), data, length); + } + while (count < 0 && errno == EINTR); + *actual = count > 0 ? (uint32_t)count : 0; + return count >= 0; +} + +bool +tiny_sys_file_seek(tiny_sys_file_t *file, int64_t offset, tiny_sys_seek_origin_t origin) +{ + static const int origins[] = { SEEK_SET, SEEK_CUR, SEEK_END }; + if (!tiny_sys_file_valid(file) || origin < TINY_SYS_SEEK_BEGIN || origin > TINY_SYS_SEEK_END + || tiny_sys_test_should_fail(TINY_SYS_TEST_SEEK)) + { + return false; + } + return lseek(native_file(file), (off_t)offset, origins[origin]) >= 0; +} + +bool +tiny_sys_file_size(const tiny_sys_file_t *file, uint64_t *size) +{ + struct stat information; + if (size == NULL) + { + return false; + } + *size = 0; + if (!tiny_sys_file_valid(file) || tiny_sys_test_should_fail(TINY_SYS_TEST_SIZE) + || fstat(native_file(file), &information) != 0) + { + return false; + } + *size = (uint64_t)information.st_size; + return true; +} + +bool +tiny_sys_file_truncate(tiny_sys_file_t *file) +{ + off_t position; + if (!tiny_sys_file_valid(file) || tiny_sys_test_should_fail(TINY_SYS_TEST_TRUNCATE)) + { + return false; + } + position = lseek(native_file(file), 0, SEEK_CUR); + return position >= 0 && ftruncate(native_file(file), position) == 0; +} + +bool +tiny_sys_file_flush(tiny_sys_file_t *file) +{ + return tiny_sys_file_valid(file) && !tiny_sys_test_should_fail(TINY_SYS_TEST_FLUSH) + && fsync(native_file(file)) == 0; +} + +bool +tiny_sys_path_replace(const char *source, const char *destination) +{ + return source != NULL && destination != NULL && !tiny_sys_test_should_fail(TINY_SYS_TEST_REPLACE) + && rename(source, destination) == 0; +} + +bool +tiny_sys_file_remove(const char *path) +{ + return unlink(path) == 0; +} + +bool +tiny_sys_directory_remove(const char *path) +{ + return rmdir(path) == 0; +} + +char * +tiny_sys_temp_directory_create(const char *prefix) +{ + const char *base = getenv("TMPDIR"); + char *pattern, *result; + size_t length; + if (prefix == NULL) + { + return NULL; + } + if (base == NULL) + { + base = "/tmp"; + } + length = strlen(base) + strlen(prefix) + 9; + pattern = malloc(length); + if (pattern == NULL) + { + return NULL; + } + snprintf(pattern, length, "%s/%sXXXXXX", base, prefix); + result = mkdtemp(pattern); + if (result == NULL) + { + free(pattern); + return NULL; + } + return pattern; +} + +uint64_t +tiny_sys_now_milliseconds(void) +{ + struct timespec time; + clock_gettime(CLOCK_REALTIME, &time); + return (uint64_t)time.tv_sec * 1000ULL + (uint64_t)time.tv_nsec / 1000000ULL; +} + +uint64_t +tiny_sys_monotonic_milliseconds(void) +{ + struct timespec time; + clock_gettime(CLOCK_MONOTONIC, &time); + return (uint64_t)time.tv_sec * 1000ULL + (uint64_t)time.tv_nsec / 1000000ULL; +} + +bool +tiny_sys_random_bytes(void *data, size_t length) +{ + tiny_sys_file_t file; + unsigned char *position = data; + size_t total = 0; + if ((data == NULL && length != 0) || tiny_sys_test_should_fail(TINY_SYS_TEST_RANDOM) + || tiny_sys_file_open(&file, "/dev/urandom", TINY_SYS_FILE_READ) != TINY_SYS_OK) + { + return false; + } + while (total < length) + { + uint32_t actual = 0; + const uint32_t requested + = length - total > UINT32_MAX ? UINT32_MAX : (uint32_t)(length - total); + if (!tiny_sys_file_read(&file, position + total, requested, &actual) || actual == 0) + { + tiny_sys_file_close(&file); + return false; + } + total += actual; + } + tiny_sys_file_close(&file); + return true; +} + +#endif + +void +tiny_sys_arguments_free(tiny_sys_arguments_t *arguments) +{ + int index; + if (arguments == NULL) + { + return; + } + for (index = 0; index < arguments->count; ++index) + { + free(arguments->values[index]); + } + free(arguments->values); + memset(arguments, 0, sizeof(*arguments)); +} diff --git a/src/sys/system.h b/src/sys/system.h new file mode 100644 index 0000000..b526ec5 --- /dev/null +++ b/src/sys/system.h @@ -0,0 +1,90 @@ +#ifndef TINY_CONVMEM_SYS_SYSTEM_H +#define TINY_CONVMEM_SYS_SYSTEM_H + +#include <stdbool.h> +#include <stddef.h> +#include <stdint.h> + +typedef enum tiny_sys_status +{ + TINY_SYS_OK = 0, + TINY_SYS_NOT_FOUND, + TINY_SYS_BUSY, + TINY_SYS_ERROR +} tiny_sys_status_t; + +typedef enum tiny_sys_file_mode +{ + TINY_SYS_FILE_READ = 0, + TINY_SYS_FILE_WRITE_NEW, + TINY_SYS_FILE_READ_WRITE, + TINY_SYS_FILE_READ_WRITE_CREATE, + TINY_SYS_FILE_LOCK +} tiny_sys_file_mode_t; + +typedef enum tiny_sys_seek_origin +{ + TINY_SYS_SEEK_BEGIN = 0, + TINY_SYS_SEEK_CURRENT, + TINY_SYS_SEEK_END +} tiny_sys_seek_origin_t; + +typedef struct tiny_sys_file +{ + intptr_t native; +} tiny_sys_file_t; + +typedef struct tiny_sys_arguments +{ + int count; + char **values; +} tiny_sys_arguments_t; + +/* Internal test seam. A call index of 1 fails the next matching operation. */ +typedef enum tiny_sys_test_operation +{ + TINY_SYS_TEST_OPEN = 0, + TINY_SYS_TEST_READ, + TINY_SYS_TEST_WRITE, + TINY_SYS_TEST_SEEK, + TINY_SYS_TEST_SIZE, + TINY_SYS_TEST_TRUNCATE, + TINY_SYS_TEST_FLUSH, + TINY_SYS_TEST_REPLACE, + TINY_SYS_TEST_RANDOM, + TINY_SYS_TEST_OPERATION_COUNT +} tiny_sys_test_operation_t; + +void tiny_sys_test_reset(void); +bool tiny_sys_test_fail_after(tiny_sys_test_operation_t operation, size_t call_index); +void tiny_sys_test_max_transfer(uint32_t length); + +bool tiny_sys_arguments_init(tiny_sys_arguments_t *arguments, int argc, char **argv); +void tiny_sys_arguments_free(tiny_sys_arguments_t *arguments); + +char *tiny_sys_path_absolute(const char *path); +char *tiny_sys_path_join(const char *directory, const char *name); +char *tiny_sys_temp_directory_create(const char *prefix); +tiny_sys_status_t tiny_sys_directory_create(const char *path); +bool tiny_sys_file_remove(const char *path); +bool tiny_sys_directory_remove(const char *path); +bool tiny_sys_path_replace(const char *source, const char *destination); + +void tiny_sys_file_init(tiny_sys_file_t *file); +bool tiny_sys_file_valid(const tiny_sys_file_t *file); +tiny_sys_status_t +tiny_sys_file_open(tiny_sys_file_t *file, const char *path, tiny_sys_file_mode_t mode); +void tiny_sys_file_close(tiny_sys_file_t *file); +bool tiny_sys_file_read(tiny_sys_file_t *file, void *data, uint32_t length, uint32_t *actual); +bool +tiny_sys_file_write(tiny_sys_file_t *file, const void *data, uint32_t length, uint32_t *actual); +bool tiny_sys_file_seek(tiny_sys_file_t *file, int64_t offset, tiny_sys_seek_origin_t origin); +bool tiny_sys_file_size(const tiny_sys_file_t *file, uint64_t *size); +bool tiny_sys_file_truncate(tiny_sys_file_t *file); +bool tiny_sys_file_flush(tiny_sys_file_t *file); + +uint64_t tiny_sys_now_milliseconds(void); +uint64_t tiny_sys_monotonic_milliseconds(void); +bool tiny_sys_random_bytes(void *data, size_t length); + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1407fc7..ad314eb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -16,19 +16,43 @@ add_executable(tiny-convmem-domain-test ) target_include_directories(tiny-convmem-domain-test PRIVATE "${PROJECT_SOURCE_DIR}/src") target_compile_features(tiny-convmem-domain-test PRIVATE c_std_17) +target_link_libraries(tiny-convmem-domain-test PRIVATE tiny-convmem-base) if(MSVC) target_compile_options(tiny-convmem-domain-test PRIVATE /utf-8 /W4) endif() add_test(NAME tiny-convmem-mcp.domain COMMAND tiny-convmem-domain-test) +add_executable(tiny-convmem-boundary-test + boundary_test.c + "${PROJECT_SOURCE_DIR}/src/mcp/json.c" + "${PROJECT_SOURCE_DIR}/src/storage/codec.c" +) +target_include_directories(tiny-convmem-boundary-test PRIVATE + "${PROJECT_SOURCE_DIR}/src" + "${CMAKE_CURRENT_SOURCE_DIR}" +) +target_compile_features(tiny-convmem-boundary-test PRIVATE c_std_17) +target_link_libraries(tiny-convmem-boundary-test PRIVATE tiny-convmem-sys tiny-convmem-base) +if(MSVC) + target_compile_options(tiny-convmem-boundary-test PRIVATE /utf-8 /W4) +endif() +add_test(NAME tiny-convmem-mcp.boundaries COMMAND tiny-convmem-boundary-test) +foreach(boundary_group allocator encoding codec json sys) + add_test( + NAME "tiny-convmem-mcp.${boundary_group}" + COMMAND tiny-convmem-boundary-test "${boundary_group}" + ) +endforeach() + add_executable(tiny-convmem-storage-test storage_test.c "${PROJECT_SOURCE_DIR}/src/domain/message.c" + "${PROJECT_SOURCE_DIR}/src/storage/codec.c" "${PROJECT_SOURCE_DIR}/src/storage/store.c" ) target_include_directories(tiny-convmem-storage-test PRIVATE "${PROJECT_SOURCE_DIR}/src") target_compile_features(tiny-convmem-storage-test PRIVATE c_std_17) -target_link_libraries(tiny-convmem-storage-test PRIVATE bcrypt) +target_link_libraries(tiny-convmem-storage-test PRIVATE tiny-convmem-sys) if(MSVC) target_compile_options(tiny-convmem-storage-test PRIVATE /utf-8 /W4) endif() @@ -47,3 +71,39 @@ set_tests_properties(tiny-convmem-mcp.lifecycle PROPERTIES TIMEOUT 15) add_test(NAME tiny-convmem-mcp.requires-data-dir COMMAND $<TARGET_FILE:tiny-convmem-mcp>) set_tests_properties(tiny-convmem-mcp.requires-data-dir PROPERTIES WILL_FAIL TRUE) + +find_package(Git REQUIRED) +find_program( + CLANG_FORMAT_EXECUTABLE + NAMES clang-format + HINTS "C:/Program Files/Microsoft Visual Studio/18/Community/VC/Tools/Llvm/x64/bin" +) +add_test( + NAME tiny-convmem-mcp.audit + COMMAND "${CMAKE_COMMAND}" + "-DSOURCE_DIR=${PROJECT_SOURCE_DIR}" + "-DGIT_EXECUTABLE=${GIT_EXECUTABLE}" + "-DCLANG_FORMAT_EXECUTABLE=${CLANG_FORMAT_EXECUTABLE}" + -P "${CMAKE_CURRENT_SOURCE_DIR}/audit.cmake" +) + +if(TINY_CONVMEM_ENABLE_ASAN AND MSVC) + get_filename_component(TINY_CONVMEM_MSVC_BIN_DIR "${CMAKE_C_COMPILER}" DIRECTORY) + set_property( + TEST + tiny-convmem-mcp.smoke + tiny-convmem-mcp.domain + tiny-convmem-mcp.boundaries + tiny-convmem-mcp.allocator + tiny-convmem-mcp.encoding + tiny-convmem-mcp.codec + tiny-convmem-mcp.json + tiny-convmem-mcp.sys + tiny-convmem-mcp.storage + tiny-convmem-mcp.lifecycle + tiny-convmem-mcp.requires-data-dir + tiny-convmem-mcp.audit + APPEND PROPERTY ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${TINY_CONVMEM_MSVC_BIN_DIR}" + ) +endif() diff --git a/tests/audit.cmake b/tests/audit.cmake new file mode 100644 index 0000000..9713f57 --- /dev/null +++ b/tests/audit.cmake @@ -0,0 +1,42 @@ +file(GLOB_RECURSE PROJECT_C_SOURCES + "${SOURCE_DIR}/src/*.c" + "${SOURCE_DIR}/src/*.h" +) + +foreach(source IN LISTS PROJECT_C_SOURCES) + file(READ "${source}" contents) + if(contents MATCHES "(^|[^A-Za-z0-9_])goto([^A-Za-z0-9_]|$)") + message(FATAL_ERROR "goto is forbidden in project source: ${source}") + endif() + if(contents MATCHES "#define[ \t]+static" OR contents MATCHES "#include[ \t]+\"[^\"]+\\.c\"") + message(FATAL_ERROR "test-only source visibility hack found: ${source}") + endif() + string(FIND "${source}" "/src/sys/" sys_position) + if(sys_position EQUAL -1 AND + contents MATCHES "windows\\.h|GetFullPathNameW|CreateFileW|ReadFile|WriteFile|MoveFileExW|BCryptGenRandom") + message(FATAL_ERROR "OS-specific API reference outside src/sys: ${source}") + endif() +endforeach() + +if(NOT CLANG_FORMAT_EXECUTABLE STREQUAL "") + execute_process( + COMMAND "${CLANG_FORMAT_EXECUTABLE}" --dry-run --Werror ${PROJECT_C_SOURCES} + RESULT_VARIABLE format_result + OUTPUT_VARIABLE format_output + ERROR_VARIABLE format_error + ) + if(NOT format_result EQUAL 0) + message(FATAL_ERROR "clang-format check failed:\n${format_output}${format_error}") + endif() +endif() + +execute_process( + COMMAND "${GIT_EXECUTABLE}" diff --check + WORKING_DIRECTORY "${SOURCE_DIR}" + RESULT_VARIABLE diff_result + OUTPUT_VARIABLE diff_output + ERROR_VARIABLE diff_error +) +if(NOT diff_result EQUAL 0) + message(FATAL_ERROR "git diff --check failed:\n${diff_output}${diff_error}") +endif() diff --git a/tests/boundary_test.c b/tests/boundary_test.c new file mode 100644 index 0000000..452e2ee --- /dev/null +++ b/tests/boundary_test.c @@ -0,0 +1,275 @@ +#include <limits.h> +#include <stdint.h> +#include <stdlib.h> +#include <string.h> + +#include "support/test.h" + +#include "base/allocator.h" +#include "mcp/json.h" +#include "storage/codec.h" +#include "sys/encoding.h" +#include "sys/system.h" + +static void +test_allocator_boundaries(tiny_test_state_t *state) +{ + void *first; + void *resized; + size_t baseline = tiny_allocator_outstanding(); + TINY_TEST_CASE(state, "allocator overflow failure positions and balance"); + TINY_EXPECT(state, tiny_calloc(SIZE_MAX, 2) == NULL); + tiny_allocator_fail_after(0); + TINY_EXPECT(state, tiny_alloc(1) == NULL); + TINY_EXPECT_EQ_U64(state, 1, tiny_allocator_call_count()); + tiny_allocator_reset(); + first = tiny_alloc(1); + TINY_EXPECT(state, first != NULL); + tiny_allocator_fail_after(0); + resized = tiny_realloc(first, 2); + TINY_EXPECT(state, resized == NULL); + tiny_allocator_reset(); + tiny_free(first); + TINY_EXPECT_EQ_U64(state, baseline, tiny_allocator_outstanding()); + first = tiny_alloc(1); + TINY_EXPECT(state, tiny_realloc(first, 0) == NULL); + TINY_EXPECT_EQ_U64(state, baseline, tiny_allocator_outstanding()); + tiny_free(NULL); +} + +static void +test_encoding_valid(tiny_test_state_t *state) +{ + static const struct vector + { + const char *utf8; + size_t utf8_length; + uint16_t utf16[3]; + size_t utf16_length; + } vectors[] = { + { "", 0, { 0 }, 0 }, + { "\x7f", 1, { 0x007f }, 1 }, + { "\xc2\x80", 2, { 0x0080 }, 1 }, + { "\xdf\xbf", 2, { 0x07ff }, 1 }, + { "\xe0\xa0\x80", 3, { 0x0800 }, 1 }, + { "\xed\x9f\xbf", 3, { 0xd7ff }, 1 }, + { "\xee\x80\x80", 3, { 0xe000 }, 1 }, + { "\xef\xbf\xbf", 3, { 0xffff }, 1 }, + { "\xf0\x90\x80\x80", 4, { 0xd800, 0xdc00 }, 2 }, + { "\xf4\x8f\xbf\xbf", 4, { 0xdbff, 0xdfff }, 2 }, + }; + size_t index; + TINY_TEST_CASE(state, "encoding valid scalar boundaries"); + for (index = 0; index < sizeof(vectors) / sizeof(vectors[0]); ++index) + { + uint16_t *wide = NULL; + char *roundtrip = NULL; + size_t wide_length = SIZE_MAX, roundtrip_length = SIZE_MAX; + TINY_EXPECT( + state, + tiny_utf8_to_utf16(vectors[index].utf8, vectors[index].utf8_length, &wide, &wide_length) + == TINY_ENCODING_OK); + TINY_EXPECT_EQ_U64(state, vectors[index].utf16_length, wide_length); + TINY_EXPECT_BYTES(state, vectors[index].utf16, wide, wide_length * sizeof(*wide)); + TINY_EXPECT(state, + tiny_utf16_to_utf8(wide, wide_length, &roundtrip, &roundtrip_length) + == TINY_ENCODING_OK); + TINY_EXPECT_EQ_U64(state, vectors[index].utf8_length, roundtrip_length); + TINY_EXPECT_BYTES(state, vectors[index].utf8, roundtrip, roundtrip_length); + free(roundtrip); + free(wide); + } +} + +static void +test_encoding_invalid(tiny_test_state_t *state) +{ + static const unsigned char invalid[][4] = { + { 0x80, 0, 0, 0 }, { 0xc0, 0x80, 0, 0 }, { 0xe0, 0x80, 0x80, 0 }, + { 0xed, 0xa0, 0x80, 0 }, { 0xf0, 0x80, 0x80, 0x80 }, { 0xf4, 0x90, 0x80, 0x80 }, + }; + static const size_t lengths[] = { 1, 2, 3, 3, 4, 4 }; + size_t index; + TINY_TEST_CASE(state, "encoding invalid and allocation failure"); + for (index = 0; index < sizeof(lengths) / sizeof(lengths[0]); ++index) + { + uint16_t *output = (uint16_t *)(uintptr_t)1; + size_t length = SIZE_MAX; + TINY_EXPECT(state, + tiny_utf8_to_utf16((const char *)invalid[index], lengths[index], &output, &length) + == TINY_ENCODING_INVALID); + TINY_EXPECT(state, output == NULL && length == 0); + } + { + uint16_t high[] = { 0xd800 }; + uint16_t low[] = { 0xdc00 }; + char *output = NULL; + size_t length = 0; + TINY_EXPECT(state, tiny_utf16_to_utf8(high, 1, &output, &length) == TINY_ENCODING_INVALID); + TINY_EXPECT(state, tiny_utf16_to_utf8(low, 1, &output, &length) == TINY_ENCODING_INVALID); + } + TINY_EXPECT(state, tiny_utf8_to_utf16(NULL, 1, NULL, NULL) == TINY_ENCODING_INVALID); + { + uint16_t *output = NULL; + size_t length = 0; + tiny_allocator_fail_after(0); + TINY_EXPECT(state, tiny_utf8_to_utf16("a", 1, &output, &length) == TINY_ENCODING_NO_MEMORY); + TINY_EXPECT(state, output == NULL && length == 0); + tiny_allocator_reset(); + } +} + +static void +test_codec_alignment(tiny_test_state_t *state) +{ + unsigned char storage[32]; + size_t offset; + TINY_TEST_CASE(state, "codec every alignment offset"); + for (offset = 0; offset < 16; ++offset) + { + unsigned char *position = storage + offset; + memset(storage, 0, sizeof(storage)); + tiny_codec_put_u32(position, UINT32_MAX); + TINY_EXPECT_EQ_U64(state, UINT32_MAX, tiny_codec_get_u32(position)); + tiny_codec_put_u64(position, UINT64_MAX); + TINY_EXPECT_EQ_U64(state, UINT64_MAX, tiny_codec_get_u64(position)); + } + TINY_EXPECT_EQ_U64(state, 0, tiny_codec_crc32(NULL, 1)); + TINY_EXPECT_EQ_U64(state, 0, tiny_codec_crc32(NULL, 0)); + tiny_codec_put_u32(NULL, 1); + tiny_codec_put_u64(NULL, 1); + TINY_EXPECT_EQ_U64(state, 0, tiny_codec_get_u32(NULL)); + TINY_EXPECT_EQ_U64(state, 0, tiny_codec_get_u64(NULL)); + TINY_EXPECT_EQ_U64(state, 0xcbf43926U, tiny_codec_crc32((const unsigned char *)"123456789", 9)); +} + +static void +test_json_boundaries(tiny_test_state_t *state) +{ + tiny_json_buffer_t buffer; + static const char controls[] = "\b\f\n\r\t\x01"; + TINY_TEST_CASE(state, "json growth escaping integer and OOM"); + tiny_json_init(&buffer); + tiny_json_init(NULL); + tiny_json_free(NULL); + TINY_EXPECT(state, !tiny_json_raw(NULL, "x")); + TINY_EXPECT(state, !tiny_json_raw(&buffer, NULL)); + TINY_EXPECT(state, !tiny_json_raw_n(&buffer, NULL, 1)); + TINY_EXPECT(state, !tiny_json_string(&buffer, NULL)); + TINY_EXPECT(state, tiny_json_raw_n(&buffer, "", 0)); + TINY_EXPECT(state, tiny_json_string(&buffer, controls)); + TINY_EXPECT(state, strstr(buffer.data, "\\b\\f\\n\\r\\t\\u0001") != NULL); + TINY_EXPECT(state, tiny_json_i64(&buffer, LLONG_MIN)); + TINY_EXPECT(state, tiny_json_u64(&buffer, ULLONG_MAX)); + tiny_json_free(&buffer); + tiny_allocator_fail_after(0); + tiny_json_init(&buffer); + TINY_EXPECT(state, !tiny_json_raw(&buffer, "allocation")); + TINY_EXPECT(state, buffer.failed); + tiny_json_free(&buffer); + tiny_allocator_reset(); +} + +static void +test_sys_boundaries(tiny_test_state_t *state) +{ + char *joined; + char *directory; + char *path; + tiny_sys_file_t file; + uint32_t actual = UINT32_MAX; + uint64_t size = UINT64_MAX; + static const unsigned char bytes[] = { 1, 2, 3, 4 }; + unsigned char received[sizeof(bytes)] = { 0 }; + TINY_TEST_CASE(state, "sys public boundary values"); + tiny_sys_test_reset(); + TINY_EXPECT(state, tiny_sys_temp_directory_create(NULL) == NULL); + TINY_EXPECT(state, tiny_sys_path_join(NULL, "name") == NULL); + TINY_EXPECT(state, tiny_sys_path_join("directory", NULL) == NULL); + joined = tiny_sys_path_join("", "name"); + TINY_EXPECT(state, joined != NULL && strcmp(joined, "name") == 0); + free(joined); + tiny_sys_file_init(NULL); + TINY_EXPECT(state, !tiny_sys_file_valid(NULL)); + tiny_sys_file_init(&file); + TINY_EXPECT(state, !tiny_sys_file_valid(&file)); + TINY_EXPECT(state, tiny_sys_file_open(NULL, "unused", TINY_SYS_FILE_READ) == TINY_SYS_ERROR); + TINY_EXPECT(state, + tiny_sys_file_open(&file, NULL, TINY_SYS_FILE_READ) == TINY_SYS_ERROR + && !tiny_sys_file_valid(&file)); + TINY_EXPECT(state, !tiny_sys_file_seek(&file, 0, (tiny_sys_seek_origin_t)-1)); + TINY_EXPECT(state, !tiny_sys_file_size(&file, &size)); + TINY_EXPECT_EQ_U64(state, 0, size); + TINY_EXPECT(state, !tiny_sys_file_read(&file, received, 1, &actual)); + TINY_EXPECT_EQ_U64(state, 0, actual); + + directory = tiny_sys_temp_directory_create("tcm"); + TINY_EXPECT(state, directory != NULL); + path = directory == NULL ? NULL : tiny_sys_path_join(directory, "fault.bin"); + TINY_EXPECT(state, path != NULL); + if (path != NULL) + { + TINY_EXPECT(state, tiny_sys_file_open(&file, path, TINY_SYS_FILE_WRITE_NEW) == TINY_SYS_OK); + tiny_sys_test_max_transfer(1); + TINY_EXPECT(state, tiny_sys_file_write(&file, bytes, sizeof(bytes), &actual)); + TINY_EXPECT_EQ_U64(state, 1, actual); + TINY_EXPECT(state, tiny_sys_test_fail_after(TINY_SYS_TEST_FLUSH, 1)); + TINY_EXPECT(state, !tiny_sys_file_flush(&file)); + tiny_sys_test_reset(); + TINY_EXPECT(state, tiny_sys_file_flush(&file)); + tiny_sys_file_close(&file); + + TINY_EXPECT(state, tiny_sys_test_fail_after(TINY_SYS_TEST_OPEN, 1)); + TINY_EXPECT(state, + tiny_sys_file_open(&file, path, TINY_SYS_FILE_READ) == TINY_SYS_ERROR + && !tiny_sys_file_valid(&file)); + tiny_sys_test_reset(); + TINY_EXPECT(state, tiny_sys_file_open(&file, path, TINY_SYS_FILE_READ) == TINY_SYS_OK); + TINY_EXPECT(state, tiny_sys_test_fail_after(TINY_SYS_TEST_READ, 1)); + actual = UINT32_MAX; + TINY_EXPECT(state, !tiny_sys_file_read(&file, received, sizeof(received), &actual)); + TINY_EXPECT_EQ_U64(state, 0, actual); + tiny_sys_test_reset(); + tiny_sys_file_close(&file); + TINY_EXPECT(state, tiny_sys_file_remove(path)); + } + free(path); + if (directory != NULL) + { + TINY_EXPECT(state, tiny_sys_directory_remove(directory)); + } + free(directory); + tiny_sys_test_reset(); +} + +int +main(int argc, char **argv) +{ + tiny_test_state_t state = { "initial", 0, 0 }; + const char *group = argc > 1 ? argv[1] : "all"; + tiny_allocator_reset(); + if (strcmp(group, "all") == 0 || strcmp(group, "allocator") == 0) + { + test_allocator_boundaries(&state); + } + if (strcmp(group, "all") == 0 || strcmp(group, "encoding") == 0) + { + test_encoding_valid(&state); + test_encoding_invalid(&state); + } + if (strcmp(group, "all") == 0 || strcmp(group, "codec") == 0) + { + test_codec_alignment(&state); + } + if (strcmp(group, "all") == 0 || strcmp(group, "json") == 0) + { + test_json_boundaries(&state); + } + if (strcmp(group, "all") == 0 || strcmp(group, "sys") == 0) + { + test_sys_boundaries(&state); + } + TINY_TEST_CASE(&state, "allocator leak balance"); + TINY_EXPECT_EQ_U64(&state, 0, tiny_allocator_outstanding()); + return tiny_test_finish(&state); +} diff --git a/tests/domain_test.c b/tests/domain_test.c index d50e4bd..4553657 100644 --- a/tests/domain_test.c +++ b/tests/domain_test.c @@ -5,6 +5,8 @@ #include <stdlib.h> #include <string.h> +#include "base/allocator.h" + static int failures; #define CHECK(value) \ do \ @@ -17,6 +19,18 @@ static int failures; } \ while (0) +static char * +duplicate_string(const char *value) +{ + const size_t length = strlen(value); + char *copy = malloc(length + 1); + if (copy != NULL) + { + memcpy(copy, value, length + 1); + } + return copy; +} + int main(void) { @@ -27,32 +41,44 @@ main(void) tiny_message_t original, copy; tiny_search_index_t *search; size_t *candidates = NULL, candidate_count = 0; + size_t failure_index; + + tiny_allocator_reset(); CHECK(tiny_role_parse("user", &role) && role == TINY_ROLE_USER); + CHECK(!tiny_role_parse(NULL, &role)); + CHECK(!tiny_role_parse("user", NULL)); CHECK(!tiny_role_parse("tool", &role)); CHECK(tiny_tier_parse("discard", &tier) && tier == TINY_TIER_DISCARD); CHECK(!tiny_tier_parse("archive", &tier)); + CHECK(!tiny_tier_parse(NULL, &tier)); + CHECK(!tiny_tier_parse("short", NULL)); + CHECK(tiny_utf8_valid(NULL, 0)); + CHECK(!tiny_utf8_valid(NULL, 1)); CHECK(tiny_utf8_valid(valid_utf8, strlen(valid_utf8))); CHECK(!tiny_utf8_valid(invalid_utf8, sizeof(invalid_utf8) - 1)); CHECK(tiny_message_id_valid("01ARZ3NDEKTSV4RRFFQ69G5FAV")); CHECK(!tiny_message_id_valid("81ARZ3NDEKTSV4RRFFQ69G5FAV")); CHECK(!tiny_message_id_valid("01ARZ3NDEKTSV4RRFFQ69G5FAI")); + CHECK(!tiny_message_id_valid(NULL)); + CHECK(!tiny_message_id_valid("01ARZ3NDEKTSV4RRFFQ69G5FA")); + CHECK(!tiny_message_id_valid("01ARZ3NDEKTSV4RRFFQ69G5FAV0")); tiny_message_init(&original); tiny_message_init(©); - original.session_id = _strdup("session"); - original.content = _strdup("content"); + original.session_id = duplicate_string("session"); + original.content = duplicate_string("content"); original.role = TINY_ROLE_ASSISTANT; - strcpy_s(original.message_id, sizeof(original.message_id), "01ARZ3NDEKTSV4RRFFQ69G5FAV"); + snprintf(original.message_id, sizeof(original.message_id), "%s", "01ARZ3NDEKTSV4RRFFQ69G5FAV"); CHECK(tiny_message_copy(©, &original) == 0); CHECK(copy.session_id != original.session_id); CHECK(strcmp(copy.content, "content") == 0); search = tiny_search_index_create(); CHECK(search != NULL); free(original.content); - original.content = _strdup("Curry and coffee"); + original.content = duplicate_string("Curry and coffee"); original.metadata.tags = calloc(1, sizeof(char *)); - original.metadata.tags[0] = _strdup("設計メモ"); + original.metadata.tags[0] = duplicate_string("設計メモ"); original.metadata.tag_count = 1; CHECK(tiny_search_index_add(search, &original, 7)); CHECK(tiny_search_index_candidates(search, "CURRY", &candidates, &candidate_count)); @@ -64,8 +90,37 @@ main(void) CHECK(candidate_count == 1 && candidates[0] == 7); CHECK(tiny_search_score("設計", &original) > 0.0); free(candidates); + + CHECK(!tiny_search_index_add(NULL, &original, 0)); + CHECK(!tiny_search_index_add(search, NULL, 0)); + CHECK(!tiny_search_index_candidates(NULL, "x", &candidates, &candidate_count)); + CHECK(!tiny_search_index_candidates(search, NULL, &candidates, &candidate_count)); + CHECK(!tiny_search_index_candidates(search, "x", NULL, &candidate_count)); + CHECK(tiny_search_score(NULL, &original) == 0.0); + CHECK(tiny_search_score("x", NULL) == 0.0); + + original.metadata.tool_call_id = duplicate_string("tool"); + original.metadata.tags = realloc(original.metadata.tags, 2 * sizeof(char *)); + original.metadata.tags[1] = duplicate_string("second"); + original.metadata.tag_count = 2; + for (failure_index = 0; failure_index < 16; ++failure_index) + { + const size_t baseline = tiny_allocator_outstanding(); + tiny_message_t swept; + tiny_allocator_fail_after(failure_index); + if (tiny_message_copy(&swept, &original) == 0) + { + tiny_message_free(&swept); + CHECK(tiny_allocator_outstanding() == baseline); + break; + } + CHECK(tiny_allocator_outstanding() == baseline); + } + CHECK(failure_index < 16); + tiny_allocator_reset(); tiny_search_index_free(search); tiny_message_free(&original); tiny_message_free(©); + CHECK(tiny_allocator_outstanding() == 0); return failures == 0 ? 0 : 1; } diff --git a/tests/fixtures/health.jsonl b/tests/fixtures/health.jsonl index 8d1a19f..95642eb 100644 --- a/tests/fixtures/health.jsonl +++ b/tests/fixtures/health.jsonl @@ -1,3 +1,4 @@ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"lifecycle","version":"1"}}} +{"jsonrpc":"2.0","method":"notifications/initialized","params":{}} {"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"read_messages","arguments":{"session_id":"lifecycle"}}} {"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_system_health","arguments":{}}} diff --git a/tests/fixtures/smoke-input.jsonl b/tests/fixtures/smoke-input.jsonl index 64bf18f..c746dac 100644 --- a/tests/fixtures/smoke-input.jsonl +++ b/tests/fixtures/smoke-input.jsonl @@ -1,9 +1,9 @@ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"tiny-convmem-smoke","version":"0.1.0"}}} {"jsonrpc":"2.0","method":"notifications/initialized","params":{}} {"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} -{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_message","arguments":{"session_id":"smoke","role":"user","content":"hello durable memory","metadata":{"tags":["hello","memory"],"memory_tier":"short"}}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"write_message","arguments":{"session_id":"smoke","role":"user","content":"hello durable memory","metadata_json":"{\"tags\":[\"hello\",\"memory\"],\"memory_tier\":\"short\"}"}}} {"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"read_messages","arguments":{"session_id":"smoke"}}} -{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"search_messages","arguments":{"query":"durable","limit":10}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"search_messages","arguments":{"query":"durable","session_id":"","limit":10}}} {"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"rebuild_index","arguments":{}}} {"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"get_system_health","arguments":{}}} -{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"write_message","arguments":{"session_id":"smoke","role":"user","content":"must not be stored","unexpected":true}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"write_message","arguments":{"session_id":"smoke","role":"user","content":"must not be stored","metadata_json":"{\"unexpected\":true}"}}} diff --git a/tests/fixtures/write-once.jsonl b/tests/fixtures/write-once.jsonl index 0378968..1ad02ae 100644 --- a/tests/fixtures/write-once.jsonl +++ b/tests/fixtures/write-once.jsonl @@ -1,2 +1,3 @@ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"lifecycle","version":"1"}}} -{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_message","arguments":{"session_id":"lifecycle","role":"user","content":"secret-to-remove","metadata":{"tags":["秘密","remove"],"memory_tier":"long"}}}} +{"jsonrpc":"2.0","method":"notifications/initialized","params":{}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"write_message","arguments":{"session_id":"lifecycle","role":"user","content":"secret-to-remove","metadata_json":"{\"tags\":[\"秘密\",\"remove\"],\"memory_tier\":\"long\"}"}}} diff --git a/tests/function-coverage.md b/tests/function-coverage.md new file mode 100644 index 0000000..66c2e4a --- /dev/null +++ b/tests/function-coverage.md @@ -0,0 +1,17 @@ +# Function coverage map + +`third_party/` is excluded. Static adapters are covered through their owning public API; pure +byte/encoding logic is exposed through internal headers and tested directly. + +| Source | Functions | Test contract | +|---|---|---| +| `base/allocator.c` | `should_fail`, `tiny_alloc`, `tiny_calloc`, `tiny_realloc`, `tiny_free`, `tiny_allocator_reset`, `tiny_allocator_fail_after`, `tiny_allocator_call_count`, `tiny_allocator_outstanding` | `boundaries`: zero/overflow, each allocation primitive, injected failure, balance | +| `sys/encoding.c` | `utf8_codepoint`, `tiny_utf8_to_utf16`, `tiny_utf16_to_utf8` | `boundaries`: all UTF widths, scalar edges, surrogates, overlong/truncated/invalid input, OOM | +| `storage/codec.c` | `tiny_codec_put_u32`, `tiny_codec_put_u64`, `tiny_codec_get_u32`, `tiny_codec_get_u64`, `tiny_codec_crc32` | `boundaries`: offsets 0–15, integer limits, empty/NULL and known CRC | +| `sys/system.c` | test-hook helpers; UTF adapters; `native_file`; argument, path, directory, file, clock and random APIs | `boundaries`, `storage`, `smoke`: NULL/invalid handle, Unicode paths, short I/O, operation failure, cleanup | +| `domain/message.c` | `duplicate_string`, `copy_failed`, all `tiny_message_*`, role/tier parse/name, `tiny_utf8_valid`, `tiny_message_id_valid` | `domain`: enum failure, UTF-8 invalidity, ULID alphabet/length, deep copy and ownership | +| `domain/search.c` | `decode_codepoints`, `gram_hash`, `find_bucket`, `add_gram`, `index_text`, all `tiny_search_*` | `domain`: ASCII case folding, Unicode grams/tags, candidates, scoring and ownership | +| `storage/store.c` | error/I/O helpers, ULID, entry growth/search, record codec, replay/snapshot/recovery, all `tiny_store_*` | `storage`, `lifecycle`: lock, append/reopen, corruption, rebuild rollback, Unicode path | +| `mcp/json.c` | `reserve`, all `tiny_json_*` | `boundaries`: empty append, growth, control escaping, signed/unsigned integer limits, OOM | +| `mcp/server.c` | line/JSON/parser/result/search helpers, `handle_call`, `tiny_convmem_server_run` | `smoke`, `lifecycle`: initialize, list/call, malformed/EOF behavior, persistence | +| `app/main.c` | `usage`, `run`, `main` | `requires-data-dir`, `smoke`: missing CLI argument, normal startup and EOF | diff --git a/tests/lifecycle.cmake b/tests/lifecycle.cmake index 8d2f97d..e0b1149 100644 --- a/tests/lifecycle.cmake +++ b/tests/lifecycle.cmake @@ -24,6 +24,7 @@ endif() set(delete_input "${DATA_DIR}/delete.jsonl") file(WRITE "${delete_input}" "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{},\"clientInfo\":{\"name\":\"lifecycle\",\"version\":\"1\"}}}\n" + "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\",\"params\":{}}\n" "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"delete_message\",\"arguments\":{\"message_id\":\"${message_id}\",\"session_id\":\"lifecycle\"}}}\n" "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"read_messages\",\"arguments\":{\"session_id\":\"lifecycle\"}}}\n" "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"rebuild_index\",\"arguments\":{}}}\n" diff --git a/tests/smoke.cmake b/tests/smoke.cmake index 2481297..3992b6b 100644 --- a/tests/smoke.cmake +++ b/tests/smoke.cmake @@ -35,7 +35,7 @@ foreach(expected "\"name\":\"delete_message\"" "\"name\":\"rebuild_index\"" "\"name\":\"get_system_health\"" - "\"required\":[\"session_id\",\"role\",\"content\"]" + "\"metadata_json\"" "\"type\":\"text\"" "\\\"status\\\":\\\"OK\\\"" "\\\"code\\\":\\\"VALIDATION_ERROR\\\"" diff --git a/tests/storage_test.c b/tests/storage_test.c index bac0f0b..3217095 100644 --- a/tests/storage_test.c +++ b/tests/storage_test.c @@ -1,7 +1,5 @@ -#define WIN32_LEAN_AND_MEAN -#include <windows.h> - #include "storage/store.h" +#include "sys/system.h" #include <stdio.h> #include <stdlib.h> @@ -19,24 +17,69 @@ static int failures; } \ while (0) +static char * +duplicate_string(const char *value) +{ + const size_t length = strlen(value); + char *copy = malloc(length + 1); + if (copy != NULL) + { + memcpy(copy, value, length + 1); + } + return copy; +} + int -wmain(void) +main(void) { - wchar_t temporary[MAX_PATH], directory[MAX_PATH], log_path[MAX_PATH]; + char *temporary_root = tiny_sys_temp_directory_create("tcm"); + char *directory; + char *log_path; + char *temporary_path; + char *lock_path; + char *snapshot_path; tiny_store_t *store = NULL, *second = NULL; tiny_message_t input, first, second_message; tiny_store_status_t status; - HANDLE log; - HANDLE blocker; + tiny_sys_file_t log; tiny_rebuild_report_t report; - LARGE_INTEGER position; unsigned char byte; - DWORD count; + uint32_t count; + tiny_store_health_t health; + size_t operation; + static const tiny_sys_test_operation_t rebuild_failures[] = { TINY_SYS_TEST_OPEN, + TINY_SYS_TEST_RANDOM, + TINY_SYS_TEST_WRITE, + TINY_SYS_TEST_FLUSH, + TINY_SYS_TEST_REPLACE }; + + CHECK(tiny_store_open(NULL, "unused") == TINY_STORE_IO_ERROR); + store = (tiny_store_t *)(uintptr_t)1; + CHECK(tiny_store_open(&store, NULL) == TINY_STORE_IO_ERROR && store == NULL); + CHECK(tiny_store_entry_count(NULL) == 0); + CHECK(tiny_store_entry(NULL, 0) == NULL); + memset(&health, 0xff, sizeof(health)); + tiny_store_health(NULL, &health); + CHECK(health.active_messages == 0 && health.log_size == 0); + + CHECK(temporary_root != NULL); + if (temporary_root == NULL) + { + return 1; + } + directory = tiny_sys_path_join(temporary_root, "日本語-memory"); + CHECK(directory != NULL && tiny_sys_directory_create(directory) == TINY_SYS_OK); + if (directory == NULL) + { + free(temporary_root); + return 1; + } + log_path = tiny_sys_path_join(directory, "active.log"); + temporary_path = tiny_sys_path_join(directory, "active.tmp"); + lock_path = tiny_sys_path_join(directory, "store.lock"); + snapshot_path = tiny_sys_path_join(directory, "snapshot.bin"); + CHECK(log_path != NULL && temporary_path != NULL && lock_path != NULL && snapshot_path != NULL); - CHECK(GetTempPathW(MAX_PATH, temporary) > 0); - CHECK(GetTempFileNameW(temporary, L"tcm", 0, directory) > 0); - DeleteFileW(directory); - CHECK(CreateDirectoryW(directory, NULL) != 0); status = tiny_store_open(&store, directory); CHECK(status == TINY_STORE_OK); CHECK(tiny_store_open(&second, directory) == TINY_STORE_BUSY); @@ -44,12 +87,12 @@ wmain(void) tiny_message_init(&input); tiny_message_init(&first); tiny_message_init(&second_message); - input.session_id = _strdup("unit"); - input.content = _strdup("first"); + input.session_id = duplicate_string("unit"); + input.content = duplicate_string("first"); input.role = TINY_ROLE_USER; CHECK(tiny_store_write(store, &input, &first) == TINY_STORE_OK); free(input.content); - input.content = _strdup("second"); + input.content = duplicate_string("second"); CHECK(tiny_store_write(store, &input, &second_message) == TINY_STORE_OK); CHECK(strcmp(first.message_id, second_message.message_id) < 0); tiny_message_free(&input); @@ -60,38 +103,43 @@ wmain(void) CHECK(tiny_store_open(&store, directory) == TINY_STORE_OK); CHECK(tiny_store_entry_count(store) == 2); - swprintf_s(log_path, MAX_PATH, L"%s\\active.tmp", directory); - blocker = CreateFileW( - log_path, GENERIC_READ | GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); - CHECK(blocker != INVALID_HANDLE_VALUE); + for (operation = 0; operation < sizeof(rebuild_failures) / sizeof(rebuild_failures[0]); + ++operation) + { + tiny_sys_test_reset(); + CHECK(tiny_sys_test_fail_after(rebuild_failures[operation], 1)); + CHECK(tiny_store_rebuild(store, &report) != TINY_STORE_OK); + CHECK(tiny_store_entry_count(store) == 2); + tiny_sys_test_reset(); + (void)tiny_sys_file_remove(temporary_path); + } + CHECK(tiny_sys_directory_create(temporary_path) == TINY_SYS_OK); CHECK(tiny_store_rebuild(store, &report) == TINY_STORE_IO_ERROR); CHECK(tiny_store_entry_count(store) == 2); - CloseHandle(blocker); - DeleteFileW(log_path); + CHECK(tiny_sys_directory_remove(temporary_path)); tiny_store_close(store); store = NULL; - swprintf_s(log_path, MAX_PATH, L"%s\\active.log", directory); - log = CreateFileW( - log_path, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); - CHECK(log != INVALID_HANDLE_VALUE); - position.QuadPart = -1; - CHECK(SetFilePointerEx(log, position, NULL, FILE_END) != 0); - CHECK(ReadFile(log, &byte, 1, &count, NULL) != 0 && count == 1); + CHECK(tiny_sys_file_open(&log, log_path, TINY_SYS_FILE_READ_WRITE) == TINY_SYS_OK); + CHECK(tiny_sys_file_seek(&log, -1, TINY_SYS_SEEK_END)); + CHECK(tiny_sys_file_read(&log, &byte, 1, &count) && count == 1); byte ^= 0x5a; - position.QuadPart = -1; - CHECK(SetFilePointerEx(log, position, NULL, FILE_CURRENT) != 0); - CHECK(WriteFile(log, &byte, 1, &count, NULL) != 0 && count == 1); - CloseHandle(log); + CHECK(tiny_sys_file_seek(&log, -1, TINY_SYS_SEEK_CURRENT)); + CHECK(tiny_sys_file_write(&log, &byte, 1, &count) && count == 1); + tiny_sys_file_close(&log); CHECK(tiny_store_open(&store, directory) == TINY_STORE_CORRUPT); - DeleteFileW(log_path); - swprintf_s(log_path, MAX_PATH, L"%s\\active.tmp", directory); - DeleteFileW(log_path); - swprintf_s(log_path, MAX_PATH, L"%s\\store.lock", directory); - DeleteFileW(log_path); - swprintf_s(log_path, MAX_PATH, L"%s\\snapshot.bin", directory); - DeleteFileW(log_path); - RemoveDirectoryW(directory); + (void)tiny_sys_file_remove(log_path); + (void)tiny_sys_file_remove(temporary_path); + (void)tiny_sys_file_remove(lock_path); + (void)tiny_sys_file_remove(snapshot_path); + (void)tiny_sys_directory_remove(directory); + (void)tiny_sys_directory_remove(temporary_root); + free(snapshot_path); + free(lock_path); + free(temporary_path); + free(log_path); + free(directory); + free(temporary_root); return failures == 0 ? 0 : 1; } diff --git a/tests/support/test.h b/tests/support/test.h new file mode 100644 index 0000000..5de3864 --- /dev/null +++ b/tests/support/test.h @@ -0,0 +1,48 @@ +#ifndef TINY_CONVMEM_TEST_SUPPORT_H +#define TINY_CONVMEM_TEST_SUPPORT_H + +#include <stdio.h> +#include <string.h> + +typedef struct tiny_test_state +{ + const char *case_name; + unsigned checks; + unsigned failures; +} tiny_test_state_t; + +#define TINY_TEST_CASE(state, name) ((state)->case_name = (name)) +#define TINY_EXPECT(state, expression) \ + do \ + { \ + (state)->checks++; \ + if (!(expression)) \ + { \ + fprintf(stderr, \ + "%s:%d [%s] expected %s\n", \ + __FILE__, \ + __LINE__, \ + (state)->case_name, \ + #expression); \ + (state)->failures++; \ + } \ + } \ + while (0) +#define TINY_EXPECT_EQ_U64(state, expected, actual) \ + TINY_EXPECT((state), (unsigned long long)(expected) == (unsigned long long)(actual)) +#define TINY_EXPECT_BYTES(state, expected, actual, length) \ + TINY_EXPECT((state), memcmp((expected), (actual), (length)) == 0) + +static inline int +tiny_test_finish(const tiny_test_state_t *state) +{ + if (state->failures == 0) + { + printf("%u checks passed\n", state->checks); + return 0; + } + fprintf(stderr, "%u of %u checks failed\n", state->failures, state->checks); + return 1; +} + +#endif |
