Files
codebase-memory-mcp/tests/test_config_json_like.c
T

1601 lines
64 KiB
C

/*
* test_config_json_like.c — Structure-preserving JSON/JSONC/JSON5 edits.
*
* This suite is intentionally standalone. The installation work that consumes
* the editor can register it with the main runner alongside its build wiring.
*/
#include "test_framework.h"
#define CBM_JSON_LIKE_ENABLE_TEST_API 1
#include "../src/cli/config_json_like.h"
#define CBM_CONFIG_EDIT_PATH_ENABLE_TEST_API 1
#include "../src/cli/config_edit_path.h"
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <sys/stat.h>
#include <unistd.h>
#endif
typedef struct {
char directory[512];
char path[640];
} jl_fixture_t;
static int jl_fixture_open(jl_fixture_t *fixture) {
snprintf(fixture->directory, sizeof(fixture->directory), "%s/cbm-json-like-XXXXXX",
cbm_tmpdir());
if (!cbm_mkdtemp(fixture->directory)) {
return -1;
}
int written =
snprintf(fixture->path, sizeof(fixture->path), "%s/config.json", fixture->directory);
return written >= 0 && (size_t)written < sizeof(fixture->path) ? 0 : -1;
}
static void jl_fixture_close(jl_fixture_t *fixture) {
cbm_dir_t *directory = cbm_opendir(fixture->directory);
if (directory) {
cbm_dirent_t *entry = NULL;
while ((entry = cbm_readdir(directory)) != NULL) {
if (strncmp(entry->name, "config.json.cbm.tmp.", strlen("config.json.cbm.tmp.")) == 0) {
char temp_path[sizeof(fixture->path) + 96U];
int written = snprintf(temp_path, sizeof(temp_path), "%s/%s", fixture->directory,
entry->name);
if (written >= 0 && (size_t)written < sizeof(temp_path)) {
(void)cbm_unlink(temp_path);
}
}
}
cbm_closedir(directory);
}
(void)cbm_unlink(fixture->path);
(void)cbm_rmdir(fixture->directory);
}
static size_t jl_temp_file_count(const jl_fixture_t *fixture) {
cbm_dir_t *directory = cbm_opendir(fixture->directory);
if (!directory) {
return SIZE_MAX;
}
size_t count = 0U;
cbm_dirent_t *entry = NULL;
while ((entry = cbm_readdir(directory)) != NULL) {
if (strncmp(entry->name, "config.json.cbm.tmp.", strlen("config.json.cbm.tmp.")) == 0) {
count++;
}
}
cbm_closedir(directory);
return count;
}
static int jl_write(const char *path, const char *content) {
FILE *file = cbm_fopen(path, "wb");
if (!file) {
return -1;
}
size_t length = strlen(content);
size_t written = fwrite(content, 1U, length, file);
int close_result = fclose(file);
return written == length && close_result == 0 ? 0 : -1;
}
static char *jl_read(const char *path) {
FILE *file = cbm_fopen(path, "rb");
if (!file || fseek(file, 0, SEEK_END) != 0) {
if (file) {
fclose(file);
}
return NULL;
}
long size = ftell(file);
if (size < 0 || fseek(file, 0, SEEK_SET) != 0) {
fclose(file);
return NULL;
}
char *content = malloc((size_t)size + 1U);
if (!content) {
fclose(file);
return NULL;
}
size_t read_count = fread(content, 1U, (size_t)size, file);
int failed = ferror(file);
fclose(file);
if (read_count != (size_t)size || failed) {
free(content);
return NULL;
}
content[read_count] = '\0';
return content;
}
static int jl_failed_unchanged(const char *path, const char *original,
const char *const *object_path, size_t path_len, const char *key,
const char *value) {
if (jl_write(path, original) != 0) {
return 0;
}
if (cbm_json_like_upsert_entry(path, object_path, path_len, key, value) == 0) {
return 0;
}
char *after = jl_read(path);
int unchanged = after && strcmp(after, original) == 0;
free(after);
return unchanged;
}
static size_t jl_occurrences(const char *text, const char *needle) {
size_t count = 0;
size_t needle_length = strlen(needle);
while (needle_length > 0U && (text = strstr(text, needle)) != NULL) {
count++;
text += needle_length;
}
return count;
}
typedef struct {
const char *content;
const char *backup_path;
bool replace_identity;
int result;
} jl_precommit_change_t;
static void jl_change_before_commit(const char *path, void *context) {
jl_precommit_change_t *change = context;
if (change->replace_identity &&
(!change->backup_path || cbm_rename_replace(path, change->backup_path) != 0)) {
change->result = -1;
return;
}
change->result = jl_write(path, change->content);
}
TEST(config_json_like_rejects_stale_content_and_cleans_temp) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_EQ(jl_write(fixture.path, "{\"keep\":1}\n"), 0);
const char *root[] = {NULL};
jl_precommit_change_t change = {
.content = "{\"concurrent\":true}\n",
.backup_path = NULL,
.replace_identity = false,
.result = -1,
};
cbm_json_like_set_precommit_hook_for_testing(jl_change_before_commit, &change);
int result = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
cbm_json_like_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(change.result, 0);
ASSERT_EQ(result, -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, "{\"concurrent\":true}\n");
free(content);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_rejects_stale_identity_with_same_content) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\"keep\":1}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
char backup[sizeof(fixture.path) + 32U];
ASSERT(snprintf(backup, sizeof(backup), "%s/original.json", fixture.directory) > 0);
const char *root[] = {NULL};
jl_precommit_change_t change = {
.content = original,
.backup_path = backup,
.replace_identity = true,
.result = -1,
};
cbm_json_like_set_precommit_hook_for_testing(jl_change_before_commit, &change);
int result = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
cbm_json_like_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(change.result, 0);
ASSERT_EQ(result, -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(backup), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_missing_target_race_does_not_replace_winner) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *root[] = {NULL};
jl_precommit_change_t race = {
.content = "{\"winner\":true}\n",
.backup_path = NULL,
.replace_identity = false,
.result = -1,
};
cbm_json_like_set_prepublish_hook_for_testing(jl_change_before_commit, &race);
int result = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
cbm_json_like_set_prepublish_hook_for_testing(NULL, NULL);
ASSERT_EQ(race.result, 0);
ASSERT_EQ(result, -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, "{\"winner\":true}\n");
free(content);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_existing_target_swap_after_check_preserves_winner) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\"keep\":1}\n";
const char *winner = "{\"winner\":true}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
char backup[sizeof(fixture.path) + 32U];
ASSERT(snprintf(backup, sizeof(backup), "%s/original.json", fixture.directory) > 0);
const char *root[] = {NULL};
jl_precommit_change_t race = {
.content = winner,
.backup_path = backup,
.replace_identity = true,
.result = -1,
};
cbm_json_like_set_prepublish_hook_for_testing(jl_change_before_commit, &race);
int result = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
cbm_json_like_set_prepublish_hook_for_testing(NULL, NULL);
ASSERT_EQ(race.result, 0);
ASSERT_EQ(result, -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, winner);
free(content);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(backup), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_rejects_non_regular_path) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_EQ(cbm_mkdir(fixture.path), 0);
const char *root[] = {NULL};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true"), -1);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_rmdir(fixture.path), 0);
jl_fixture_close(&fixture);
PASS();
}
#ifndef _WIN32
/* Any editor temp file left anywhere in the fixture directory, whatever
* document it was staged for: a link's target lives in the same directory. */
static size_t jl_stray_temp_count(const jl_fixture_t *fixture) {
cbm_dir_t *directory = cbm_opendir(fixture->directory);
if (!directory) {
return SIZE_MAX;
}
size_t count = 0U;
cbm_dirent_t *entry = NULL;
while ((entry = cbm_readdir(directory)) != NULL) {
if (strstr(entry->name, ".cbm.tmp.")) {
count++;
}
}
cbm_closedir(directory);
return count;
}
/* A link owned by someone else is never followed. A test cannot create a
* foreign-owned link without root, so the observer moves instead (test seam):
* the refusal touches neither the link nor its target and stages no temp file. */
TEST(config_json_like_rejects_foreign_owned_symlink_without_touching_target) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.json", fixture.directory) > 0);
const char *original = "{\"target\":true}\n";
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
cbm_config_edit_path_set_invoking_uid_for_test((unsigned)geteuid() + 1U, 1);
const char *root[] = {NULL};
int upsert_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
char reason[256];
int refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
cbm_config_edit_path_set_invoking_uid_for_test(0U, 0);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(upsert_rc, -1);
ASSERT_EQ(refused, 1);
ASSERT_NOT_NULL(strstr(reason, "not the invoking user"));
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char *content = jl_read(target);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
jl_fixture_close(&fixture);
PASS();
}
/* A root process never follows a link a non-root user owns: a user-planted
* link must not redirect a privileged writer. A real root run (CI containers)
* demotes the link to an unprivileged owner first; elsewhere the observer moves. */
TEST(config_json_like_root_refuses_user_owned_symlink) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.json", fixture.directory) > 0);
const char *original = "{\"target\":true}\n";
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
if (geteuid() == 0 && lchown(fixture.path, 65534, 65534) != 0) {
(void)cbm_unlink(fixture.path);
(void)cbm_unlink(target);
jl_fixture_close(&fixture);
FAIL("lchown to an unprivileged owner failed");
}
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
cbm_config_edit_path_set_invoking_uid_for_test(0U, 1);
const char *root[] = {NULL};
int upsert_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
char reason[256];
int refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
cbm_config_edit_path_set_invoking_uid_for_test(0U, 0);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(upsert_rc, -1);
ASSERT_EQ(refused, 1);
ASSERT_NOT_NULL(strstr(reason, "root process"));
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char *content = jl_read(target);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_refuses_dangling_symlink) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char missing[sizeof(fixture.path) + 32U];
ASSERT(snprintf(missing, sizeof(missing), "%s/missing.json", fixture.directory) > 0);
ASSERT_EQ(symlink(missing, fixture.path), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
const char *root[] = {NULL};
int upsert_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
char *document = NULL;
size_t length = 0U;
int read_rc = cbm_json_like_read_document(fixture.path, &document, &length);
free(document);
char reason[256];
int refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
cbm_config_edit_path_follow_clear();
ASSERT_EQ(upsert_rc, -1);
ASSERT_EQ(read_rc, -1);
ASSERT_EQ(refused, 1);
ASSERT_NOT_NULL(strstr(reason, "dangling"));
struct stat state;
ASSERT_EQ(lstat(fixture.path, &state), 0);
ASSERT(S_ISLNK(state.st_mode));
ASSERT(lstat(missing, &state) != 0);
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_refuses_symlink_to_directory) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char sub[sizeof(fixture.path) + 32U];
ASSERT(snprintf(sub, sizeof(sub), "%s/sub", fixture.directory) > 0);
ASSERT_EQ(cbm_mkdir(sub), 0);
ASSERT_EQ(symlink(sub, fixture.path), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
const char *root[] = {NULL};
int upsert_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
char reason[256];
int refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
cbm_config_edit_path_follow_clear();
ASSERT_EQ(upsert_rc, -1);
ASSERT_EQ(refused, 1);
ASSERT_NOT_NULL(strstr(reason, "directory"));
struct stat state;
ASSERT_EQ(lstat(fixture.path, &state), 0);
ASSERT(S_ISLNK(state.st_mode));
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_rmdir(sub), 0);
jl_fixture_close(&fixture);
PASS();
}
/* Without the agent-config writers' opt-in, a same-owner link is still
* refused: the follow is never a property of every editor caller. */
TEST(config_json_like_refuses_symlink_without_opt_in) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.json", fixture.directory) > 0);
const char *original = "{\"target\":true}\n";
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
cbm_config_edit_path_follow_clear();
const char *root[] = {NULL};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true"), -1);
char reason[256];
ASSERT_EQ(cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason)), 1);
ASSERT_NOT_NULL(strstr(reason, "not followed by this command"));
/* An opt-in for a different root does not cover this link either. */
char elsewhere[sizeof(fixture.path) + 32U];
ASSERT(snprintf(elsewhere, sizeof(elsewhere), "%s/elsewhere", fixture.directory) > 0);
ASSERT_EQ(cbm_mkdir(elsewhere), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(elsewhere), 0);
int outside_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
int outside_refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
cbm_config_edit_path_follow_clear();
ASSERT_EQ(outside_rc, -1);
ASSERT_EQ(outside_refused, 1);
ASSERT_NOT_NULL(strstr(reason, "outside the opted-in configuration roots"));
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char *content = jl_read(target);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_rmdir(elsewhere), 0);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
jl_fixture_close(&fixture);
PASS();
}
/* The validated descriptor must be the very file stat(realpath) saw: a
* target swapped between the two checks (seam) is refused, untouched. */
TEST(config_json_like_refuses_symlink_when_target_identity_skews) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.json", fixture.directory) > 0);
const char *original = "{\"target\":true}\n";
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
cbm_config_edit_path_set_identity_skew_for_test(1);
const char *root[] = {NULL};
int upsert_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
char reason[256];
int refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
cbm_config_edit_path_set_identity_skew_for_test(0);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(upsert_rc, -1);
ASSERT_EQ(refused, 1);
ASSERT_NOT_NULL(strstr(reason, "changed while it was being checked"));
char *content = jl_read(target);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
jl_fixture_close(&fixture);
PASS();
}
/* The target's parent directory is pinned and must not be writable by others
* (a sticky directory is fine): otherwise anyone could stage a swap there. */
TEST(config_json_like_refuses_symlink_with_world_writable_parent) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char dotfiles[sizeof(fixture.path) + 32U];
char target[sizeof(fixture.path) + 64U];
ASSERT(snprintf(dotfiles, sizeof(dotfiles), "%s/dotfiles", fixture.directory) > 0);
ASSERT(snprintf(target, sizeof(target), "%s/target.json", dotfiles) > 0);
ASSERT_EQ(cbm_mkdir(dotfiles), 0);
const char *original = "{\"target\":true}\n";
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
ASSERT_EQ(chmod(dotfiles, 0777), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
const char *root[] = {NULL};
int loose_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
char reason[256];
int loose_refused = cbm_config_edit_path_refusal(fixture.path, reason, sizeof(reason));
ASSERT_EQ(chmod(dotfiles, 0755), 0);
int tight_rc = cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true");
cbm_config_edit_path_follow_clear();
ASSERT_EQ(loose_rc, -1);
ASSERT_EQ(loose_refused, 1);
ASSERT_NOT_NULL(strstr(reason, "writable by others"));
ASSERT_EQ(tight_rc, 0);
char *content = jl_read(target);
ASSERT_NOT_NULL(content);
ASSERT_NOT_NULL(strstr(content, "\"owned\""));
free(content);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
ASSERT_EQ(cbm_rmdir(dotfiles), 0);
jl_fixture_close(&fixture);
PASS();
}
typedef struct {
char seen_path[640];
size_t temps_beside_target;
size_t calls;
} jl_follow_hook_state_t;
/* Pre-commit hook: the editor hands over the path it is about to publish —
* for a followed link that must be the RESOLVED target, with the staged
* temp file sitting right beside it, never beside the link. */
static void jl_follow_precommit_hook(const char *path, void *context) {
jl_follow_hook_state_t *state = context;
state->calls++;
(void)snprintf(state->seen_path, sizeof(state->seen_path), "%s", path);
char directory[640];
(void)snprintf(directory, sizeof(directory), "%s", path);
char *slash = strrchr(directory, '/');
if (!slash) {
return;
}
const char *base = slash + 1;
char prefix[320];
(void)snprintf(prefix, sizeof(prefix), "%s.cbm.tmp.", base);
*slash = '\0';
cbm_dir_t *handle = cbm_opendir(directory);
if (!handle) {
return;
}
cbm_dirent_t *entry = NULL;
while ((entry = cbm_readdir(handle)) != NULL) {
if (strncmp(entry->name, prefix, strlen(prefix)) == 0) {
state->temps_beside_target++;
}
}
cbm_closedir(handle);
}
/* Decision C (#1954): a symlink the invoking user owns, pointing at a regular
* file the same user owns, is edited THROUGH once the writer opted in — the
* link survives and still points at the same target, the temp file is staged
* beside the target, and every edit is byte-identical to the same edit on a
* plain file. */
TEST(config_json_like_follows_user_owned_symlink_in_place) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char target[sizeof(fixture.path) + 32U];
char control[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.json", fixture.directory) > 0);
ASSERT(snprintf(control, sizeof(control), "%s/control.json", fixture.directory) > 0);
const char *original = "{\n // dotfiles\n \"mcpServers\": {\n"
" \"keep\": {\"command\": \"x\"}\n }\n}\n";
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(jl_write(control, original), 0);
/* Relative, the way a dotfiles checkout links its files. */
ASSERT_EQ(symlink("target.json", fixture.path), 0);
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
char target_real[CBM_CONFIG_EDIT_PATH_MAX];
ASSERT_EQ(cbm_canonical_path(target, target_real, sizeof(target_real)), 1);
jl_follow_hook_state_t hook_state = {0};
cbm_json_like_set_precommit_hook_for_testing(jl_follow_precommit_hook, &hook_state);
const char *path[] = {"mcpServers"};
int through_rc =
cbm_json_like_upsert_entry(fixture.path, path, 1U, "owned", "{\"command\":\"y\"}");
cbm_json_like_set_precommit_hook_for_testing(NULL, NULL);
int plain_rc = cbm_json_like_upsert_entry(control, path, 1U, "owned", "{\"command\":\"y\"}");
if (through_rc != 0 || plain_rc != 0) {
cbm_config_edit_path_follow_clear();
}
ASSERT_EQ(through_rc, 0);
ASSERT_EQ(plain_rc, 0);
ASSERT_EQ(hook_state.calls, 1U);
ASSERT_STR_EQ(hook_state.seen_path, target_real);
ASSERT_EQ(hook_state.temps_beside_target, 1U);
struct stat link_state;
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
char link_value[256] = {0};
ASSERT(readlink(fixture.path, link_value, sizeof(link_value) - 1U) > 0);
ASSERT_STR_EQ(link_value, "target.json");
char *through = jl_read(target);
char *plain = jl_read(control);
ASSERT_NOT_NULL(through);
ASSERT_NOT_NULL(plain);
ASSERT_STR_EQ(through, plain);
ASSERT_NOT_NULL(strstr(through, "\"owned\""));
ASSERT_NOT_NULL(strstr(through, "// dotfiles"));
/* Reading through the link sees the target, and the compare-and-remove
* form uninstall relies on works through the link as well. */
char *document = NULL;
size_t length = 0U;
ASSERT_EQ(cbm_json_like_read_document(fixture.path, &document, &length), 0);
ASSERT_NOT_NULL(document);
ASSERT_STR_EQ(document, through);
ASSERT_EQ(
cbm_json_like_remove_entry_if_unchanged(fixture.path, path, 1U, "owned", document, length),
0);
ASSERT_EQ(cbm_json_like_remove_entry(control, path, 1U, "owned"), 0);
free(document);
free(through);
free(plain);
through = jl_read(target);
plain = jl_read(control);
ASSERT_NOT_NULL(through);
ASSERT_NOT_NULL(plain);
ASSERT_STR_EQ(through, plain);
ASSERT_NULL(strstr(through, "\"owned\""));
free(through);
free(plain);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(lstat(fixture.path, &link_state), 0);
ASSERT(S_ISLNK(link_state.st_mode));
ASSERT_EQ(jl_stray_temp_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(cbm_unlink(target), 0);
ASSERT_EQ(cbm_unlink(control), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_rejects_hard_link_without_splitting_identity) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
char alias[sizeof(fixture.path) + 32U];
ASSERT(snprintf(alias, sizeof(alias), "%s/alias.json", fixture.directory) > 0);
const char *original = "{\"shared\":true}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
ASSERT_EQ(link(fixture.path, alias), 0);
const char *root[] = {NULL};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true"), -1);
char *content = jl_read(alias);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(cbm_unlink(alias), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_preserves_owner_group_and_mode) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_EQ(jl_write(fixture.path, "{\"keep\":1}\n"), 0);
ASSERT_EQ(chmod(fixture.path, 0640), 0);
struct stat before;
ASSERT_EQ(stat(fixture.path, &before), 0);
const char *root[] = {NULL};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "true"), 0);
struct stat after;
ASSERT_EQ(stat(fixture.path, &after), 0);
ASSERT_EQ(after.st_uid, before.st_uid);
ASSERT_EQ(after.st_gid, before.st_gid);
ASSERT_EQ(after.st_mode & 07777, before.st_mode & 07777);
jl_fixture_close(&fixture);
PASS();
}
#endif
TEST(config_json_like_supports_bom_and_common_json5_whitespace) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "\xEF\xBB\xBF"
"{\x0C"
"theme:'dark',\x0B"
"// retained\xE2\x80\xA8"
"mcp:\xC2\xA0{servers:{}},\xE2\x80\xA9"
"}";
ASSERT_EQ(jl_write(fixture.path, original), 0);
const char *path[] = {"mcp", "servers"};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 2U, "owned", "true"), 0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(memcmp(content, "\xEF\xBB\xBF", 3U) == 0);
ASSERT(strstr(content, "// retained\xE2\x80\xA8") != NULL);
ASSERT(strstr(content, "theme:'dark'") != NULL);
ASSERT(strstr(content, "\"owned\": true") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_rejects_json5_decimal_escapes_byte_identical) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *root[] = {NULL};
ASSERT(jl_failed_unchanged(fixture.path, "{bad:'\\8'}\n", root, 0U, "owned", "true"));
ASSERT(jl_failed_unchanged(fixture.path, "{bad:'\\01'}\n", root, 0U, "owned", "true"));
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_fresh_strict_upsert_replace_remove) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *root[] = {NULL};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "{\"command\":\"cbm\"}"),
0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, "{\n \"owned\": {\"command\":\"cbm\"}\n}\n");
free(content);
ASSERT_EQ(jl_write(fixture.path, "{\"keep\":1,\"owned\":false}\n"), 0);
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "owned", "[1,2]"), 0);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, "{\"keep\":1,\"owned\":[1,2]}\n");
free(content);
ASSERT_EQ(cbm_json_like_remove_entry(fixture.path, root, 0U, "owned"), 0);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "\"keep\":1") != NULL);
ASSERT(strstr(content, "owned") == NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_preserves_jsonc_comments) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\n"
" // user preference\n"
" \"theme\": \"dark\",\n"
" \"mcpServers\": {\n"
" /* retained server */\n"
" \"other\": {\"command\": \"other\"},\n"
" },\n"
"}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
const char *path[] = {"mcpServers"};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 1U, "codebase-memory",
"{\"command\":\"cbm\"}"),
0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "// user preference") != NULL);
ASSERT(strstr(content, "/* retained server */") != NULL);
ASSERT(strstr(content, "\"theme\": \"dark\"") != NULL);
ASSERT(strstr(content, "\"other\": {\"command\": \"other\"}") != NULL);
ASSERT(strstr(content, "\"codebase-memory\": {\"command\":\"cbm\"}") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_openclaw_json5_nested_servers) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original =
"{ theme: 'dark', mcp: { servers: { other: { command: 'other' }, }, }, }";
ASSERT_EQ(jl_write(fixture.path, original), 0);
const char *path[] = {"mcp", "servers"};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 2U, "codebase-memory",
"{\"command\":\"cbm\",\"args\":[]}"),
0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "theme: 'dark'") != NULL);
ASSERT(strstr(content, "other: { command: 'other' }") != NULL);
ASSERT(strstr(content, "\"codebase-memory\"") != NULL);
free(content);
ASSERT_EQ(cbm_json_like_remove_entry(fixture.path, path, 2U, "codebase-memory"), 0);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "codebase-memory") == NULL);
ASSERT(strstr(content, "theme: 'dark'") != NULL);
ASSERT(strstr(content, "other: { command: 'other' }") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_creates_missing_nested_path) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_EQ(jl_write(fixture.path, "{\n \"theme\": \"dark\"\n}\n"), 0);
const char *path[] = {"mcp", "servers"};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 2U, "codebase-memory",
"{\"command\":\"cbm\"}"),
0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "\"theme\": \"dark\"") != NULL);
ASSERT(strstr(content, "\"mcp\": {") != NULL);
ASSERT(strstr(content, "\"servers\": {") != NULL);
ASSERT(strstr(content, "\"codebase-memory\": {\"command\":\"cbm\"}") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_rejects_duplicate_path_byte_identical) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *path[] = {"mcp"};
const char *duplicate = "{\"mcp\":{}, 'mcp':{}}\n";
ASSERT(jl_failed_unchanged(fixture.path, duplicate, path, 1U, "owned", "true"));
const char *root[] = {NULL};
const char *duplicate_entry = "{\"owned\":1, owned:2}\n";
ASSERT(jl_failed_unchanged(fixture.path, duplicate_entry, root, 0U, "owned", "3"));
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_rejects_invalid_and_malformed_byte_identical) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *root[] = {NULL};
ASSERT(jl_failed_unchanged(fixture.path, "{\"keep\":1}\n", root, 0U, "owned", "{\"broken\":}"));
ASSERT(jl_failed_unchanged(fixture.path, "{\"keep\":1}\n", root, 0U, "owned", ".5"));
ASSERT(jl_failed_unchanged(fixture.path, "{\"keep\":1 /* never closes", root, 0U, "owned",
"true"));
ASSERT(jl_failed_unchanged(fixture.path, "{\"keep\":[1,2}\n", root, 0U, "owned", "true"));
const char *path[] = {"mcp"};
ASSERT(jl_failed_unchanged(fixture.path, "{\"mcp\":false}\n", path, 1U, "owned", "true"));
ASSERT_EQ(jl_write(fixture.path, "{\"keep\":1}\n"), 0);
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, root, 0U, "bad\nkey", "true"), -1);
ASSERT_EQ(cbm_json_like_upsert_entry(NULL, root, 0U, "owned", "true"), -1);
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, NULL, 0U, "owned", "true"), -1);
const char *deep_path[64];
for (size_t i = 0; i < sizeof(deep_path) / sizeof(deep_path[0]); i++) {
deep_path[i] = "level";
}
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, deep_path, 64U, "owned", "true"), -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, "{\"keep\":1}\n");
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_upsert_is_byte_idempotent) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_EQ(jl_write(fixture.path, "{\n \"mcp\": {}\n}\n"), 0);
const char *path[] = {"mcp"};
const char *value = "{\"command\":\"cbm\",\"args\":[\"serve\"]}";
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 1U, "owned", value), 0);
char *first = jl_read(fixture.path);
ASSERT_NOT_NULL(first);
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 1U, "owned", value), 0);
char *second = jl_read(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, first);
free(first);
free(second);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_ignores_braces_and_comments_inside_strings) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\"note\":\"} // text /* text */ \\\" {\",\"mcp\":{\"servers\":{}}}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
const char *path[] = {"mcp", "servers"};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 2U, "owned", "{\"command\":\"cbm\"}"),
0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "} // text /* text */ \\\" {") != NULL);
ASSERT(strstr(content, "\"owned\": {\"command\":\"cbm\"}") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_handles_trailing_commas) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\n mcp: {\n servers: {\n other: [1, 2,],\n },\n },\n}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
const char *path[] = {"mcp", "servers"};
ASSERT_EQ(cbm_json_like_upsert_entry(fixture.path, path, 2U, "owned", "true"), 0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "other: [1, 2,]") != NULL);
ASSERT(strstr(content, "\"owned\": true,") != NULL);
free(content);
ASSERT_EQ(cbm_json_like_remove_entry(fixture.path, path, 2U, "owned"), 0);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "owned") == NULL);
ASSERT(strstr(content, "other: [1, 2,]") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_removes_first_middle_last_and_only) {
static const char *inputs[] = {
"{\"owned\":1,\"b\":2,\"c\":3}",
"{\"a\":1,\"owned\":2,\"c\":3}",
"{\"a\":1,\"b\":2,\"owned\":3}",
"{\"owned\":1}",
};
static const char *required[] = {"\"b\":2", "\"a\":1", "\"b\":2", "{"};
const char *root[] = {NULL};
for (size_t i = 0; i < sizeof(inputs) / sizeof(inputs[0]); i++) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_EQ(jl_write(fixture.path, inputs[i]), 0);
ASSERT_EQ(cbm_json_like_remove_entry(fixture.path, root, 0U, "owned"), 0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "owned") == NULL);
ASSERT(strstr(content, required[i]) != NULL);
ASSERT_EQ(cbm_json_like_remove_entry(fixture.path, root, 0U, "owned"), 0);
free(content);
jl_fixture_close(&fixture);
}
PASS();
}
TEST(config_json_like_removal_preserves_comments_and_siblings) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original =
"{\n \"first\": 1,\n /* keep this comment */\n \"owned\": 2, // keep this too\n"
" \"last\": 3\n}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
const char *root[] = {NULL};
ASSERT_EQ(cbm_json_like_remove_entry(fixture.path, root, 0U, "owned"), 0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "owned") == NULL);
ASSERT(strstr(content, "/* keep this comment */") != NULL);
ASSERT(strstr(content, "// keep this too") != NULL);
ASSERT(strstr(content, "\"first\": 1") != NULL);
ASSERT(strstr(content, "\"last\": 3") != NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_top_level_array_unique_string) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\n"
" theme: 'dark',\n"
" instructions: [\n"
" '~/rules/other.md',\n"
" // keep this user comment\n"
" ],\n"
"}\n";
const char *owned = "~/.config/kilo/rules/codebase-memory-mcp.md";
ASSERT_EQ(jl_write(fixture.path, original), 0);
ASSERT_EQ(cbm_json_like_add_unique_string(fixture.path, "instructions", owned), 0);
char *first = jl_read(fixture.path);
ASSERT_NOT_NULL(first);
ASSERT(strstr(first, "// keep this user comment") != NULL);
ASSERT(strstr(first, "'~/rules/other.md'") != NULL);
ASSERT_EQ(jl_occurrences(first, owned), 1U);
ASSERT_EQ(cbm_json_like_add_unique_string(fixture.path, "instructions", owned), 0);
char *second = jl_read(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, first);
free(first);
free(second);
ASSERT_EQ(cbm_json_like_remove_string(fixture.path, "instructions", owned), 0);
char *removed = jl_read(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT(strstr(removed, owned) == NULL);
ASSERT(strstr(removed, "// keep this user comment") != NULL);
ASSERT(strstr(removed, "'~/rules/other.md'") != NULL);
free(removed);
jl_fixture_close(&fixture);
PASS();
}
/* OpenHands profiles ship `"mcp_server_refs": null` (#1826). A null value is
* the documented "no list yet" shape, so adding a unique string must replace
* exactly that token with a one-element array, preserving everything around
* it, and stay idempotent. Removal leaves the minimal edit: an empty array. */
TEST(config_json_like_array_add_replaces_null_value_issue1826) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\n"
" \"name\": \"default\",\n"
" \"mcp_server_refs\": null, // keep this user comment\n"
" \"tools\": [\"bash\"]\n"
"}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
ASSERT_EQ(
cbm_json_like_add_unique_string(fixture.path, "mcp_server_refs", "codebase-memory-mcp"), 0);
char *first = jl_read(fixture.path);
ASSERT_NOT_NULL(first);
ASSERT_STR_EQ(first,
"{\n"
" \"name\": \"default\",\n"
" \"mcp_server_refs\": [\"codebase-memory-mcp\"], // keep this user comment\n"
" \"tools\": [\"bash\"]\n"
"}\n");
ASSERT_EQ(
cbm_json_like_add_unique_string(fixture.path, "mcp_server_refs", "codebase-memory-mcp"), 0);
char *second = jl_read(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, first);
free(first);
free(second);
ASSERT_EQ(cbm_json_like_remove_string(fixture.path, "mcp_server_refs", "codebase-memory-mcp"),
0);
char *removed = jl_read(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT_STR_EQ(removed, "{\n"
" \"name\": \"default\",\n"
" \"mcp_server_refs\": [], // keep this user comment\n"
" \"tools\": [\"bash\"]\n"
"}\n");
free(removed);
/* Only the literal null converts; any other non-array value still fails
* closed byte-identically. */
const char *wrong_type = "{\"mcp_server_refs\": \"null\", \"keep\": true}\n";
ASSERT_EQ(jl_write(fixture.path, wrong_type), 0);
ASSERT_EQ(cbm_json_like_add_unique_string(fixture.path, "mcp_server_refs", "x"), -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, wrong_type);
free(content);
jl_fixture_close(&fixture);
PASS();
}
/* The OpenHands settings entry carries `enabled: true` (#1826). A literal shape
* lets ownership matching require that token exactly, so uninstall recognises
* our own entry instead of treating the boolean as a foreign annotation. */
static int jl_match_openhands_entry(const char *document,
const cbm_json_like_object_field_t *fields, size_t field_count,
char **captured) {
static const char *const path[] = {"mcp_config"};
return cbm_json_like_match_object_entry(document, strlen(document), path, 1U,
"codebase-memory-mcp", fields, field_count, captured);
}
TEST(config_json_like_match_object_entry_literal_shape_issue1826) {
const cbm_json_like_object_field_t fields[] = {
{.key = "command",
.shape = CBM_JSON_LIKE_VALUE_STRING,
.expected_string = NULL,
.flags = CBM_JSON_LIKE_FIELD_REQUIRED | CBM_JSON_LIKE_FIELD_CAPTURE_STRING},
{.key = "transport",
.shape = CBM_JSON_LIKE_VALUE_STRING,
.expected_string = "stdio",
.flags = CBM_JSON_LIKE_FIELD_REQUIRED},
{.key = "enabled",
.shape = CBM_JSON_LIKE_VALUE_LITERAL,
.expected_string = "true",
.flags = CBM_JSON_LIKE_FIELD_REQUIRED},
};
char *captured = NULL;
const char *ours = "{\"mcp_config\": {\"codebase-memory-mcp\": {\"transport\": \"stdio\", "
"\"command\": \"/x/cbm\", \"enabled\": true}}}\n";
ASSERT_EQ(jl_match_openhands_entry(ours, fields, 3U, &captured), CBM_JSON_LIKE_OBJECT_MATCH);
ASSERT_NOT_NULL(captured);
ASSERT_STR_EQ(captured, "/x/cbm");
free(captured);
/* Trivia around the token is not part of the value. */
const char *spaced = "{\"mcp_config\": {\"codebase-memory-mcp\": {\"transport\": \"stdio\", "
"\"command\": \"/x/cbm\", \"enabled\" : true /* on */ }}}\n";
ASSERT_EQ(jl_match_openhands_entry(spaced, fields, 3U, &captured), CBM_JSON_LIKE_OBJECT_MATCH);
free(captured);
const char *disabled = "{\"mcp_config\": {\"codebase-memory-mcp\": {\"transport\": \"stdio\", "
"\"command\": \"/x/cbm\", \"enabled\": false}}}\n";
ASSERT_EQ(jl_match_openhands_entry(disabled, fields, 3U, &captured),
CBM_JSON_LIKE_OBJECT_MISMATCH);
ASSERT_NULL(captured);
const char *quoted = "{\"mcp_config\": {\"codebase-memory-mcp\": {\"transport\": \"stdio\", "
"\"command\": \"/x/cbm\", \"enabled\": \"true\"}}}\n";
ASSERT_EQ(jl_match_openhands_entry(quoted, fields, 3U, &captured),
CBM_JSON_LIKE_OBJECT_MISMATCH);
ASSERT_NULL(captured);
const char *missing = "{\"mcp_config\": {\"codebase-memory-mcp\": {\"transport\": \"stdio\", "
"\"command\": \"/x/cbm\"}}}\n";
ASSERT_EQ(jl_match_openhands_entry(missing, fields, 3U, &captured),
CBM_JSON_LIKE_OBJECT_MISMATCH);
ASSERT_NULL(captured);
/* A literal field can neither lack its token nor be captured. */
const cbm_json_like_object_field_t no_token[] = {
fields[0],
{.key = "enabled",
.shape = CBM_JSON_LIKE_VALUE_LITERAL,
.expected_string = NULL,
.flags = CBM_JSON_LIKE_FIELD_REQUIRED},
};
ASSERT_EQ(jl_match_openhands_entry(ours, no_token, 2U, &captured), -1);
const cbm_json_like_object_field_t captured_literal[] = {
{.key = "enabled",
.shape = CBM_JSON_LIKE_VALUE_LITERAL,
.expected_string = "true",
.flags = CBM_JSON_LIKE_FIELD_REQUIRED | CBM_JSON_LIKE_FIELD_CAPTURE_STRING},
};
ASSERT_EQ(jl_match_openhands_entry(ours, captured_literal, 1U, &captured), -1);
PASS();
}
/* An OpenHands profile with an existing single-line list (#1826): the added
* name follows the list's own spacing, and removing it again restores the
* original bytes exactly — no stray space before the closing bracket. */
static bool jl_round_trips(jl_fixture_t *fixture, const char *original, const char *expected) {
if (jl_write(fixture->path, original) != 0 ||
cbm_json_like_add_unique_string(fixture->path, "mcp_server_refs", "codebase-memory-mcp") !=
0) {
return false;
}
char *added = jl_read(fixture->path);
bool ok = added && strcmp(added, expected) == 0;
free(added);
if (!ok ||
cbm_json_like_remove_string(fixture->path, "mcp_server_refs", "codebase-memory-mcp") != 0) {
return false;
}
char *restored = jl_read(fixture->path);
ok = restored && strcmp(restored, original) == 0;
free(restored);
return ok;
}
TEST(config_json_like_single_line_array_round_trips_byte_for_byte_issue1826) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
ASSERT_TRUE(jl_round_trips(
&fixture, "{\"name\": \"custom\", \"mcp_server_refs\": [\"other-mcp\"]}\n",
"{\"name\": \"custom\", \"mcp_server_refs\": [\"other-mcp\", \"codebase-memory-mcp\"]}\n"));
ASSERT_TRUE(
jl_round_trips(&fixture, "{ \"mcp_server_refs\": [ \"other-mcp\" ] }\n",
"{ \"mcp_server_refs\": [ \"other-mcp\", \"codebase-memory-mcp\" ] }\n"));
ASSERT_TRUE(jl_round_trips(&fixture, "{\"mcp_server_refs\": [ ]}\n",
"{\"mcp_server_refs\": [ \"codebase-memory-mcp\" ]}\n"));
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_top_level_array_create_escape_and_fail_closed) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *owned = "C:\\rules\\\"main\".md";
ASSERT_EQ(cbm_json_like_add_unique_string(fixture.path, "instructions", owned), 0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "\"instructions\": [") != NULL);
ASSERT(strstr(content, "C:") != NULL);
ASSERT(strstr(content, "rules") != NULL);
free(content);
ASSERT_EQ(cbm_json_like_remove_string(fixture.path, "instructions", owned), 0);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "rules") == NULL);
free(content);
const char *duplicate = "{instructions:['owned', \"owned\",], keep:true}\n";
ASSERT_EQ(jl_write(fixture.path, duplicate), 0);
ASSERT_EQ(cbm_json_like_add_unique_string(fixture.path, "instructions", "owned"), -1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, duplicate);
free(content);
ASSERT_EQ(cbm_json_like_remove_string(fixture.path, "instructions", "owned"), -1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, duplicate);
free(content);
const char *wrong_type = "{instructions:false, keep:true}\n";
ASSERT_EQ(jl_write(fixture.path, wrong_type), 0);
ASSERT_EQ(cbm_json_like_add_unique_string(fixture.path, "instructions", "owned"), -1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, wrong_type);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_nested_array_creates_missing_path_and_escapes) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *path[] = {"agents", "defaults", "compaction"};
const char *owned = "Session \"memory\"\\path\nsection";
ASSERT_EQ(cbm_json_like_add_unique_string_at_path(fixture.path, path, 3U,
"postCompactionSections", owned),
0);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "\"agents\": {") != NULL);
ASSERT(strstr(content, "\"defaults\": {") != NULL);
ASSERT(strstr(content, "\"compaction\": {") != NULL);
ASSERT(strstr(content, "\"postCompactionSections\": [") != NULL);
ASSERT(strstr(content, "Session \\\"memory\\\"\\\\path\\nsection") != NULL);
free(content);
ASSERT_EQ(cbm_json_like_remove_string_at_path(fixture.path, path, 3U, "postCompactionSections",
owned),
0);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT(strstr(content, "postCompactionSections") != NULL);
ASSERT(strstr(content, "Session") == NULL);
free(content);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_nested_array_preserves_jsonc_and_is_idempotent) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *original = "{\n"
" // retain root comment\n"
" theme: 'dark',\n"
" options: {\n"
" keep: true,\n"
" context_paths: [\n"
" 'memory.md.bak',\n"
" // retain array comment\n"
" ],\n"
" },\n"
"}\n";
const char *path[] = {"options"};
const char *owned = "memory.md";
ASSERT_EQ(jl_write(fixture.path, original), 0);
ASSERT_EQ(
cbm_json_like_add_unique_string_at_path(fixture.path, path, 1U, "context_paths", owned), 0);
char *first = jl_read(fixture.path);
ASSERT_NOT_NULL(first);
ASSERT(strstr(first, "// retain root comment") != NULL);
ASSERT(strstr(first, "// retain array comment") != NULL);
ASSERT(strstr(first, "theme: 'dark'") != NULL);
ASSERT(strstr(first, "keep: true") != NULL);
ASSERT(strstr(first, "'memory.md.bak',") != NULL);
ASSERT_EQ(jl_occurrences(first, "\"memory.md\""), 1U);
ASSERT_EQ(
cbm_json_like_add_unique_string_at_path(fixture.path, path, 1U, "context_paths", owned), 0);
char *second = jl_read(fixture.path);
ASSERT_NOT_NULL(second);
ASSERT_STR_EQ(second, first);
free(first);
free(second);
ASSERT_EQ(cbm_json_like_remove_string_at_path(fixture.path, path, 1U, "context_paths", owned),
0);
char *removed = jl_read(fixture.path);
ASSERT_NOT_NULL(removed);
ASSERT(strstr(removed, "\"memory.md\"") == NULL);
ASSERT(strstr(removed, "'memory.md.bak'") != NULL);
ASSERT(strstr(removed, "// retain array comment") != NULL);
ASSERT(strstr(removed, "keep: true") != NULL);
free(removed);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_nested_array_fails_closed_on_ambiguous_or_invalid_paths) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *path[] = {"options"};
const char *wrong_type = "{options:false, keep:true}\n";
ASSERT_EQ(jl_write(fixture.path, wrong_type), 0);
ASSERT_EQ(
cbm_json_like_add_unique_string_at_path(fixture.path, path, 1U, "context_paths", "owned"),
-1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, wrong_type);
free(content);
const char *duplicate_path = "{options:{context_paths:[]}, options:{keep:true}}\n";
ASSERT_EQ(jl_write(fixture.path, duplicate_path), 0);
ASSERT_EQ(
cbm_json_like_add_unique_string_at_path(fixture.path, path, 1U, "context_paths", "owned"),
-1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, duplicate_path);
free(content);
const char *duplicate_array =
"{options:{context_paths:['owned'], context_paths:[]}, keep:true}\n";
ASSERT_EQ(jl_write(fixture.path, duplicate_array), 0);
ASSERT_EQ(cbm_json_like_remove_string_at_path(fixture.path, path, 1U, "context_paths", "owned"),
-1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, duplicate_array);
free(content);
ASSERT_EQ(
cbm_json_like_add_unique_string_at_path(fixture.path, NULL, 1U, "context_paths", "owned"),
-1);
const char *null_path[] = {NULL};
ASSERT_EQ(cbm_json_like_add_unique_string_at_path(fixture.path, null_path, 1U, "context_paths",
"owned"),
-1);
const char *control_path[] = {"bad\npath"};
ASSERT_EQ(cbm_json_like_remove_string_at_path(fixture.path, control_path, 1U, "context_paths",
"owned"),
-1);
const char *deep_path[64];
for (size_t i = 0; i < sizeof(deep_path) / sizeof(deep_path[0]); i++) {
deep_path[i] = "level";
}
ASSERT_EQ(cbm_json_like_add_unique_string_at_path(fixture.path, deep_path, 64U, "context_paths",
"owned"),
-1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, duplicate_array);
free(content);
jl_fixture_close(&fixture);
PASS();
}
#ifndef _WIN32
TEST(config_json_like_nested_array_rejects_foreign_symlink_and_hardlink) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *path[] = {"options"};
const char *original = "{options:{context_paths:[]}}\n";
char target[sizeof(fixture.path) + 32U];
ASSERT(snprintf(target, sizeof(target), "%s/target.json", fixture.directory) > 0);
ASSERT_EQ(jl_write(target, original), 0);
ASSERT_EQ(symlink(target, fixture.path), 0);
/* Foreign-owned link (observer moved by the test seam): still refused. */
ASSERT_EQ(cbm_config_edit_path_follow_add_root(fixture.directory), 0);
cbm_config_edit_path_set_invoking_uid_for_test((unsigned)geteuid() + 1U, 1);
int add_rc =
cbm_json_like_add_unique_string_at_path(fixture.path, path, 1U, "context_paths", "owned");
char *workspace = (char *)(uintptr_t)1U;
int get_rc =
cbm_json_like_get_string_at_path(fixture.path, path, 1U, "context_paths", &workspace);
cbm_config_edit_path_set_invoking_uid_for_test(0U, 0);
cbm_config_edit_path_follow_clear();
ASSERT_EQ(add_rc, -1);
ASSERT_EQ(get_rc, -1);
ASSERT_NULL(workspace);
char *content = jl_read(target);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(cbm_unlink(fixture.path), 0);
ASSERT_EQ(jl_write(fixture.path, original), 0);
char alias[sizeof(fixture.path) + 32U];
ASSERT(snprintf(alias, sizeof(alias), "%s/alias.json", fixture.directory) > 0);
ASSERT_EQ(link(fixture.path, alias), 0);
ASSERT_EQ(cbm_json_like_remove_string_at_path(fixture.path, path, 1U, "context_paths", "owned"),
-1);
workspace = (char *)(uintptr_t)1U;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 1U, "context_paths", &workspace),
-1);
ASSERT_NULL(workspace);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(cbm_unlink(alias), 0);
ASSERT_EQ(cbm_unlink(target), 0);
jl_fixture_close(&fixture);
PASS();
}
#endif
TEST(config_json_like_nested_array_rejects_precommit_content_and_identity_races) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *path[] = {"options"};
const char *original = "{options:{context_paths:['owned']}, keep:true}\n";
ASSERT_EQ(jl_write(fixture.path, original), 0);
jl_precommit_change_t content_change = {
.content = "{concurrent:true}\n",
.backup_path = NULL,
.replace_identity = false,
.result = -1,
};
cbm_json_like_set_precommit_hook_for_testing(jl_change_before_commit, &content_change);
int result =
cbm_json_like_add_unique_string_at_path(fixture.path, path, 1U, "context_paths", "new");
cbm_json_like_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(content_change.result, 0);
ASSERT_EQ(result, -1);
char *content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, "{concurrent:true}\n");
free(content);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
ASSERT_EQ(jl_write(fixture.path, original), 0);
char backup[sizeof(fixture.path) + 32U];
ASSERT(snprintf(backup, sizeof(backup), "%s/original.json", fixture.directory) > 0);
jl_precommit_change_t identity_change = {
.content = original,
.backup_path = backup,
.replace_identity = true,
.result = -1,
};
cbm_json_like_set_precommit_hook_for_testing(jl_change_before_commit, &identity_change);
result = cbm_json_like_remove_string_at_path(fixture.path, path, 1U, "context_paths", "owned");
cbm_json_like_set_precommit_hook_for_testing(NULL, NULL);
ASSERT_EQ(identity_change.result, 0);
ASSERT_EQ(result, -1);
content = jl_read(fixture.path);
ASSERT_NOT_NULL(content);
ASSERT_STR_EQ(content, original);
free(content);
ASSERT_EQ(jl_temp_file_count(&fixture), 0U);
ASSERT_EQ(cbm_unlink(backup), 0);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_nested_string_lookup_decodes_json5_workspace) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *document =
"{agents:{defaults:{workspace:'~\\/Open\\x43law\\u0020work\\\'space'}}}\n";
const char *path[] = {"agents", "defaults"};
ASSERT_EQ(jl_write(fixture.path, document), 0);
char *workspace = NULL;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 2U, "workspace", &workspace), 0);
ASSERT_NOT_NULL(workspace);
ASSERT_STR_EQ(workspace, "~/OpenClaw work'space");
free(workspace);
jl_fixture_close(&fixture);
PASS();
}
TEST(config_json_like_nested_string_lookup_distinguishes_missing_and_fails_closed) {
jl_fixture_t fixture;
ASSERT_EQ(jl_fixture_open(&fixture), 0);
const char *path[] = {"agents", "defaults"};
char *workspace = (char *)(uintptr_t)1U;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 2U, "workspace", &workspace), 1);
ASSERT_NULL(workspace);
const char *missing = "{agents:{defaults:{keep:true}}}\n";
ASSERT_EQ(jl_write(fixture.path, missing), 0);
workspace = (char *)(uintptr_t)1U;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 2U, "workspace", &workspace), 1);
ASSERT_NULL(workspace);
const char *non_string = "{agents:{defaults:{workspace:false}}}\n";
ASSERT_EQ(jl_write(fixture.path, non_string), 0);
workspace = (char *)(uintptr_t)1U;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 2U, "workspace", &workspace),
-1);
ASSERT_NULL(workspace);
const char *duplicate_key = "{agents:{defaults:{workspace:'one', workspace:'two'}}}\n";
ASSERT_EQ(jl_write(fixture.path, duplicate_key), 0);
workspace = (char *)(uintptr_t)1U;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 2U, "workspace", &workspace),
-1);
ASSERT_NULL(workspace);
const char *duplicate_path =
"{agents:{defaults:{workspace:'one'}}, agents:{defaults:{workspace:'two'}}}\n";
ASSERT_EQ(jl_write(fixture.path, duplicate_path), 0);
workspace = (char *)(uintptr_t)1U;
ASSERT_EQ(cbm_json_like_get_string_at_path(fixture.path, path, 2U, "workspace", &workspace),
-1);
ASSERT_NULL(workspace);
jl_fixture_close(&fixture);
PASS();
}
SUITE(config_json_like) {
RUN_TEST(config_json_like_rejects_stale_content_and_cleans_temp);
RUN_TEST(config_json_like_rejects_stale_identity_with_same_content);
RUN_TEST(config_json_like_missing_target_race_does_not_replace_winner);
RUN_TEST(config_json_like_existing_target_swap_after_check_preserves_winner);
RUN_TEST(config_json_like_rejects_non_regular_path);
#ifndef _WIN32
RUN_TEST(config_json_like_rejects_foreign_owned_symlink_without_touching_target);
RUN_TEST(config_json_like_root_refuses_user_owned_symlink);
RUN_TEST(config_json_like_refuses_dangling_symlink);
RUN_TEST(config_json_like_refuses_symlink_to_directory);
RUN_TEST(config_json_like_refuses_symlink_without_opt_in);
RUN_TEST(config_json_like_refuses_symlink_when_target_identity_skews);
RUN_TEST(config_json_like_refuses_symlink_with_world_writable_parent);
RUN_TEST(config_json_like_follows_user_owned_symlink_in_place);
RUN_TEST(config_json_like_rejects_hard_link_without_splitting_identity);
RUN_TEST(config_json_like_preserves_owner_group_and_mode);
#endif
RUN_TEST(config_json_like_supports_bom_and_common_json5_whitespace);
RUN_TEST(config_json_like_rejects_json5_decimal_escapes_byte_identical);
RUN_TEST(config_json_like_fresh_strict_upsert_replace_remove);
RUN_TEST(config_json_like_preserves_jsonc_comments);
RUN_TEST(config_json_like_openclaw_json5_nested_servers);
RUN_TEST(config_json_like_creates_missing_nested_path);
RUN_TEST(config_json_like_rejects_duplicate_path_byte_identical);
RUN_TEST(config_json_like_rejects_invalid_and_malformed_byte_identical);
RUN_TEST(config_json_like_upsert_is_byte_idempotent);
RUN_TEST(config_json_like_ignores_braces_and_comments_inside_strings);
RUN_TEST(config_json_like_handles_trailing_commas);
RUN_TEST(config_json_like_removes_first_middle_last_and_only);
RUN_TEST(config_json_like_removal_preserves_comments_and_siblings);
RUN_TEST(config_json_like_top_level_array_unique_string);
RUN_TEST(config_json_like_array_add_replaces_null_value_issue1826);
RUN_TEST(config_json_like_match_object_entry_literal_shape_issue1826);
RUN_TEST(config_json_like_single_line_array_round_trips_byte_for_byte_issue1826);
RUN_TEST(config_json_like_top_level_array_create_escape_and_fail_closed);
RUN_TEST(config_json_like_nested_array_creates_missing_path_and_escapes);
RUN_TEST(config_json_like_nested_array_preserves_jsonc_and_is_idempotent);
RUN_TEST(config_json_like_nested_array_fails_closed_on_ambiguous_or_invalid_paths);
#ifndef _WIN32
RUN_TEST(config_json_like_nested_array_rejects_foreign_symlink_and_hardlink);
#endif
RUN_TEST(config_json_like_nested_array_rejects_precommit_content_and_identity_races);
RUN_TEST(config_json_like_nested_string_lookup_decodes_json5_workspace);
RUN_TEST(config_json_like_nested_string_lookup_distinguishes_missing_and_fails_closed);
}