diff --git a/core/include/gitmind/types/ulid.h b/core/include/gitmind/types/ulid.h new file mode 100644 index 00000000..3cd5dc6f --- /dev/null +++ b/core/include/gitmind/types/ulid.h @@ -0,0 +1,77 @@ +/* SPDX-License-Identifier: LicenseRef-MIND-UCAL-1.0 */ +#ifndef GITMIND_TYPES_ULID_H +#define GITMIND_TYPES_ULID_H + +#include +#include +#include + +#include "gitmind/result.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ULID size constants */ +#define GM_ULID_SIZE 26 +#define GM_ULID_BUFFER_SIZE (GM_ULID_SIZE + 1) + +/* Result type for ULID operations */ +GM_RESULT_DEF(gm_result_ulid, char *); + +/** + * Generate a new ULID (Universally Unique Lexicographically Sortable ID). + * + * The ULID consists of: + * - 48-bit timestamp (millisecond precision) + * - 80-bit randomness + * + * Total of 128 bits encoded as 26 character base32 string. + * + * @param buffer Output buffer (must be at least GM_ULID_BUFFER_SIZE bytes) + * @return Result containing pointer to buffer on success + */ +gm_result_ulid_t gm_ulid_generate(char *buffer); + +/** + * Generate a ULID with custom timestamp. + * + * @param buffer Output buffer (must be at least GM_ULID_BUFFER_SIZE bytes) + * @param timestamp_ms Unix timestamp in milliseconds + * @return Result containing pointer to buffer on success + */ +gm_result_ulid_t gm_ulid_generate_with_timestamp(char *buffer, + uint64_t timestamp_ms); + +/** + * Validate a ULID string. + * + * @param ulid String to validate + * @return true if valid ULID, false otherwise + */ +bool gm_ulid_is_valid(const char *ulid); + +/** + * Extract timestamp from ULID. + * + * @param ulid ULID string + * @param timestamp_ms Output timestamp in milliseconds + * @return Result containing success status + */ +gm_result_void_t gm_ulid_get_timestamp(const char *ulid, + uint64_t *timestamp_ms); + +/** + * Compare two ULIDs. + * + * @param ulid1 First ULID + * @param ulid2 Second ULID + * @return <0 if ulid1 < ulid2, 0 if equal, >0 if ulid1 > ulid2 + */ +int gm_ulid_compare(const char *ulid1, const char *ulid2); + +#ifdef __cplusplus +} +#endif + +#endif /* GITMIND_TYPES_ULID_H */ diff --git a/core/src/types/ulid.c b/core/src/types/ulid.c new file mode 100644 index 00000000..c20335e4 --- /dev/null +++ b/core/src/types/ulid.c @@ -0,0 +1,212 @@ +/* SPDX-License-Identifier: LicenseRef-MIND-UCAL-1.0 */ +#include "gitmind/types/ulid.h" + +#include +#include +#include + +#include "gitmind/crypto/backend.h" +#include "gitmind/crypto/random.h" +#include "gitmind/error.h" + +/* Crockford's Base32 alphabet (excludes I, L, O, U to avoid confusion) */ +static const char ENCODING[32] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/* ULID structure constants */ +#define TIME_COMPONENT_LENGTH 10 +#define RANDOM_COMPONENT_LENGTH 16 +#define BASE32_MASK 0x1FU +#define BASE32_SHIFT 5 +#define BITS_PER_CHARACTER 5 +#define MILLISECONDS_PER_SECOND 1000 +#define NANOSECONDS_PER_MILLISECOND 1000000 + +/* Decoding table for validation (-1 for invalid characters) */ +static const int8_t DECODING[256] = { + /* 0-31: Control characters */ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + /* 32-47: Space and punctuation */ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + /* 48-57: '0'-'9' */ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -1, -1, -1, -1, -1, -1, + /* 64-79: '@', 'A'-'O' (excluding I, L, O) */ + -1, 10, 11, 12, 13, 14, 15, 16, 17, -1, 18, 19, -1, 20, 21, -1, + /* 80-95: 'P'-'_' */ + 22, 23, 24, 25, 26, -1, 27, 28, 29, 30, 31, -1, -1, -1, -1, -1, + /* 96-111: '`', 'a'-'o' (lowercase) */ + -1, 10, 11, 12, 13, 14, 15, 16, 17, -1, 18, 19, -1, 20, 21, -1, + /* 112-127: 'p'-DEL */ + 22, 23, 24, 25, 26, -1, 27, 28, 29, 30, 31, -1, -1, -1, -1, -1, + /* 128-255: Extended ASCII */ + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, + -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1 +}; + +/* Get current time in milliseconds */ +static uint64_t get_current_time_ms(void) { + struct timespec timestamp; + if (clock_gettime(CLOCK_REALTIME, ×tamp) != 0) { + /* Fallback to time() if clock_gettime fails */ + return (uint64_t)time(NULL) * MILLISECONDS_PER_SECOND; + } + return (uint64_t)timestamp.tv_sec * MILLISECONDS_PER_SECOND + + (uint64_t)timestamp.tv_nsec / NANOSECONDS_PER_MILLISECOND; +} + +/* Encode time component (48 bits) into 10 base32 characters */ +static void encode_time(uint64_t time_ms, char *output) { + /* Encode from right to left (least significant first) */ + for (int index = TIME_COMPONENT_LENGTH - 1; index >= 0; index--) { + output[index] = ENCODING[time_ms & BASE32_MASK]; + time_ms >>= BASE32_SHIFT; + } +} + +/* Encode random component (80 bits) into 16 base32 characters */ +static gm_result_void_t encode_random(char *output) { + uint8_t random_bytes[10]; /* 80 bits = 10 bytes */ + + /* Get random bytes using default backend */ + const gm_crypto_backend_t *backend = gm_crypto_backend_libsodium(); + gm_result_crypto_context_t ctx_result = gm_crypto_context_create(backend); + if (!ctx_result.ok) { + return (gm_result_void_t){.ok = false, .u.err = ctx_result.u.err}; + } + + gm_result_void_t result = gm_random_bytes_with_context(&ctx_result.u.val, random_bytes, sizeof(random_bytes)); + if (!result.ok) { + return result; + } + + /* Convert 80 bits to base32 + * We need to handle bit shifting across byte boundaries */ + uint32_t bits_available = 0; + uint32_t bit_buffer = 0; + size_t byte_index = 0; + + for (int char_index = 0; char_index < RANDOM_COMPONENT_LENGTH; char_index++) { + /* Ensure we have at least 5 bits */ + while (bits_available < BITS_PER_CHARACTER && byte_index < sizeof(random_bytes)) { + bit_buffer = (bit_buffer << 8) | random_bytes[byte_index++]; + bits_available += 8; + } + + /* Extract 5 bits */ + bits_available -= BITS_PER_CHARACTER; + output[char_index] = ENCODING[(bit_buffer >> bits_available) & BASE32_MASK]; + } + + return gm_ok_void(); +} + +/* Public API implementation */ + +gm_result_ulid_t gm_ulid_generate(char *buffer) { + if (!buffer) { + gm_error_t *err = GM_ERROR(GM_ERR_INVALID_ARGUMENT, "Buffer cannot be NULL"); + return (gm_result_ulid_t){.ok = false, .u.err = err}; + } + + uint64_t timestamp_ms = get_current_time_ms(); + return gm_ulid_generate_with_timestamp(buffer, timestamp_ms); +} + +gm_result_ulid_t gm_ulid_generate_with_timestamp(char *buffer, + uint64_t timestamp_ms) { + if (!buffer) { + gm_error_t *err = GM_ERROR(GM_ERR_INVALID_ARGUMENT, "Buffer cannot be NULL"); + return (gm_result_ulid_t){.ok = false, .u.err = err}; + } + + /* Encode time component */ + encode_time(timestamp_ms, buffer); + + /* Encode random component */ + gm_result_void_t random_result = encode_random(buffer + TIME_COMPONENT_LENGTH); + if (!random_result.ok) { + return (gm_result_ulid_t){.ok = false, .u.err = random_result.u.err}; + } + + /* Null terminate */ + buffer[GM_ULID_SIZE] = '\0'; + + return (gm_result_ulid_t){.ok = true, .u.val = buffer}; +} + +bool gm_ulid_is_valid(const char *ulid) { + if (!ulid) { + return false; + } + + /* Check length */ + size_t length = 0; + while (ulid[length] != '\0' && length <= GM_ULID_SIZE) { + length++; + } + if (length != GM_ULID_SIZE) { + return false; + } + + /* Validate each character */ + for (size_t index = 0; index < GM_ULID_SIZE; index++) { + unsigned char character = (unsigned char)ulid[index]; + if (DECODING[character] < 0) { + return false; + } + } + + /* Check timestamp doesn't overflow (first 10 chars = 50 bits, max 48 bits) */ + unsigned char first_char = (unsigned char)ulid[0]; + int first_value = DECODING[first_char]; + if (first_value >= 16) { /* Top 2 bits must be 0 for 48-bit value */ + return false; + } + + return true; +} + +gm_result_void_t gm_ulid_get_timestamp(const char *ulid, + uint64_t *timestamp_ms) { + if (!ulid || !timestamp_ms) { + gm_error_t *err = GM_ERROR(GM_ERR_INVALID_ARGUMENT, "Parameters cannot be NULL"); + return (gm_result_void_t){.ok = false, .u.err = err}; + } + + if (!gm_ulid_is_valid(ulid)) { + gm_error_t *err = GM_ERROR(GM_ERR_INVALID_ARGUMENT, "Invalid ULID format"); + return (gm_result_void_t){.ok = false, .u.err = err}; + } + + /* Decode time component */ + uint64_t time_value = 0; + for (int index = 0; index < TIME_COMPONENT_LENGTH; index++) { + unsigned char character = (unsigned char)ulid[index]; + int value = DECODING[character]; + time_value = (time_value << BASE32_SHIFT) | (uint64_t)value; + } + + *timestamp_ms = time_value; + return gm_ok_void(); +} + +int gm_ulid_compare(const char *ulid1, const char *ulid2) { + if (!ulid1 && !ulid2) { + return 0; + } + if (!ulid1) { + return -1; + } + if (!ulid2) { + return 1; + } + + /* ULIDs are lexicographically sortable */ + return strcmp(ulid1, ulid2); +} diff --git a/core/tests/unit/test_ulid.c b/core/tests/unit/test_ulid.c new file mode 100644 index 00000000..bf3da480 --- /dev/null +++ b/core/tests/unit/test_ulid.c @@ -0,0 +1,250 @@ +/* SPDX-License-Identifier: LicenseRef-MIND-UCAL-1.0 */ +#include "gitmind/types/ulid.h" + +#include +#include +#include +#include +#include + +#include "gitmind/crypto/backend.h" +#include "gitmind/error.h" + +/* Test constants */ +#define TEST_TIMESTAMP_MS 1234567890123ULL +#define EXPECTED_TIME_PREFIX "013XRZP16B" /* First 10 chars for timestamp 1234567890123 */ +#define INVALID_ULID_LENGTH "01BX5ZT" +#define INVALID_ULID_CHARS "01BX5ZT@#$%^&*()!@#$%^" +#define INVALID_ULID_OVERFLOW "ZZZZZZZZZZ0000000000000000" /* Timestamp overflow */ + +static void test_ulid_generate_basic(void) { + printf("Testing basic ULID generation...\n"); + + char ulid1[GM_ULID_BUFFER_SIZE]; + char ulid2[GM_ULID_BUFFER_SIZE]; + + /* Generate first ULID */ + gm_result_ulid_t result1 = gm_ulid_generate(ulid1); + assert(result1.ok); + assert(result1.u.val == ulid1); + assert(strlen(ulid1) == GM_ULID_SIZE); + assert(gm_ulid_is_valid(ulid1)); + + /* Small delay to ensure different timestamp */ + struct timespec delay = {.tv_sec = 0, .tv_nsec = 2000000}; /* 2ms */ + thrd_sleep(&delay, NULL); + + /* Generate second ULID */ + gm_result_ulid_t result2 = gm_ulid_generate(ulid2); + assert(result2.ok); + assert(strlen(ulid2) == GM_ULID_SIZE); + assert(gm_ulid_is_valid(ulid2)); + + /* ULIDs should be different */ + assert(strcmp(ulid1, ulid2) != 0); + + /* Second ULID should be greater (later timestamp) */ + assert(gm_ulid_compare(ulid2, ulid1) > 0); + + printf("✓ Basic ULID generation works\n"); +} + +static void test_ulid_generate_with_timestamp(void) { + printf("Testing ULID generation with timestamp...\n"); + + char ulid1[GM_ULID_BUFFER_SIZE]; + char ulid2[GM_ULID_BUFFER_SIZE]; + + /* Generate two ULIDs with same timestamp */ + gm_result_ulid_t result1 = gm_ulid_generate_with_timestamp(ulid1, TEST_TIMESTAMP_MS); + assert(result1.ok); + + gm_result_ulid_t result2 = gm_ulid_generate_with_timestamp(ulid2, TEST_TIMESTAMP_MS); + assert(result2.ok); + + /* Time component should be identical */ + assert(strncmp(ulid1, ulid2, 10) == 0); + + /* Random component should be different */ + assert(strcmp(ulid1 + 10, ulid2 + 10) != 0); + + /* Verify timestamp prefix */ + assert(strncmp(ulid1, EXPECTED_TIME_PREFIX, 10) == 0); + + printf("✓ ULID generation with timestamp works\n"); +} + +static void test_ulid_null_buffer(void) { + printf("Testing ULID generation with NULL buffer...\n"); + + gm_result_ulid_t result = gm_ulid_generate(NULL); + assert(!result.ok); + assert(result.u.err->code == GM_ERR_INVALID_ARGUMENT); + gm_error_free(result.u.err); + + result = gm_ulid_generate_with_timestamp(NULL, TEST_TIMESTAMP_MS); + assert(!result.ok); + assert(result.u.err->code == GM_ERR_INVALID_ARGUMENT); + gm_error_free(result.u.err); + + printf("✓ NULL buffer handling works\n"); +} + +static void test_ulid_validation(void) { + printf("Testing ULID validation...\n"); + + /* Valid ULID */ + char valid_ulid[GM_ULID_BUFFER_SIZE]; + gm_result_ulid_t result = gm_ulid_generate(valid_ulid); + assert(result.ok); + assert(gm_ulid_is_valid(valid_ulid)); + + /* NULL input */ + assert(!gm_ulid_is_valid(NULL)); + + /* Wrong length */ + assert(!gm_ulid_is_valid(INVALID_ULID_LENGTH)); + assert(!gm_ulid_is_valid("01BX5ZT0000000000000000000X")); /* Too long */ + + /* Invalid characters */ + assert(!gm_ulid_is_valid(INVALID_ULID_CHARS)); + assert(!gm_ulid_is_valid("01BX5ZT000000000000000000I")); /* Contains 'I' */ + assert(!gm_ulid_is_valid("01BX5ZT000000000000000000L")); /* Contains 'L' */ + assert(!gm_ulid_is_valid("01BX5ZT000000000000000000O")); /* Contains 'O' */ + assert(!gm_ulid_is_valid("01BX5ZT000000000000000000U")); /* Contains 'U' */ + + /* Timestamp overflow (>48 bits) */ + assert(!gm_ulid_is_valid(INVALID_ULID_OVERFLOW)); + + printf("✓ ULID validation works\n"); +} + +static void test_ulid_get_timestamp(void) { + printf("Testing ULID timestamp extraction...\n"); + + char ulid[GM_ULID_BUFFER_SIZE]; + uint64_t original_timestamp = TEST_TIMESTAMP_MS; + uint64_t extracted_timestamp = 0; + + /* Generate ULID with known timestamp */ + gm_result_ulid_t gen_result = gm_ulid_generate_with_timestamp(ulid, original_timestamp); + assert(gen_result.ok); + + /* Extract timestamp */ + gm_result_void_t extract_result = gm_ulid_get_timestamp(ulid, &extracted_timestamp); + assert(extract_result.ok); + assert(extracted_timestamp == original_timestamp); + + /* Test error cases */ + extract_result = gm_ulid_get_timestamp(NULL, &extracted_timestamp); + assert(!extract_result.ok); + assert(extract_result.u.err->code == GM_ERR_INVALID_ARGUMENT); + gm_error_free(extract_result.u.err); + + extract_result = gm_ulid_get_timestamp(ulid, NULL); + assert(!extract_result.ok); + assert(extract_result.u.err->code == GM_ERR_INVALID_ARGUMENT); + gm_error_free(extract_result.u.err); + + extract_result = gm_ulid_get_timestamp(INVALID_ULID_CHARS, &extracted_timestamp); + assert(!extract_result.ok); + assert(extract_result.u.err->code == GM_ERR_INVALID_ARGUMENT); + gm_error_free(extract_result.u.err); + + printf("✓ ULID timestamp extraction works\n"); +} + +static void test_ulid_compare(void) { + printf("Testing ULID comparison...\n"); + + char ulid1[GM_ULID_BUFFER_SIZE]; + char ulid2[GM_ULID_BUFFER_SIZE]; + char ulid3[GM_ULID_BUFFER_SIZE]; + + /* Generate ULIDs with increasing timestamps */ + gm_result_ulid_t result1 = gm_ulid_generate_with_timestamp(ulid1, 1000); + assert(result1.ok); + + gm_result_ulid_t result2 = gm_ulid_generate_with_timestamp(ulid2, 2000); + assert(result2.ok); + + /* Copy ulid1 to ulid3 */ + strcpy(ulid3, ulid1); + + /* Test comparisons */ + assert(gm_ulid_compare(ulid1, ulid2) < 0); /* ulid1 < ulid2 */ + assert(gm_ulid_compare(ulid2, ulid1) > 0); /* ulid2 > ulid1 */ + assert(gm_ulid_compare(ulid1, ulid3) == 0); /* ulid1 == ulid3 */ + assert(gm_ulid_compare(ulid1, ulid1) == 0); /* Self comparison */ + + /* Test NULL handling */ + assert(gm_ulid_compare(NULL, NULL) == 0); + assert(gm_ulid_compare(NULL, ulid1) < 0); + assert(gm_ulid_compare(ulid1, NULL) > 0); + + printf("✓ ULID comparison works\n"); +} + +static void test_ulid_monotonic_within_ms(void) { + printf("Testing ULID monotonicity within same millisecond...\n"); + + /* Generate multiple ULIDs with same timestamp */ + char ulids[10][GM_ULID_BUFFER_SIZE]; + uint64_t timestamp = TEST_TIMESTAMP_MS; + + for (int i = 0; i < 10; i++) { + gm_result_ulid_t result = gm_ulid_generate_with_timestamp(ulids[i], timestamp); + assert(result.ok); + + /* All should have same time prefix */ + if (i > 0) { + assert(strncmp(ulids[0], ulids[i], 10) == 0); + } + } + + /* Random components should all be different (with high probability) */ + for (int i = 0; i < 10; i++) { + for (int j = i + 1; j < 10; j++) { + assert(strcmp(ulids[i] + 10, ulids[j] + 10) != 0); + } + } + + printf("✓ ULID monotonicity within millisecond works\n"); +} + +static void test_ulid_case_sensitivity(void) { + printf("Testing ULID case sensitivity...\n"); + + /* ULIDs should use uppercase encoding */ + char ulid[GM_ULID_BUFFER_SIZE]; + gm_result_ulid_t result = gm_ulid_generate(ulid); + assert(result.ok); + + /* Check all characters are uppercase or digits */ + for (size_t i = 0; i < GM_ULID_SIZE; i++) { + char c = ulid[i]; + assert((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z')); + assert(c != 'I' && c != 'L' && c != 'O' && c != 'U'); /* Excluded chars */ + } + + printf("✓ ULID case sensitivity works\n"); +} + +int main(void) { + printf("=== ULID Test Suite ===\n\n"); + + /* No initialization needed - backends are now stateless */ + + /* Run tests */ + test_ulid_generate_basic(); + test_ulid_generate_with_timestamp(); + test_ulid_null_buffer(); + test_ulid_validation(); + test_ulid_get_timestamp(); + test_ulid_compare(); + test_ulid_monotonic_within_ms(); + test_ulid_case_sensitivity(); + + printf("\n✅ All ULID tests passed!\n"); + return 0; +} diff --git a/meson.build b/meson.build index 844b2aec..e5146cc7 100644 --- a/meson.build +++ b/meson.build @@ -45,6 +45,7 @@ src = files( 'core/src/types/string.c', 'core/src/types/string_core.c', 'core/src/types/string_utf8.c', + 'core/src/types/ulid.c', 'core/src/utf8/validate.c', ) @@ -127,6 +128,12 @@ test_crypto_backend = executable('test_crypto_backend', dependencies : [libsodium_dep, thread_dep], c_args : ['-DGITMIND_ENABLE_TEST_BACKEND']) +test_ulid = executable('test_ulid', + 'core/tests/unit/test_ulid.c', + include_directories : inc, + link_with : libgitmind, + dependencies : [libsodium_dep, thread_dep]) + # Register tests test('error', test_error) test('id', test_id) @@ -136,4 +143,5 @@ test('path', test_path) test('sha256', test_sha256) test('random', test_random) test('utf8', test_utf8) -test('crypto_backend', test_crypto_backend) \ No newline at end of file +test('crypto_backend', test_crypto_backend) +test('ulid', test_ulid) \ No newline at end of file diff --git a/src/util/ulid.c b/src/util/ulid.c deleted file mode 100644 index b237c516..00000000 --- a/src/util/ulid.c +++ /dev/null @@ -1,84 +0,0 @@ -/* SPDX-License-Identifier: LicenseRef-MIND-UCAL-1.0 */ -/* © 2025 J. Kirby Ross / Neuroglyph Collective */ - -#define _POSIX_C_SOURCE 200809L - -#include "gitmind.h" - -#include "gitmind/constants_cbor.h" -#include "gitmind/constants_internal.h" - -#include -#include -#include - -/* Crockford's Base32 alphabet */ -static const char ENCODING[] = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -/* ULID constants */ -#define TIME_LEN 10 -#define RANDOM_LEN 16 -#define ULID_LEN 26 -#define BASE32_MASK 0x1F -#define BASE32_SHIFT 5 - -/* Get current time in milliseconds */ -static uint64_t get_time_millis(gm_context_t *ctx) { - struct timespec ts; - if (ctx && ctx->time_ops && ctx->time_ops->clock_gettime) { - ctx->time_ops->clock_gettime(CLOCK_REALTIME, &ts); - } else { - /* Fallback to direct call if no context */ - clock_gettime(CLOCK_REALTIME, &ts); - } - return (uint64_t)ts.tv_sec * MILLIS_PER_SECOND + - ts.tv_nsec / NANOS_PER_MILLI; -} - -/* Encode time component */ -static void encode_time(uint64_t time, char *out) { - for (int i = TIME_LEN - 1; i >= 0; i--) { - out[i] = ENCODING[time & BASE32_MASK]; - time >>= BASE32_SHIFT; - } -} - -/* Encode random component */ -static void encode_random(gm_context_t *ctx, char *out) { - for (int i = 0; i < RANDOM_LEN; i++) { - int r; - if (ctx && ctx->random_ops && ctx->random_ops->rand) { - r = ctx->random_ops->rand(); - } else { - /* Fallback to direct call if no context */ - r = rand(); - } - out[i] = ENCODING[r & BASE32_MASK]; - } -} - -/* Generate ULID with context */ -int gm_ulid_generate_ex(gm_context_t *ctx, char *ulid) { - if (!ulid) { - return GM_INVALID_ARG; - } - - /* Get current time */ - uint64_t time = get_time_millis(ctx); - - /* Encode time component */ - encode_time(time, ulid); - - /* Encode random component */ - encode_random(ctx, ulid + TIME_LEN); - - /* Null terminate */ - ulid[ULID_LEN] = '\0'; - - return GM_OK; -} - -/* Generate ULID (backward compatible) */ -int gm_ulid_generate(char *ulid) { - return gm_ulid_generate_ex(NULL, ulid); -} \ No newline at end of file diff --git a/src/util/ulid.md b/src/util/ulid.md deleted file mode 100644 index b93feb31..00000000 --- a/src/util/ulid.md +++ /dev/null @@ -1,231 +0,0 @@ -# ULID Generator - -## Purpose -Generate Universally Unique Lexicographically Sortable Identifiers for edges. - -## Design Rationale - -### Why ULID over UUID? -```mermaid -graph LR - A[UUID v4] -->|Random| B[No time order] - C[UUID v1] -->|MAC address| D[Privacy concern] - E[ULID] -->|Timestamp+Random| F[Time-sortable!] - - style E fill:#0f0 - style F fill:#0f0 -``` - -ULIDs give us: -1. **Lexicographic sorting** = chronological order -2. **Millisecond precision** timestamps -3. **80 bits of randomness** (collision resistant) -4. **Crockford Base32** (human-friendly) -5. **128-bit total** (same as UUID) - -### Encoding Choice -``` -Crockford's Base32: 0123456789ABCDEFGHJKMNPQRSTVWXYZ - -Excluded: I L O U -Why: Look like 1 1 0 U (confusing) -``` - -Smart design: Reduces human transcription errors. - -### Structure Breakdown -``` - 01AN4Z07BY 79KA1307SR9X4MV3 -|----------| |----------------| - Timestamp Randomness - 48bits 80bits - -Total: 128 bits, encoded as 26 characters -``` - -## Implementation Details - -### Time Component -```c -uint64_t time = get_time_millis(); -``` -- Unix epoch milliseconds -- 48 bits = ~8,900 years until overflow -- Stored big-endian in encoding - -### Random Component -```c -for (int i = 0; i < RANDOM_LEN; i++) { - out[i] = ENCODING[rand() & 0x1F]; -} -``` - -Current: Uses `rand()` (not cryptographic) -- Good enough for uniqueness -- Not for security tokens -- Future: Read from /dev/urandom? - -### Clock Source -```c -clock_gettime(CLOCK_REALTIME, &ts); -``` -- POSIX standard -- Millisecond resolution sufficient -- Monotonic not needed (want wall time) - -## Edge Cases - -### Clock Skew -``` -Machine A: 2025-06-16 12:00:00.000 → 01HPGJ4X7M... -Machine B: 2025-06-16 11:59:59.999 → 01HPGJ4X7L... -``` -- B's ULID sorts before A's -- Distributed systems problem -- Acceptable for our use case - -### Rapid Generation -```c -for (int i = 0; i < 1000000; i++) { - gm_ulid_generate(ulid); -} -``` -- Same millisecond → same timestamp prefix -- 80 bits random → 2^80 space -- Collision probability negligible - -### System Time Changes -``` -NTP adjustment: Time jumps backward -Result: ULIDs temporarily unsorted -Recovery: Wait 1ms, order restored -``` -- Rare in practice -- Git commits have same issue -- Document as known limitation - -## Collision Analysis - -### Birthday Paradox -With 80 bits of randomness per millisecond: -- 50% collision after 2^40 IDs in same ms -- That's 1.1 trillion IDs in 1ms -- Practically impossible - -### Global Uniqueness -Total 128-bit space: -- Same as UUID -- Can generate 1 billion per second for 100 years -- Still only 0.00000006% collision chance - -## Performance - -### Generation Cost -``` -clock_gettime(): ~50ns -16 rand() calls: ~100ns -Base32 encoding: ~50ns -Total: ~200ns per ULID -``` - -Can generate 5 million ULIDs/second on modern CPU. - -### Memory Footprint -- Stack allocation only -- No heap usage -- 27 bytes output (26 + null) -- ~100 bytes stack frame total - -## Security Considerations - -### Not Cryptographically Secure -```c -rand() & 0x1F // Predictable with seed -``` -- Don't use for: - - Session tokens - - Password reset tokens - - Anything security-critical -- Fine for: - - Database IDs - - Correlation IDs - - Our edge IDs - -### Information Leakage -ULID reveals: -- Creation timestamp (millisecond precision) -- Generation order (sortable) -- Nothing else - -Acceptable for git-mind edges. - -## Testing Approach - -### Unit Tests -1. Format validation (26 chars, valid alphabet) -2. Timestamp extraction and verification -3. Sortability (generate 2, compare) -4. Uniqueness (generate 1M, check duplicates) - -### Property Tests -```c -// Monotonic within same millisecond -t1 = generate(); -t2 = generate(); -assert(strcmp(t1, t2) < 0); - -// Length invariant -assert(strlen(ulid) == 26); - -// Character set invariant -assert(strspn(ulid, ENCODING) == 26); -``` - -### Stress Tests -```bash -# Generate ULIDs at max rate -./ulid-stress --duration=60s --threads=8 - -# Should see 0 collisions -``` - -## Future Improvements - -### Crypto Random -```c -#ifdef __linux__ - int fd = open("/dev/urandom", O_RDONLY); - read(fd, random_bytes, 10); - close(fd); -#else - arc4random_buf(random_bytes, 10); -#endif -``` -- Better randomness quality -- Platform-specific code -- Complexity tradeoff - -### Monotonic Random -``` -If timestamp_ms == last_timestamp_ms: - random = last_random + 1 -Else: - random = new_random() -``` -- Guarantees strict ordering -- Prevents same-ms collisions -- Adds state (complexity) - -## Why This Implementation Rocks - -1. **Simple**: ~50 lines of clear code -2. **Fast**: 200ns per generation -3. **Portable**: Pure C99, POSIX time -4. **Sufficient**: Solves our ID needs -5. **Proven**: ULID spec battle-tested - -As Linus would say: "Don't overengineer. Make it work, make it right, stop." - -## Reference -- [ULID Spec](https://github.com/ulid/spec) -- [Crockford Base32](https://www.crockford.com/base32.html) \ No newline at end of file