Skip to content

Commit 2cc80f2

Browse files
authored
Merge pull request #1204 from DavidMusk93/deleted-self-exec-fix
fix(mcp): survive atomic binary replacement
2 parents b1d9dba + 5b96b06 commit 2cc80f2

3 files changed

Lines changed: 174 additions & 1 deletion

File tree

‎src/ui/http_server.c‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1052,15 +1052,50 @@ bool cbm_http_server_resolve_binary_path(const char *argv0, char *out, size_t ou
10521052
}
10531053
#endif
10541054

1055+
#ifndef _WIN32
1056+
/* #1204: never hand back a self-resolved path we have not confirmed is
1057+
* executable. After an installer's atomic rename-over, the resolved image
1058+
* path no longer exists (Linux readlink reads "<path> (deleted)"), and an
1059+
* unvalidated return turns into a doomed worker spawn (ENOENT). */
1060+
if (resolve_self_executable(out, outsz)) {
1061+
if (is_executable_file(out)) {
1062+
return true;
1063+
}
1064+
#if defined(__linux__)
1065+
/* Deleted image: the /proc/self/exe magic link still executes the
1066+
* in-memory OLD build — the only spawn that satisfies the worker's
1067+
* build-fingerprint gate (a rename-over leaves the on-disk path
1068+
* holding a DIFFERENT build). Hand back the link, not the stale
1069+
* path. Deliberately NOT the saved launch path: preferring it swaps
1070+
* an ENOENT for a fingerprint refusal. */
1071+
return copy_path(out, outsz, "/proc/self/exe");
1072+
#else
1073+
/* macOS has no magic link. Fail closed: the supervisor logs
1074+
* index.supervisor.no_self_path and indexing resumes after a daemon
1075+
* restart, instead of spawning a missing or mismatched binary. */
1076+
out[0] = '\0';
1077+
return false;
1078+
#endif
1079+
}
1080+
#else
10551081
if (resolve_self_executable(out, outsz)) {
10561082
return true;
10571083
}
1084+
#endif
10581085
return copy_path(out, outsz, argv0);
10591086
}
10601087

10611088
void cbm_http_server_set_binary_path(const char *path) {
10621089
if (path) {
1063-
if (!cbm_http_server_resolve_binary_path(path, g_binary_path, sizeof(g_binary_path))) {
1090+
/* Resolve into a local buffer first: `path` may alias g_binary_path
1091+
* on a repeated call. Resolving in place first hits resolve's leading
1092+
* out[0]='\0', which zeroes the shared buffer and silently discards
1093+
* the very path we were asked to re-resolve — and the in-place write
1094+
* is one reset-reorder away from an overlapping snprintf. */
1095+
char resolved[sizeof(g_binary_path)];
1096+
if (cbm_http_server_resolve_binary_path(path, resolved, sizeof(resolved))) {
1097+
snprintf(g_binary_path, sizeof(g_binary_path), "%s", resolved);
1098+
} else {
10641099
g_binary_path[0] = '\0';
10651100
}
10661101
}

‎tests/test_httpd.c‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
* RPC dispatch, transport limits, receive deadline, clean shutdown.
1212
*/
1313
#include "../src/foundation/compat.h"
14+
#if defined(__APPLE__)
15+
#include <mach-o/dyld.h> /* _NSGetExecutablePath — deleted-self driver */
16+
#endif
1417
#include "../src/foundation/compat_fs.h"
1518
#include "../src/foundation/compat_thread.h"
1619
#include "../src/foundation/log.h"
@@ -457,6 +460,86 @@ TEST(httpd_resolves_bare_binary_path_from_path) {
457460
#endif
458461
}
459462

463+
TEST(httpd_deleted_self_spawn_path_follows_platform_ruling) {
464+
#if defined(__linux__) || defined(__APPLE__)
465+
char tmpdir[256];
466+
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_httpd_deleted_self_XXXXXX");
467+
ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir));
468+
469+
char current[1024];
470+
#if defined(__linux__)
471+
ssize_t current_len = readlink("/proc/self/exe", current, sizeof(current) - 1);
472+
ASSERT_GT(current_len, 0);
473+
ASSERT_LT(current_len, (ssize_t)(sizeof(current) - 1));
474+
current[current_len] = '\0';
475+
#else
476+
uint32_t current_sz = sizeof(current);
477+
ASSERT_EQ(_NSGetExecutablePath(current, &current_sz), 0);
478+
#endif
479+
480+
char launch_path[512];
481+
char replacement_path[512];
482+
snprintf(launch_path, sizeof(launch_path), "%s/test-runner", tmpdir);
483+
snprintf(replacement_path, sizeof(replacement_path), "%s/test-runner.new", tmpdir);
484+
ASSERT_EQ(cbm_copy_file(current, launch_path), 0);
485+
ASSERT_EQ(chmod(launch_path, 0755), 0);
486+
487+
int ready_pipe[2];
488+
int continue_pipe[2];
489+
ASSERT_EQ(pipe(ready_pipe), 0);
490+
ASSERT_EQ(pipe(continue_pipe), 0);
491+
492+
pid_t child = fork();
493+
if (child == 0) {
494+
close(ready_pipe[0]);
495+
close(continue_pipe[1]);
496+
char ready_fd[32];
497+
char continue_fd[32];
498+
snprintf(ready_fd, sizeof(ready_fd), "%d", ready_pipe[1]);
499+
snprintf(continue_fd, sizeof(continue_fd), "%d", continue_pipe[0]);
500+
execl(launch_path, launch_path, "__cbm_deleted_self_probe", ready_fd, continue_fd,
501+
launch_path, (char *)NULL);
502+
_exit(127);
503+
}
504+
ASSERT_GT(child, 0);
505+
506+
close(ready_pipe[1]);
507+
close(continue_pipe[0]);
508+
char ready = '\0';
509+
ASSERT_EQ(read(ready_pipe[0], &ready, 1), 1);
510+
ASSERT_EQ(ready, 'R');
511+
512+
#if defined(__linux__)
513+
/* Atomic rename-over: the installer's real move. /proc/self/exe now reads
514+
* "(deleted)" in the child, which is the state the magic-link contract
515+
* covers. */
516+
ASSERT_EQ(cbm_copy_file(current, replacement_path), 0);
517+
ASSERT_EQ(chmod(replacement_path, 0755), 0);
518+
ASSERT_EQ(rename(replacement_path, launch_path), 0);
519+
#else
520+
/* macOS: a rename-over leaves a VALID executable at the launch path, which
521+
* resolve is right to return (the worker's build-fingerprint gate is the
522+
* layer that refuses a mismatched build — covered by its own tests). The
523+
* fail-closed branch guards the image-GONE case, so remove the image. */
524+
(void)replacement_path;
525+
ASSERT_EQ(unlink(launch_path), 0);
526+
#endif
527+
ASSERT_EQ(write(continue_pipe[1], "G", 1), 1);
528+
close(ready_pipe[0]);
529+
close(continue_pipe[1]);
530+
531+
int status = 0;
532+
ASSERT_EQ(waitpid(child, &status, 0), child);
533+
ASSERT_TRUE(WIFEXITED(status));
534+
ASSERT_EQ(WEXITSTATUS(status), 0);
535+
unlink(launch_path);
536+
rmdir(tmpdir);
537+
PASS();
538+
#else
539+
PASS();
540+
#endif
541+
}
542+
460543
/* ── Transport integration (listener only) ────────────────────── */
461544

462545
TEST(httpd_listen_ephemeral_port) {
@@ -2278,6 +2361,7 @@ SUITE(httpd) {
22782361
RUN_TEST(httpd_query_param_edge_cases);
22792362
RUN_TEST(httpd_path_match_matrix);
22802363
RUN_TEST(httpd_resolves_bare_binary_path_from_path);
2364+
RUN_TEST(httpd_deleted_self_spawn_path_follows_platform_ruling);
22812365
RUN_TEST(repo_info_web_base_normalizes_to_https);
22822366
RUN_TEST(repo_info_strips_credentials_from_remote);
22832367

‎tests/test_main.c‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ int tf_skip_count = 0;
2222
#include "daemon/version_cohort.h" /* Windows crash-turnover re-exec probe */
2323
#include "mcp/index_supervisor.h" /* cbm_index_set_worker_role */
2424
#include "mcp/mcp.h" /* cbm_mcp_handle_tool — act as a real worker */
25+
#include "ui/http_server.h" /* deleted-self executable probe */
2526
#include <sqlite3.h>
2627
#include <errno.h>
2728
#include <stdbool.h>
@@ -617,6 +618,55 @@ static int tf_maybe_run_mcp_idxfailclosed_probe(int argc, char **argv) {
617618
#endif
618619
}
619620

621+
static int tf_maybe_run_deleted_self_probe(int argc, char **argv) {
622+
#if defined(__linux__) || defined(__APPLE__)
623+
if (argc != 5 || strcmp(argv[1], "__cbm_deleted_self_probe") != 0) {
624+
return -1;
625+
}
626+
int ready_fd = atoi(argv[2]);
627+
int continue_fd = atoi(argv[3]);
628+
cbm_http_server_set_binary_path(argv[4]);
629+
if (write(ready_fd, "R", 1) != 1) {
630+
return 41;
631+
}
632+
char go = '\0';
633+
if (read(continue_fd, &go, 1) != 1) {
634+
return 42;
635+
}
636+
char resolved[1024];
637+
bool ok = cbm_http_server_resolve_binary_path(NULL, resolved, sizeof(resolved));
638+
#if defined(__linux__)
639+
/* Contract (#1204 strategy ruling): after a rename-over, the resolver
640+
* hands back the /proc/self/exe magic link — the in-memory OLD build,
641+
* the only spawn the worker's build-fingerprint gate accepts. First
642+
* prove we really are in the deleted state, or the assertions below
643+
* would pass vacuously on an intact image. */
644+
char link_target[1024];
645+
ssize_t n = readlink("/proc/self/exe", link_target, sizeof(link_target) - 1);
646+
if (n <= 0) {
647+
return 45;
648+
}
649+
link_target[n] = '\0';
650+
if (strstr(link_target, " (deleted)") == NULL) {
651+
return 46;
652+
}
653+
if (!ok) {
654+
return 43;
655+
}
656+
return strcmp(resolved, "/proc/self/exe") == 0 && access(resolved, X_OK) == 0 ? 0 : 44;
657+
#else
658+
/* macOS has no magic link: the ruling is fail-closed. Success here is
659+
* the resolver REFUSING, so the supervisor logs no_self_path instead of
660+
* spawning a missing or mismatched binary. */
661+
return ok ? 44 : 0;
662+
#endif
663+
#else
664+
(void)argc;
665+
(void)argv;
666+
return -1;
667+
#endif
668+
}
669+
620670
static int g_suite_argc = 0;
621671
static char **g_suite_argv = NULL;
622672
static bool *g_suite_arg_matched = NULL;
@@ -869,6 +919,10 @@ int main(int argc, char **argv) {
869919
if (daemon_ipc_probe_rc >= 0) {
870920
return daemon_ipc_probe_rc;
871921
}
922+
int deleted_self_rc = tf_maybe_run_deleted_self_probe(argc, argv);
923+
if (deleted_self_rc >= 0) {
924+
return deleted_self_rc;
925+
}
872926

873927
/* #798 follow-up: if spawned as the socket-isolation probe, report whether an
874928
* inheritable socket handle crossed into this child and exit before any suite. */

0 commit comments

Comments
 (0)