diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ae74ac5..a779d2b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,6 +51,7 @@ else() nostr_core/nip017.c nostr_core/nip019.c nostr_core/nip021.c + nostr_core/nip034.c nostr_core/nip042.c nostr_core/nip044.c nostr_core/nip046.c diff --git a/README.md b/README.md index c0295551..bf55da0b 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ A C library for NOSTR protocol implementation. Work in progress. - [ ] [NIP-31](nips/31.md) - Dealing with Unknown Events - [ ] [NIP-32](nips/32.md) - Labeling - [ ] [NIP-33](nips/33.md) - Parameterized Replaceable Events -- [ ] [NIP-34](nips/34.md) - `git` stuff +- [x] [NIP-34](nips/34.md) - `git` stuff - [ ] [NIP-35](nips/35.md) - Torrents - [ ] [NIP-36](nips/36.md) - Sensitive Content / Content Warning - [ ] [NIP-37](nips/37.md) - Draft Events @@ -96,7 +96,7 @@ A C library for NOSTR protocol implementation. Work in progress. **Legend:** ✅ Fully Implemented | ⚠️ Partial Implementation | ❌ Not Implemented | ➖ Not Applicable -**Implementation Summary:** 13 of 96+ NIPs fully implemented (13.5%) +**Implementation Summary:** 14 of 96+ NIPs fully implemented (14.6%) ## 📦 Quick Start diff --git a/VERSION b/VERSION index 45a346db..592e815e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.11 +0.6.12 diff --git a/nostr_core/nip034.c b/nostr_core/nip034.c new file mode 100644 index 00000000..f52e4b5f --- /dev/null +++ b/nostr_core/nip034.c @@ -0,0 +1,625 @@ +/* + * NIP-34: `git` stuff Implementation + * https://github.com/nostr-protocol/nips/blob/master/34.md + */ + +#define _GNU_SOURCE +#include "nip034.h" +#include "nip001.h" +#include "utils.h" +#include "nostr_common.h" +#include +#include +#include +#include + +// Forward declarations for crypto functions +int nostr_ec_public_key_from_private_key(const unsigned char* private_key, unsigned char* public_key); + +/** + * Helper: add a string array tag to a cJSON tags array. + * Creates ["", ""] and appends it. + */ +static cJSON* add_simple_tag(cJSON* tags, const char* name, const char* value) { + if (!tags || !name || !value) return NULL; + cJSON* tag = cJSON_CreateArray(); + if (!tag) return NULL; + cJSON_AddItemToArray(tag, cJSON_CreateString(name)); + cJSON_AddItemToArray(tag, cJSON_CreateString(value)); + cJSON_AddItemToArray(tags, tag); + return tag; +} + +/** + * Helper: add a multi-value tag (same name, multiple values). + * Creates ["", ""] for each value and appends it. + */ +static int add_multi_tag(cJSON* tags, const char* name, const char** values, int count) { + if (!tags || !name || !values || count <= 0) return -1; + for (int i = 0; i < count; i++) { + if (values[i]) { + cJSON* tag = cJSON_CreateArray(); + if (!tag) return -1; + cJSON_AddItemToArray(tag, cJSON_CreateString(name)); + cJSON_AddItemToArray(tag, cJSON_CreateString(values[i])); + cJSON_AddItemToArray(tags, tag); + } + } + return 0; +} + +/** + * Helper: add a three-value tag. + * Creates ["", "", ""] and appends it. + */ +static cJSON* add_tag_with_extra(cJSON* tags, const char* name, const char* value1, const char* value2) { + if (!tags || !name || !value1) return NULL; + cJSON* tag = cJSON_CreateArray(); + if (!tag) return NULL; + cJSON_AddItemToArray(tag, cJSON_CreateString(name)); + cJSON_AddItemToArray(tag, cJSON_CreateString(value1)); + if (value2) { + cJSON_AddItemToArray(tag, cJSON_CreateString(value2)); + } + cJSON_AddItemToArray(tags, tag); + return tag; +} + +/** + * NIP-34: Create a repository announcement event (kind 30617) + */ +cJSON* nostr_nip34_create_repo_announcement( + nostr_signer_t* signer, + const char* repo_id, + const char* name, + const char* description, + const char* web_url, + const char** clone_urls, + int clone_count, + const char** relay_urls, + int relay_count, + const char* euc_commit, + const char** maintainers, + int maintainer_count +) { + if (!signer || !repo_id) { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // d tag (required) + add_simple_tag(tags, "d", repo_id); + + // name tag (optional) + if (name) { + add_simple_tag(tags, "name", name); + } + + // description tag (optional) + if (description) { + add_simple_tag(tags, "description", description); + } + + // web tag (optional, can be multiple) + if (web_url) { + add_simple_tag(tags, "web", web_url); + } + + // clone tags (optional, can be multiple) + if (clone_urls && clone_count > 0) { + add_multi_tag(tags, "clone", clone_urls, clone_count); + } + + // relays tags (optional, can be multiple) + if (relay_urls && relay_count > 0) { + add_multi_tag(tags, "relays", relay_urls, relay_count); + } + + // r tag with euc marker (optional) + if (euc_commit) { + cJSON* r_tag = cJSON_CreateArray(); + if (r_tag) { + cJSON_AddItemToArray(r_tag, cJSON_CreateString("r")); + cJSON_AddItemToArray(r_tag, cJSON_CreateString(euc_commit)); + cJSON_AddItemToArray(r_tag, cJSON_CreateString("euc")); + cJSON_AddItemToArray(tags, r_tag); + } + } + + // maintainers tags (optional, can be multiple) + if (maintainers && maintainer_count > 0) { + add_multi_tag(tags, "maintainers", maintainers, maintainer_count); + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_REPO_ANNOUNCEMENT, "", tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create a repository state announcement event (kind 30618) + */ +cJSON* nostr_nip34_create_repo_state( + nostr_signer_t* signer, + const char* repo_id, + const char** ref_names, + const char** ref_values, + int ref_count +) { + if (!signer || !repo_id) { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // d tag (required) + add_simple_tag(tags, "d", repo_id); + + // refs tags (optional, can be multiple) + if (ref_names && ref_values && ref_count > 0) { + for (int i = 0; i < ref_count; i++) { + if (ref_names[i] && ref_values[i]) { + add_simple_tag(tags, ref_names[i], ref_values[i]); + } + } + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_REPO_STATE, "", tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create a patch event (kind 1617) + */ +cJSON* nostr_nip34_create_patch( + nostr_signer_t* signer, + const char* repo_address, + const char* euc_commit, + const char* patch_content, + const char* commit_id, + const char* parent_commit_id, + const char* commit_pgp_sig, + const char* committer_name, + const char* committer_email, + const char* committer_timestamp, + int committer_tz_offset, + int is_root, + int is_root_revision +) { + if (!signer || !repo_address || !patch_content) { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // a tag pointing to the repository announcement + add_simple_tag(tags, "a", repo_address); + + // r tag with euc (optional) + if (euc_commit) { + add_tag_with_extra(tags, "r", euc_commit, NULL); + } + + // t tags for root/root-revision markers + if (is_root) { + add_simple_tag(tags, "t", "root"); + } + if (is_root_revision) { + add_simple_tag(tags, "t", "root-revision"); + } + + // commit tag (optional, for stable commit IDs) + if (commit_id) { + add_simple_tag(tags, "commit", commit_id); + } + + // parent-commit tag (optional) + if (parent_commit_id) { + add_simple_tag(tags, "parent-commit", parent_commit_id); + } + + // commit-pgp-sig tag (optional) + if (commit_pgp_sig) { + add_simple_tag(tags, "commit-pgp-sig", commit_pgp_sig); + } + + // committer tag (optional) + if (committer_name && committer_email && committer_timestamp) { + char tz_str[16]; + snprintf(tz_str, sizeof(tz_str), "%d", committer_tz_offset); + cJSON* committer_tag = cJSON_CreateArray(); + if (committer_tag) { + cJSON_AddItemToArray(committer_tag, cJSON_CreateString("committer")); + cJSON_AddItemToArray(committer_tag, cJSON_CreateString(committer_name)); + cJSON_AddItemToArray(committer_tag, cJSON_CreateString(committer_email)); + cJSON_AddItemToArray(committer_tag, cJSON_CreateString(committer_timestamp)); + cJSON_AddItemToArray(committer_tag, cJSON_CreateString(tz_str)); + cJSON_AddItemToArray(tags, committer_tag); + } + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_PATCH, patch_content, tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create a pull request event (kind 1618) + */ +cJSON* nostr_nip34_create_pull_request( + nostr_signer_t* signer, + const char* repo_address, + const char* euc_commit, + const char* subject, + const char* content, + const char* tip_commit, + const char** clone_urls, + int clone_count, + const char* branch_name, + const char* root_patch_event_id, + const char* merge_base +) { + if (!signer || !repo_address) { + return NULL; + } + + const char* body = content ? content : ""; + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // a tag pointing to the repository announcement + add_simple_tag(tags, "a", repo_address); + + // r tag with euc (optional) + if (euc_commit) { + add_tag_with_extra(tags, "r", euc_commit, NULL); + } + + // subject tag (optional) + if (subject) { + add_simple_tag(tags, "subject", subject); + } + + // c tag for tip commit (optional) + if (tip_commit) { + add_simple_tag(tags, "c", tip_commit); + } + + // clone tags (optional, can be multiple) + if (clone_urls && clone_count > 0) { + add_multi_tag(tags, "clone", clone_urls, clone_count); + } + + // branch-name tag (optional) + if (branch_name) { + add_simple_tag(tags, "branch-name", branch_name); + } + + // e tag for revision of existing patch (optional) + if (root_patch_event_id) { + add_simple_tag(tags, "e", root_patch_event_id); + } + + // merge-base tag (optional) + if (merge_base) { + add_simple_tag(tags, "merge-base", merge_base); + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_PULL_REQUEST, body, tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create a pull request update event (kind 1619) + */ +cJSON* nostr_nip34_create_pr_update( + nostr_signer_t* signer, + const char* repo_address, + const char* euc_commit, + const char* pr_event_id, + const char* pr_author, + const char* tip_commit, + const char** clone_urls, + int clone_count, + const char* merge_base +) { + if (!signer || !repo_address || !pr_event_id || !pr_author) { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // a tag pointing to the repository announcement + add_simple_tag(tags, "a", repo_address); + + // r tag with euc (optional) + if (euc_commit) { + add_tag_with_extra(tags, "r", euc_commit, NULL); + } + + // NIP-22 tags for the parent PR + add_simple_tag(tags, "E", pr_event_id); + add_simple_tag(tags, "P", pr_author); + + // c tag for updated tip commit (optional) + if (tip_commit) { + add_simple_tag(tags, "c", tip_commit); + } + + // clone tags (optional, can be multiple) + if (clone_urls && clone_count > 0) { + add_multi_tag(tags, "clone", clone_urls, clone_count); + } + + // merge-base tag (optional) + if (merge_base) { + add_simple_tag(tags, "merge-base", merge_base); + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_PR_UPDATE, "", tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create an issue event (kind 1621) + */ +cJSON* nostr_nip34_create_issue( + nostr_signer_t* signer, + const char* repo_address, + const char* subject, + const char* content, + const char** labels, + int label_count +) { + if (!signer || !repo_address) { + return NULL; + } + + const char* body = content ? content : ""; + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // a tag pointing to the repository announcement + add_simple_tag(tags, "a", repo_address); + + // subject tag (optional) + if (subject) { + add_simple_tag(tags, "subject", subject); + } + + // t tags for labels (optional, can be multiple) + if (labels && label_count > 0) { + add_multi_tag(tags, "t", labels, label_count); + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_ISSUE, body, tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create a status event (kind 1630-1633) + */ +cJSON* nostr_nip34_create_status( + nostr_signer_t* signer, + int kind, + const char* root_id, + const char* repo_address, + const char* euc_commit, + const char* content +) { + if (!signer || !root_id) { + return NULL; + } + + // Validate kind is a valid status kind + if (kind != NOSTR_NIP34_KIND_STATUS_OPEN && + kind != NOSTR_NIP34_KIND_STATUS_MERGED && + kind != NOSTR_NIP34_KIND_STATUS_CLOSED && + kind != NOSTR_NIP34_KIND_STATUS_DRAFT) { + return NULL; + } + + const char* body = content ? content : ""; + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // e tag pointing to the root issue, PR, or patch + add_tag_with_extra(tags, "e", root_id, "root"); + + // a tag for the repository (optional) + if (repo_address) { + add_simple_tag(tags, "a", repo_address); + } + + // r tag with euc (optional) + if (euc_commit) { + add_tag_with_extra(tags, "r", euc_commit, NULL); + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + kind, body, tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * NIP-34: Create a user grasp list event (kind 10317) + */ +cJSON* nostr_nip34_create_grasp_list( + nostr_signer_t* signer, + const char** grasp_urls, + int grasp_count +) { + if (!signer) { + return NULL; + } + + cJSON* tags = cJSON_CreateArray(); + if (!tags) return NULL; + + // g tags for grasp server URLs (optional, can be multiple) + if (grasp_urls && grasp_count > 0) { + add_multi_tag(tags, "g", grasp_urls, grasp_count); + } + + cJSON* event = nostr_create_and_sign_event_with_signer( + NOSTR_NIP34_KIND_GRASP_LIST, "", tags, signer, 0 + ); + + cJSON_Delete(tags); + return event; +} + +/** + * Percent-decode a string in-place. + * Returns the number of characters after decoding. + */ +static int percent_decode_inplace(char* s) { + if (!s) return 0; + char* src = s; + char* dst = s; + while (*src) { + if (*src == '%' && *(src+1) && *(src+2)) { + char hex[3] = { src[1], src[2], '\0' }; + *dst++ = (char)strtol(hex, NULL, 16); + src += 3; + } else { + *dst++ = *src++; + } + } + *dst = '\0'; + return (int)(dst - s); +} + +/** + * NIP-34: Parse a nostr:// git URL into its components + * + * Supports formats: + * nostr:// + * nostr:/// + * nostr://// + * + * Uses manual character scanning (no strtok) to avoid reentrancy issues. + */ +int nostr_nip34_parse_nostr_url( + const char* url, + char* npub_out, + char* identifier_out, + char* relay_hint_out +) { + if (!url || !npub_out || !identifier_out) { + return -1; + } + + // Check for nostr:// prefix + const char* prefix = "nostr://"; + size_t prefix_len = strlen(prefix); + + if (strncmp(url, prefix, prefix_len) != 0) { + return -1; + } + + const char* p = url + prefix_len; + if (*p == '\0') { + return -1; + } + + // Make a mutable copy of the path + char* path_copy = strdup(p); + if (!path_copy) { + return -1; + } + + // Find the three possible segments separated by '/' + // Segment 0: npub or nip05 address (up to first '/') + // Segment 1: relay hint (between first and second '/') + // Segment 2: identifier (after second '/') + char* segments[3] = {NULL, NULL, NULL}; + int seg_count = 0; + + segments[0] = path_copy; + seg_count = 1; + + for (char* q = path_copy; *q; q++) { + if (*q == '/') { + *q = '\0'; // null-terminate current segment + if (seg_count < 3) { + segments[seg_count] = q + 1; + seg_count++; + } + } + } + + // Percent-decode each segment in-place + for (int i = 0; i < seg_count; i++) { + if (segments[i]) { + percent_decode_inplace(segments[i]); + } + } + + // Copy segments to output buffers + if (seg_count >= 1 && segments[0]) { + strncpy(npub_out, segments[0], 255); + npub_out[255] = '\0'; + } else { + npub_out[0] = '\0'; + } + + if (seg_count >= 2 && segments[1]) { + strncpy(identifier_out, segments[1], 255); + identifier_out[255] = '\0'; + } else { + identifier_out[0] = '\0'; + } + + if (relay_hint_out) { + if (seg_count >= 3 && segments[2]) { + // With 3 segments, segment[1] is the relay hint, segment[2] is the identifier + strncpy(relay_hint_out, segments[1], 511); + relay_hint_out[511] = '\0'; + // Move identifier from segment[2] to identifier_out + strncpy(identifier_out, segments[2], 255); + identifier_out[255] = '\0'; + } else { + relay_hint_out[0] = '\0'; + } + } else { + // No relay_hint_out provided, but we still need to handle 3-segment case + if (seg_count >= 3 && segments[2]) { + strncpy(identifier_out, segments[2], 255); + identifier_out[255] = '\0'; + } + } + + free(path_copy); + return 0; +} diff --git a/nostr_core/nip034.h b/nostr_core/nip034.h new file mode 100644 index 00000000..eeb6cbc2 --- /dev/null +++ b/nostr_core/nip034.h @@ -0,0 +1,268 @@ +/* + * NIP-34: `git` stuff + * https://github.com/nostr-protocol/nips/blob/master/34.md + * + * Defines event kinds and helpers for code collaboration using git on Nostr. + */ + +#ifndef NOSTR_NIP034_H +#define NOSTR_NIP034_H + +#include +#include "nostr_signer.h" +#include "../cjson/cJSON.h" + +#ifdef __cplusplus +extern "C" { +#endif + +// NIP-34 event kinds +#define NOSTR_NIP34_KIND_REPO_ANNOUNCEMENT 30617 +#define NOSTR_NIP34_KIND_REPO_STATE 30618 +#define NOSTR_NIP34_KIND_PATCH 1617 +#define NOSTR_NIP34_KIND_PULL_REQUEST 1618 +#define NOSTR_NIP34_KIND_PR_UPDATE 1619 +#define NOSTR_NIP34_KIND_ISSUE 1621 +#define NOSTR_NIP34_KIND_STATUS_OPEN 1630 +#define NOSTR_NIP34_KIND_STATUS_MERGED 1631 +#define NOSTR_NIP34_KIND_STATUS_CLOSED 1632 +#define NOSTR_NIP34_KIND_STATUS_DRAFT 1633 +#define NOSTR_NIP34_KIND_GRASP_LIST 10317 + +/** + * NIP-34: Create a repository announcement event (kind 30617) + * + * Announces a git repository's existence on Nostr. + * + * @param signer Signer to use for event creation + * @param repo_id Repository identifier (d tag, usually kebab-case short name) + * @param name Human-readable project name (can be NULL) + * @param description Brief human-readable project description (can be NULL) + * @param web_url URL for browsing the repository (can be NULL) + * @param clone_urls Array of git clone URLs (can be NULL) + * @param clone_count Number of clone URLs + * @param relay_urls Array of relay URLs for patches and issues (can be NULL) + * @param relay_count Number of relay URLs + * @param euc_commit Earliest unique commit ID for fork grouping (can be NULL) + * @param maintainers Array of maintainer pubkeys (can be NULL) + * @param maintainer_count Number of maintainers + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_repo_announcement( + nostr_signer_t* signer, + const char* repo_id, + const char* name, + const char* description, + const char* web_url, + const char** clone_urls, + int clone_count, + const char** relay_urls, + int relay_count, + const char* euc_commit, + const char** maintainers, + int maintainer_count +); + +/** + * NIP-34: Create a repository state announcement event (kind 30618) + * + * Announces the current state of branches and tags in a repository. + * + * @param signer Signer to use for event creation + * @param repo_id Repository identifier (matches the d tag in the announcement) + * @param ref_names Array of reference names (e.g., "refs/heads/master") + * @param ref_values Array of reference values (commit hashes or "ref: refs/heads/") + * @param ref_count Number of references + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_repo_state( + nostr_signer_t* signer, + const char* repo_id, + const char** ref_names, + const char** ref_values, + int ref_count +); + +/** + * NIP-34: Create a patch event (kind 1617) + * + * Contains the output of git format-patch for proposing changes. + * + * @param signer Signer to use for event creation + * @param repo_address Address tag value "30617::" + * @param euc_commit Earliest unique commit ID of the repo (can be NULL) + * @param patch_content The git format-patch output + * @param commit_id Current commit ID for stable commit IDs (can be NULL) + * @param parent_commit_id Parent commit ID (can be NULL) + * @param commit_pgp_sig PGP signature for the commit (can be NULL, empty string for unsigned) + * @param committer_name Committer name (can be NULL) + * @param committer_email Committer email (can be NULL) + * @param committer_timestamp Committer timestamp (can be NULL) + * @param committer_tz_offset Committer timezone offset in minutes (0 if unused) + * @param is_root 1 if this is the root patch in a series, 0 otherwise + * @param is_root_revision 1 if this is the first patch in a revision, 0 otherwise + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_patch( + nostr_signer_t* signer, + const char* repo_address, + const char* euc_commit, + const char* patch_content, + const char* commit_id, + const char* parent_commit_id, + const char* commit_pgp_sig, + const char* committer_name, + const char* committer_email, + const char* committer_timestamp, + int committer_tz_offset, + int is_root, + int is_root_revision +); + +/** + * NIP-34: Create a pull request event (kind 1618) + * + * Points to proposed changes in a git repository via clone URLs. + * + * @param signer Signer to use for event creation + * @param repo_address Address tag value "30617::" + * @param euc_commit Earliest unique commit ID of the repo (can be NULL) + * @param subject PR subject/title (can be NULL) + * @param content Markdown body text + * @param tip_commit Current commit ID at the tip of the PR branch (can be NULL) + * @param clone_urls Array of git clone URLs where the PR branch can be fetched + * @param clone_count Number of clone URLs + * @param branch_name Recommended branch name (can be NULL) + * @param root_patch_event_id Event ID of the root patch this revises (can be NULL) + * @param merge_base Most recent common ancestor with the target branch (can be NULL) + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_pull_request( + nostr_signer_t* signer, + const char* repo_address, + const char* euc_commit, + const char* subject, + const char* content, + const char* tip_commit, + const char** clone_urls, + int clone_count, + const char* branch_name, + const char* root_patch_event_id, + const char* merge_base +); + +/** + * NIP-34: Create a pull request update event (kind 1619) + * + * Updates the tip commit of an existing pull request. + * + * @param signer Signer to use for event creation + * @param repo_address Address tag value "30617::" + * @param euc_commit Earliest unique commit ID of the repo (can be NULL) + * @param pr_event_id The pull request event ID being updated + * @param pr_author The pubkey of the pull request author + * @param tip_commit Updated commit ID at the tip of the PR branch (can be NULL) + * @param clone_urls Array of git clone URLs where the PR branch can be fetched + * @param clone_count Number of clone URLs + * @param merge_base Most recent common ancestor with the target branch (can be NULL) + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_pr_update( + nostr_signer_t* signer, + const char* repo_address, + const char* euc_commit, + const char* pr_event_id, + const char* pr_author, + const char* tip_commit, + const char** clone_urls, + int clone_count, + const char* merge_base +); + +/** + * NIP-34: Create an issue event (kind 1621) + * + * Bug reports, feature requests, questions or comments related to a repository. + * + * @param signer Signer to use for event creation + * @param repo_address Address tag value "30617::" + * @param subject Issue subject/title (can be NULL) + * @param content Markdown body text + * @param labels Array of label strings (can be NULL) + * @param label_count Number of labels + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_issue( + nostr_signer_t* signer, + const char* repo_address, + const char* subject, + const char* content, + const char** labels, + int label_count +); + +/** + * NIP-34: Create a status event (kind 1630-1633) + * + * Sets the status of a root patch, PR, or issue. + * + * @param signer Signer to use for event creation + * @param kind Status kind: 1630 (Open), 1631 (Applied/Merged/Resolved), + * 1632 (Closed), 1633 (Draft) + * @param root_id Event ID of the root issue, PR, or original root patch + * @param repo_address Address tag value "30617::" (can be NULL) + * @param euc_commit Earliest unique commit ID of the repo (can be NULL) + * @param content Markdown text describing the status change (can be NULL) + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_status( + nostr_signer_t* signer, + int kind, + const char* root_id, + const char* repo_address, + const char* euc_commit, + const char* content +); + +/** + * NIP-34: Create a user grasp list event (kind 10317) + * + * Lists grasp servers the user wishes to use for NIP-34 activity. + * + * @param signer Signer to use for event creation + * @param grasp_urls Array of grasp service websocket URLs + * @param grasp_count Number of grasp URLs + * @return Signed cJSON event, or NULL on error + */ +cJSON* nostr_nip34_create_grasp_list( + nostr_signer_t* signer, + const char** grasp_urls, + int grasp_count +); + +/** + * NIP-34: Parse a nostr:// git URL into its components + * + * Supports formats: + * nostr:// + * nostr:/// + * nostr://// + * + * @param url The nostr:// URL to parse + * @param npub_out Buffer for npub or NIP-05 address (at least 256 bytes) + * @param identifier_out Buffer for the repository identifier (at least 256 bytes) + * @param relay_hint_out Buffer for the relay hint URL (at least 512 bytes, optional, can be NULL) + * @return 0 on success, -1 on error + */ +int nostr_nip34_parse_nostr_url( + const char* url, + char* npub_out, + char* identifier_out, + char* relay_hint_out +); + +#ifdef __cplusplus +} +#endif + +#endif /* NOSTR_NIP034_H */ diff --git a/nostr_core/nostr_core.h b/nostr_core/nostr_core.h index 5c306cce..8930b687 100644 --- a/nostr_core/nostr_core.h +++ b/nostr_core/nostr_core.h @@ -2,10 +2,10 @@ #define NOSTR_CORE_H // Version information (auto-updated by increment_and_push.sh) -#define VERSION "v0.6.11" +#define VERSION "v0.6.12" #define VERSION_MAJOR 0 #define VERSION_MINOR 6 -#define VERSION_PATCH 11 +#define VERSION_PATCH 12 /* * NOSTR Core Library - Complete API Reference @@ -71,6 +71,17 @@ * - nostr_nip17_receive_dm() -> Receive and decrypt DM * - nostr_nip17_extract_dm_relays() -> Extract relay URLs from kind 10050 * + * NIP-34 GIT STUFF: + * - nostr_nip34_create_repo_announcement() -> Create repo announcement (kind 30617) + * - nostr_nip34_create_repo_state() -> Create repo state (kind 30618) + * - nostr_nip34_create_patch() -> Create patch (kind 1617) + * - nostr_nip34_create_pull_request() -> Create pull request (kind 1618) + * - nostr_nip34_create_pr_update() -> Create PR update (kind 1619) + * - nostr_nip34_create_issue() -> Create issue (kind 1621) + * - nostr_nip34_create_status() -> Create status (kind 1630-1633) + * - nostr_nip34_create_grasp_list() -> Create grasp list (kind 10317) + * - nostr_nip34_parse_nostr_url() -> Parse nostr:// git URL + * * NIP-42 AUTHENTICATION: * - nostr_nip42_create_auth_event() -> Create authentication event (kind 22242) * - nostr_nip42_verify_auth_event() -> Verify authentication event (relay-side) @@ -187,6 +198,7 @@ extern "C" { #include "nip017.h" // Private Direct Messages #include "nip019.h" // Bech32 encoding (nsec/npub) #include "nip021.h" // nostr: URI scheme +#include "nip034.h" // git stuff #include "nip042.h" // Authentication of clients to relays #include "nip044.h" // Encryption (modern) #include "nip046.h" // Remote signing diff --git a/tests/nip34_test.c b/tests/nip34_test.c new file mode 100644 index 00000000..ba888167 --- /dev/null +++ b/tests/nip34_test.c @@ -0,0 +1,606 @@ +/* + * NIP-34 Git Stuff Test Suite + * Tests repository announcement, state, patch, PR, issue, and status event creation + * Following TESTS POLICY: Shows expected vs actual values, prints entire JSON events + */ + +#define _GNU_SOURCE +#include +#include +#include +#include +#include "../nostr_core/nip034.h" +#include "../nostr_core/nip001.h" +#include "../nostr_core/nostr_common.h" +#include "../nostr_core/utils.h" +#include "../cjson/cJSON.h" + +// Test private key (well-known test key, DO NOT use in production) +static const unsigned char test_private_key[32] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01 +}; + +static int test_count = 0; +static int passed_tests = 0; + +void print_test_header(const char* test_name) { + test_count++; + printf("\n=== TEST %d: %s ===\n", test_count, test_name); +} + +void print_test_result(int passed, const char* test_name) { + if (passed) { + passed_tests++; + printf("✅ PASS: %s\n", test_name); + } else { + printf("❌ FAIL: %s\n", test_name); + } +} + +void print_json_event(cJSON* event, const char* label) { + if (!event) { + printf("%s: NULL\n", label); + return; + } + char* json = cJSON_Print(event); + printf("%s:\n%s\n", label, json ? json : "NULL"); + if (json) free(json); +} + +// Test 1: Create a repository announcement event +int test_repo_announcement(void) { + print_test_header("Repository Announcement (kind 30617)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* clone_urls[] = { + "https://laantungir.net/grasp/npub1test/c-relay.git", + "https://gitgrasp.com/npub1test/c-relay.git", + NULL + }; + const char* relay_urls[] = { + "wss://relay.laantungir.net", + "wss://relay.damus.io", + NULL + }; + const char* maintainers[] = { + "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4", + NULL + }; + + cJSON* event = nostr_nip34_create_repo_announcement( + signer, + "c-relay", + "c-relay", + "A blazingly fast, production-ready Nostr relay", + "https://git.laantungir.net/laantungir/c-relay", + clone_urls, 2, + relay_urls, 2, + "abc123def456abc123def456abc123def456abc123def456abc123def456abc123", + maintainers, 1 + ); + + if (!event) { + printf("❌ Failed to create repo announcement event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Repository Announcement Event"); + + // Validate the event + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0 = NOSTR_SUCCESS)\n", rc); + + // Check kind + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 30617)\n", kind_val); + + // Check tags + cJSON* tags = cJSON_GetObjectItem(event, "tags"); + int tag_count = tags ? cJSON_GetArraySize(tags) : 0; + printf("Tag count: %d (expected >= 6)\n", tag_count); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 30617 && tag_count >= 6); + print_test_result(passed, "Repository Announcement"); + return passed; +} + +// Test 2: Create a repository state event +int test_repo_state(void) { + print_test_header("Repository State (kind 30618)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* ref_names[] = { + "refs/heads/master", + "refs/heads/develop", + "refs/tags/v1.0.0", + "HEAD", + NULL + }; + const char* ref_values[] = { + "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6", + "f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1f6e5d4c3b2a1", + "0011223344550011223344550011223344550011223344550011223344550011", + "ref: refs/heads/master", + NULL + }; + + cJSON* event = nostr_nip34_create_repo_state( + signer, + "c-relay", + ref_names, ref_values, 4 + ); + + if (!event) { + printf("❌ Failed to create repo state event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Repository State Event"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 30618)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 30618); + print_test_result(passed, "Repository State"); + return passed; +} + +// Test 3: Create a patch event +int test_patch(void) { + print_test_header("Patch (kind 1617)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* patch_content = + "From 1234567890abcdef1234567890abcdef12345678 Mon Sep 17 00:00:00 2024\n" + "From: Test User \n" + "Date: Mon, 1 Jan 2024 12:00:00 +0000\n" + "Subject: [PATCH] Add new feature\n" + "\n" + "This is a test patch.\n" + "---\n" + " src/main.c | 2 ++\n" + " 1 file changed, 2 insertions(+)\n" + "\n" + "diff --git a/src/main.c b/src/main.c\n" + "index abc123..def456 100644\n" + "--- a/src/main.c\n" + "+++ b/src/main.c\n" + "@@ -1,3 +1,5 @@\n" + " #include \n" + "+\n" + "+// New feature\n" + " int main(void) {\n" + " return 0;\n" + " }\n"; + + cJSON* event = nostr_nip34_create_patch( + signer, + "30617:aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4:c-relay", + "abc123def456abc123def456abc123def456abc123def456abc123def456abc123", + patch_content, + "1234567890abcdef1234567890abcdef12345678", + "abcdef1234567890abcdef1234567890abcdef12", + "-----BEGIN PGP SIGNATURE-----\n\niQEzBAABCAAdFiEE...\n-----END PGP SIGNATURE-----", + "Test User", + "test@example.com", + "1704110400", + 0, + 1, // is_root + 1 // is_root_revision + ); + + if (!event) { + printf("❌ Failed to create patch event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Patch Event"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 1617)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 1617); + print_test_result(passed, "Patch"); + return passed; +} + +// Test 4: Create a pull request event +int test_pull_request(void) { + print_test_header("Pull Request (kind 1618)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* clone_urls[] = { + "https://github.com/user/c-relay.git", + NULL + }; + + cJSON* event = nostr_nip34_create_pull_request( + signer, + "30617:aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4:c-relay", + "abc123def456abc123def456abc123def456abc123def456abc123def456abc123", + "Fix critical bug in relay connection handling", + "This PR fixes a race condition in the relay connection code.\n\nChanges:\n- Added mutex locking\n- Fixed timeout handling", + "fedcba9876543210fedcba9876543210fedcba98", + clone_urls, 1, + "fix-race-condition", + NULL, // no root patch event + "abcdef1234567890abcdef1234567890abcdef12" // merge base + ); + + if (!event) { + printf("❌ Failed to create pull request event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Pull Request Event"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 1618)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 1618); + print_test_result(passed, "Pull Request"); + return passed; +} + +// Test 5: Create an issue event +int test_issue(void) { + print_test_header("Issue (kind 1621)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* labels[] = { + "bug", + "high-priority", + NULL + }; + + cJSON* event = nostr_nip34_create_issue( + signer, + "30617:aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4:c-relay", + "Connection timeout on startup", + "When starting the relay with a large database, the connection times out after 30 seconds.\n\nSteps to reproduce:\n1. Start relay with 1M events\n2. Try to connect\n3. Observe timeout", + labels, 2 + ); + + if (!event) { + printf("❌ Failed to create issue event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Issue Event"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 1621)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 1621); + print_test_result(passed, "Issue"); + return passed; +} + +// Test 6: Create a status event +int test_status(void) { + print_test_header("Status (kind 1631 - Merged)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + cJSON* event = nostr_nip34_create_status( + signer, + NOSTR_NIP34_KIND_STATUS_MERGED, + "abc123def456abc123def456abc123def456abc123def456abc123def456abc123", + "30617:aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4:c-relay", + "abc123def456abc123def456abc123def456abc123def456abc123def456abc123", + "Patch has been merged to master" + ); + + if (!event) { + printf("❌ Failed to create status event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Status Event (Merged)"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 1631)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 1631); + print_test_result(passed, "Status"); + return passed; +} + +// Test 7: Create a grasp list event +int test_grasp_list(void) { + print_test_header("Grasp List (kind 10317)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* grasp_urls[] = { + "wss://grasp.laantungir.net", + "wss://gitgrasp.com", + NULL + }; + + cJSON* event = nostr_nip34_create_grasp_list( + signer, + grasp_urls, 2 + ); + + if (!event) { + printf("❌ Failed to create grasp list event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "Grasp List Event"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 10317)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 10317); + print_test_result(passed, "Grasp List"); + return passed; +} + +// Test 8: Parse nostr:// URLs +int test_parse_nostr_url(void) { + print_test_header("Parse nostr:// URLs"); + + int all_passed = 1; + + // Test 1: nostr:/// + { + char npub[256], ident[256], relay[512]; + int rc = nostr_nip34_parse_nostr_url( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/c-relay", + npub, ident, relay + ); + printf("Test 1 - npub/identifier:\n"); + printf(" Result: %d (expected 0)\n", rc); + printf(" npub: %s\n", npub); + printf(" ident: %s\n", ident); + printf(" relay: %s\n", relay); + if (rc != 0 || strlen(npub) == 0 || strlen(ident) == 0) { + printf(" ❌ FAIL\n"); + all_passed = 0; + } else { + printf(" ✅ PASS\n"); + } + } + + // Test 2: nostr://// + { + char npub[256], ident[256], relay[512]; + int rc = nostr_nip34_parse_nostr_url( + "nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/relay.ngit.dev/ngit", + npub, ident, relay + ); + printf("Test 2 - npub/relay/identifier:\n"); + printf(" Result: %d (expected 0)\n", rc); + printf(" npub: %s\n", npub); + printf(" relay: %s\n", relay); + printf(" ident: %s\n", ident); + if (rc != 0 || strlen(npub) == 0 || strlen(relay) == 0 || strlen(ident) == 0) { + printf(" ❌ FAIL\n"); + all_passed = 0; + } else { + printf(" ✅ PASS\n"); + } + } + + // Test 3: nostr:/// + { + char npub[256], ident[256], relay[512]; + int rc = nostr_nip34_parse_nostr_url( + "nostr://danconwaydev.com/ws%3A%2F%2Flocalhost%3A7334/my-local-only-repo", + npub, ident, relay + ); + printf("Test 3 - nip05/relay/identifier (percent-encoded):\n"); + printf(" Result: %d (expected 0)\n", rc); + printf(" npub: %s\n", npub); + printf(" relay: %s\n", relay); + printf(" ident: %s\n", ident); + if (rc != 0 || strlen(npub) == 0 || strlen(relay) == 0 || strcmp(ident, "my-local-only-repo") != 0) { + printf(" ❌ FAIL\n"); + all_passed = 0; + } else { + printf(" ✅ PASS\n"); + } + } + + // Test 4: Invalid URL (no nostr:// prefix) + { + char npub[256], ident[256], relay[512]; + int rc = nostr_nip34_parse_nostr_url( + "https://example.com/repo.git", + npub, ident, relay + ); + printf("Test 4 - invalid URL (https):\n"); + printf(" Result: %d (expected -1)\n", rc); + if (rc != -1) { + printf(" ❌ FAIL\n"); + all_passed = 0; + } else { + printf(" ✅ PASS\n"); + } + } + + print_test_result(all_passed, "Parse nostr:// URLs"); + return all_passed; +} + +// Test 9: Create a PR update event +int test_pr_update(void) { + print_test_header("PR Update (kind 1619)"); + + nostr_signer_t* signer = nostr_signer_local(test_private_key); + if (!signer) { + printf("❌ Failed to create signer\n"); + return 0; + } + + const char* clone_urls[] = { + "https://github.com/user/c-relay.git", + NULL + }; + + cJSON* event = nostr_nip34_create_pr_update( + signer, + "30617:aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4:c-relay", + "abc123def456abc123def456abc123def456abc123def456abc123def456abc123", + "pr_event_id_1234567890abcdef", + "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4", + "new_tip_commit_hash_1234567890abcdef", + clone_urls, 1, + "updated_merge_base_hash_1234567890" + ); + + if (!event) { + printf("❌ Failed to create PR update event\n"); + nostr_signer_free(signer); + return 0; + } + + print_json_event(event, "PR Update Event"); + + int rc = nostr_validate_event(event); + printf("Validation result: %d (expected 0)\n", rc); + + cJSON* kind = cJSON_GetObjectItem(event, "kind"); + int kind_val = kind ? (int)cJSON_GetNumberValue(kind) : -1; + printf("Kind: %d (expected 1619)\n", kind_val); + + cJSON_Delete(event); + nostr_signer_free(signer); + + int passed = (rc == NOSTR_SUCCESS && kind_val == 1619); + print_test_result(passed, "PR Update"); + return passed; +} + +int main(void) { + printf("========================================\n"); + printf(" NIP-34 Git Stuff Test Suite\n"); + printf("========================================\n"); + + // Initialize crypto + if (nostr_crypto_init() != 0) { + printf("❌ Failed to initialize crypto\n"); + return 1; + } + + // Run all tests + int results[9]; + results[0] = test_repo_announcement(); + results[1] = test_repo_state(); + results[2] = test_patch(); + results[3] = test_pull_request(); + results[4] = test_issue(); + results[5] = test_status(); + results[6] = test_grasp_list(); + results[7] = test_parse_nostr_url(); + results[8] = test_pr_update(); + + // Summary + printf("\n========================================\n"); + printf(" TEST SUMMARY\n"); + printf("========================================\n"); + printf("Total tests: %d\n", test_count); + printf("Passed: %d\n", passed_tests); + printf("Failed: %d\n", test_count - passed_tests); + + nostr_crypto_cleanup(); + + if (passed_tests == test_count) { + printf("🎉 ALL TESTS PASSED! NIP-34 implementation is working correctly.\n"); + return 0; + } else { + printf("❌ SOME TESTS FAILED!\n"); + return 1; + } +}