diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 024f37e93..aa17e5005 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -235,6 +235,33 @@ be indexed and later returned. And the credential list is a denylist, so it raises the cost of a mistake rather than closing the class — a directory it does not name is permitted. +### Shared / HPC filesystems + +On clusters and other shared machines, home directories often live on a network +filesystem (NFS, Lustre, GPFS, SMB/CIFS), and `~/.cache` is sometimes a symlink +into a shared tree. Three rules keep CBM working there: + +- **Put the cache and the rendezvous on local storage.** Set `CBM_CACHE_DIR` (indexes, + `_config.db`) and, if needed, `CBM_RUNTIME_DIR` (the daemon rendezvous) to a + local disk you own, for example `/local/scratch/$USER/cbm` or `/tmp/$USER-cbm`. + Set the same values in your MCP client's environment and your shell. +- **Do not route the cache into another account's tree.** A symlinked `~/.cache` is + resolved and allowed, but every directory on the *resolved* path must be owned + by you or by root. If a parent such as `/shared/fs` belongs to another account, + that account can rename or replace anything below it, so CBM refuses the path + and names the directory and its owner: + + ```text + ... (cache-private) - '/shared/fs' is owned by uid 1234 (alice), not by you (uid 1000) or root; ... + ``` + + This check has no override. Choose a location with a safe ancestry instead. +- **Avoid network filesystems for the cache.** The indexes are SQLite databases in + WAL mode, which needs shared-memory locking that network filesystems do not + provide reliably. On Linux, CBM logs one `daemon.cache_root_network_fs` warning + at startup when the cache root is on NFS, Lustre, GPFS, CIFS or SMB2. The cache + still works there, but it can be slow, and a database can be corrupted. + ## 5. Agent and Editor Integration Files The `install` command can also write MCP entries and instruction blocks into agent/editor config files such as Claude Code, Codex, Gemini, VS Code, Cursor, Zed, and others. diff --git a/src/daemon/ipc.c b/src/daemon/ipc.c index eb87e43be..0ea819542 100644 --- a/src/daemon/ipc.c +++ b/src/daemon/ipc.c @@ -21,6 +21,9 @@ #include #include #include +#if defined(__linux__) +#include +#endif enum { CBM_DAEMON_IPC_SEND_TIMEOUT_MS = 5000, @@ -54,6 +57,76 @@ const char *cbm_daemon_ipc_validation_detail(void) { return ipc_validation_detail_buffer; } +/* #1717: a cache root on a network filesystem (a shared HPC home, an NFS or + * SMB mount) is ACCEPTED -- this is advice, never a refusal. SQLite's WAL mode + * relies on shared-memory locking that network filesystems do not provide + * reliably, so indexes there can be slow or corrupt. Detection is statfs + * f_type, Linux only; other platforms never warn. */ +static const char *ipc_network_fs_name(unsigned long magic) { + static const struct { + unsigned long magic; + const char *name; + } kinds[] = { + {0x6969UL, "NFS"}, {0xFF534D42UL, "CIFS"}, {0xFE534D42UL, "SMB2"}, + {0x0BD00BD0UL, "Lustre"}, {0x47504653UL, "GPFS"}, + }; + for (size_t index = 0; index < sizeof(kinds) / sizeof(kinds[0]); index++) { + if (kinds[index].magic == magic) { + return kinds[index].name; + } + } + return NULL; +} + +static atomic_bool g_ipc_network_cache_warned; +#ifdef CBM_ENABLE_TEST_SEAMS +static bool g_ipc_fs_magic_override_active; +static unsigned long g_ipc_fs_magic_override; +void cbm_daemon_ipc_set_fs_magic_for_test(bool active, unsigned long magic) { + g_ipc_fs_magic_override_active = active; + g_ipc_fs_magic_override = magic; + atomic_store(&g_ipc_network_cache_warned, false); +} +#endif + +static bool ipc_path_fs_magic(const char *path, unsigned long *magic_out) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (g_ipc_fs_magic_override_active) { + *magic_out = g_ipc_fs_magic_override; + return true; + } +#endif +#if defined(__linux__) + struct statfs status; + if (statfs(path, &status) != 0) { + return false; + } + /* f_type is a signed word on some ABIs; the magics are 32-bit. */ + *magic_out = (unsigned long)status.f_type & 0xFFFFFFFFUL; + return true; +#else + (void)path; + (void)magic_out; + return false; +#endif +} + +bool cbm_daemon_ipc_warn_if_network_cache_root(const char *cache_root) { + unsigned long magic = 0; + const char *kind = NULL; + if (!cache_root || !cache_root[0] || !ipc_path_fs_magic(cache_root, &magic) || + !(kind = ipc_network_fs_name(magic))) { + return false; + } + if (atomic_exchange(&g_ipc_network_cache_warned, true)) { + return false; /* one warning per process */ + } + cbm_log_warn("daemon.cache_root_network_fs", "path", cache_root, "fs", kind, "reason", + "sqlite_wal_unreliable_on_network_fs", "hint", + "set CBM_CACHE_DIR to local storage (docs/CONFIGURATION.md)"); + return true; +} + static cbm_daemon_ipc_listen_failure_t ipc_listen_failure; /* The failure recorder and its reset are only reached from the POSIX socket @@ -401,6 +474,9 @@ int cbm_daemon_ipc_wait_pending(const cbm_ipc_pending_ops_t *ops, uint32_t timeo #include #include #include +#if !defined(__linux__) +#include +#endif #include #include #include @@ -1788,14 +1864,138 @@ static bool posix_directory_parent_secure(int directory_fd) { * never chmod'd or ACL-rewritten. The euid-only enforcement that stops a * squatted private directory lives in the created/final/snapshot checks, not * here — this only walks ancestors. */ -static bool posix_directory_transition_secure(int parent_fd, int child_fd) { +#ifdef CBM_ENABLE_TEST_SEAMS +/* #1717 seam: report one directory (by dev/ino) as owned by a foreign uid, so + * the foreign-owned-ancestor refusal and its diagnostic are testable without + * root (an unprivileged test cannot chown a directory to another account). */ +static bool g_posix_foreign_owner_active; +static dev_t g_posix_foreign_owner_dev; +static ino_t g_posix_foreign_owner_ino; +static uid_t g_posix_foreign_owner_uid; +bool cbm_daemon_ipc_posix_set_foreign_owned_dir_for_test(const char *directory_path, + unsigned long foreign_uid) { + struct stat status; + g_posix_foreign_owner_active = false; + if (!directory_path || lstat(directory_path, &status) != 0 || !S_ISDIR(status.st_mode)) { + return false; + } + g_posix_foreign_owner_dev = status.st_dev; + g_posix_foreign_owner_ino = status.st_ino; + g_posix_foreign_owner_uid = (uid_t)foreign_uid; + g_posix_foreign_owner_active = true; + return true; +} +void cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test(void) { + g_posix_foreign_owner_active = false; +} +#endif + +static uid_t posix_directory_observed_owner(const struct stat *status) { +#ifdef CBM_ENABLE_TEST_SEAMS + if (g_posix_foreign_owner_active && status->st_dev == g_posix_foreign_owner_dev && + status->st_ino == g_posix_foreign_owner_ino) { + return g_posix_foreign_owner_uid; + } +#endif + return status->st_uid; +} + +enum { POSIX_OWNER_NAME_CAP = 33, POSIX_PASSWD_LINE_CAP = 1024 }; + +/* The account name for uid, or "" when it cannot be named. Diagnostic only. + * + * Linux reads /etc/passwd directly instead of calling getpwuid(): the Linux + * release binary is statically linked against glibc, where getpwuid() loads the + * HOST's NSS modules at runtime -- a version-mismatch crash hazard exactly on + * the sssd/LDAP hosts (#1717's shared HPC filesystems) this message serves. + * A directory-service account is then shown by uid only. */ +static void posix_owner_name(uid_t uid, char out[POSIX_OWNER_NAME_CAP]) { + out[0] = '\0'; +#if defined(__linux__) + FILE *passwd = cbm_fopen("/etc/passwd", "r"); + if (!passwd) { + return; + } + char line[POSIX_PASSWD_LINE_CAP]; + while (fgets(line, sizeof(line), passwd)) { + char *name_end = strchr(line, ':'); + char *password_end = name_end ? strchr(name_end + 1, ':') : NULL; + if (!password_end || name_end == line) { + continue; + } + char *uid_end = NULL; + errno = 0; + unsigned long line_uid = strtoul(password_end + 1, &uid_end, 10); + if (errno == 0 && uid_end != password_end + 1 && *uid_end == ':' && + line_uid == (unsigned long)uid) { + (void)snprintf(out, POSIX_OWNER_NAME_CAP, "%.*s", (int)(name_end - line), line); + break; + } + } + (void)fclose(passwd); +#else + struct passwd entry; + struct passwd *found = NULL; + char storage[POSIX_PASSWD_LINE_CAP]; + if (getpwuid_r(uid, &entry, storage, sizeof(storage), &found) == 0 && found && found->pw_name) { + (void)snprintf(out, POSIX_OWNER_NAME_CAP, "%s", found->pw_name); + } +#endif +} + +/* The foreign-owned-ancestor refusal. The remedy is the part the reader acts + * on, so it must survive the fixed detail buffer: the path is shortened from + * the LEFT ("...") to fit, which keeps the refusing component at its end. */ +static void posix_foreign_owner_detail_set(const char *child_path, uid_t owner) { + char name[POSIX_OWNER_NAME_CAP]; + posix_owner_name(owner, name); + char owner_text[64]; + if (name[0]) { + (void)snprintf(owner_text, sizeof(owner_text), "uid %ld (%s)", (long)owner, name); + } else { + (void)snprintf(owner_text, sizeof(owner_text), "uid %ld", (long)owner); + } + char tail[sizeof(ipc_validation_detail_buffer)]; + int tail_length = snprintf(tail, sizeof(tail), + "' is owned by %s, not by you (uid %ld) or root; that account " + "controls this path. Use a location whose parent directories are " + "yours or root's (e.g. set CBM_CACHE_DIR)", + owner_text, (long)geteuid()); + if (tail_length < 0) { + return; + } + size_t used = (size_t)tail_length + 1; /* the opening quote */ + size_t room = used < sizeof(ipc_validation_detail_buffer) + ? sizeof(ipc_validation_detail_buffer) - 1 - used + : 0; + size_t path_length = strlen(child_path); + const char *ellipsis = ""; + const char *shown = child_path; + if (path_length > room) { + ellipsis = "..."; + shown = child_path + path_length - (room > 3 ? room - 3 : 0); + } + ipc_validation_detail_set("'%s%s%s", ellipsis, shown, tail); +} + +static bool posix_directory_transition_secure(int parent_fd, int child_fd, const char *child_path) { struct stat parent; struct stat child; if (parent_fd < 0 || child_fd < 0 || !posix_directory_parent_secure(parent_fd) || fstat(parent_fd, &parent) != 0 || fstat(child_fd, &child) != 0 || !S_ISDIR(parent.st_mode) || !S_ISDIR(child.st_mode) || - !posix_directory_ancestor_owner_trusted(parent.st_uid) || - !posix_directory_ancestor_owner_trusted(child.st_uid)) { + !posix_directory_ancestor_owner_trusted(posix_directory_observed_owner(&parent))) { + return false; + } + uid_t child_owner = posix_directory_observed_owner(&child); + if (!posix_directory_ancestor_owner_trusted(child_owner)) { + /* #1717: this refusal used to set no detail, so the caller printed + * "ancestry component validation failed (errno 17)" -- a stale EEXIST + * from the mkdirat() on the existing component, naming neither the + * directory nor the reason. The policy is unchanged: an account that + * owns an ancestor can rename or replace everything below it after + * this walk, so it could redirect the private directory. Name it. */ + posix_foreign_owner_detail_set(child_path, child_owner); return false; } return true; @@ -1870,7 +2070,8 @@ static int private_directory_tree_open(const char *directory_path) { : -1; struct stat status; ok = next_fd >= 0 && fd_set_cloexec(next_fd) && fstat(next_fd, &status) == 0 && - S_ISDIR(status.st_mode) && posix_directory_transition_secure(current_fd, next_fd); + S_ISDIR(status.st_mode) && + posix_directory_transition_secure(current_fd, next_fd, path); if (ok && created) { ok = status.st_uid == geteuid() && fchmod(next_fd, 0700) == 0 && cbm_macos_extended_acl_fd_clear(next_fd) && diff --git a/src/daemon/ipc.h b/src/daemon/ipc.h index effe6a852..f8195f3d5 100644 --- a/src/daemon/ipc.h +++ b/src/daemon/ipc.h @@ -92,6 +92,17 @@ bool cbm_daemon_ipc_private_directory_secure(const char *directory_path); * append it to their error messages; policy decisions never read it. */ const char *cbm_daemon_ipc_validation_detail(void); +/* #1717: advice, never a refusal. When cache_root lives on a network + * filesystem (Linux statfs: NFS, CIFS, SMB2, Lustre, GPFS), log one + * `daemon.cache_root_network_fs` warning per process pointing at local + * storage. Returns true only when this call emitted that warning. */ +bool cbm_daemon_ipc_warn_if_network_cache_root(const char *cache_root); +#ifdef CBM_ENABLE_TEST_SEAMS +/* Report `magic` as the cache root's statfs f_type on every platform (active), + * or restore real detection; either way re-arm the once-per-process warning. */ +void cbm_daemon_ipc_set_fs_magic_for_test(bool active, unsigned long magic); +#endif + /* This process's most recent listener-publication failure: the stage that * refused, the errno the failing step reported (0 when the step reported * none), and the artifact path it was operating on ("" when no path applies). @@ -127,6 +138,12 @@ bool cbm_daemon_ipc_win_sid_trusted_for_testing(void *sid); * so they can be tested without a chown-able overflow-owned directory. */ void cbm_daemon_ipc_posix_set_ancestor_overflow_uid_for_test(bool active, unsigned long overflow_uid); +/* #1717 seam (POSIX): report the directory at directory_path (matched by + * dev/ino) as owned by foreign_uid during the ancestor walk. False when the + * path is not a directory. Clear restores real ownership. */ +bool cbm_daemon_ipc_posix_set_foreign_owned_dir_for_test(const char *directory_path, + unsigned long foreign_uid); +void cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test(void); bool cbm_daemon_ipc_posix_uid_map_is_single_uid_for_test(const char *uid_map, unsigned long euid); bool cbm_daemon_ipc_posix_ancestor_stat_ok_for_test(unsigned long owner, unsigned int mode, unsigned long euid, bool overflow_active, diff --git a/src/main.c b/src/main.c index fd3370c04..3f179fffe 100644 --- a/src/main.c +++ b/src/main.c @@ -1455,6 +1455,8 @@ static main_build_identity_status_t main_build_identity(cbm_daemon_build_identit if (!cbm_daemon_ipc_private_directory_secure(canonical_cache)) { return MAIN_BUILD_IDENTITY_CACHE_PRIVATE; } + /* #1717: advice only -- a network-filesystem cache root is accepted. */ + (void)cbm_daemon_ipc_warn_if_network_cache_root(canonical_cache); /* Every cache consumer in this process must use the exact path whose * fingerprint joins the account-wide cohort. Keeping an original symlink * spelling in the environment would let a later retarget move storage diff --git a/tests/test_daemon_ipc.c b/tests/test_daemon_ipc.c index 97257506c..35ad5433c 100644 --- a/tests/test_daemon_ipc.c +++ b/tests/test_daemon_ipc.c @@ -49,6 +49,7 @@ #include #include #include +#include #include #ifdef __linux__ #include /* #1830 userns real smoke: unshare(CLONE_NEWUSER). */ @@ -5274,6 +5275,147 @@ TEST(daemon_ipc_posix_wsl_drvfs_refusal_names_remedy_issue1687) { PASS(); } +/* #1717: an ancestor owned by ANOTHER non-root account (a shared filesystem + * whose parent directory belongs to a colleague) is refused -- that account can + * rename or replace everything below it, so the policy stays. What was wrong is + * the message: the refusal set no detail, and the caller printed + * "ancestry component validation failed (errno 17)", a stale EEXIST from + * mkdirat() that named neither the directory nor the reason. The reporter + * could not tell which directory, or why. The refusal must name the refusing + * directory, its owner uid, and the remedy. The seam reports one directory as + * foreign-owned because an unprivileged test cannot chown to another account. */ +enum { IPC_TEST_FOREIGN_MAX_DEPTH = 16 }; + +typedef struct { + bool paths_ok; + bool seam_set; + bool refused; + bool cache_created; + bool secured_without_seam; + size_t foreign_path_length; + char detail[512]; +} ipc_test_foreign_result_t; + +/* Build //shared-fs, mark shared-fs as owned by + * foreign_uid through the seam, and ask for <...>/shared-fs/cache. Deeper + * chains produce a path long enough that the detail must be shortened. */ +static void ipc_test_foreign_ancestor_run(const char *tag, int depth, unsigned long foreign_uid, + ipc_test_foreign_result_t *result) { + char parent[TEST_PATH_CAP]; + char chain[IPC_TEST_FOREIGN_MAX_DEPTH + 1][TEST_PATH_CAP]; + char cache[TEST_PATH_CAP]; + int made = 0; + memset(result, 0, sizeof(*result)); + result->paths_ok = depth <= IPC_TEST_FOREIGN_MAX_DEPTH && ipc_test_parent_new(parent, tag); + const char *base = parent; + for (int level = 0; result->paths_ok && level <= depth; level++) { + const char *leaf = level == depth ? "shared-fs" : "level-component-24-chars"; + int w = snprintf(chain[level], sizeof(chain[level]), "%s/%s", base, leaf); + result->paths_ok = w > 0 && w < (int)sizeof(chain[level]) && mkdir(chain[level], 0755) == 0; + made += result->paths_ok ? 1 : 0; + base = chain[level]; + } + if (result->paths_ok) { + int c = snprintf(cache, sizeof(cache), "%s/cache", chain[depth]); + result->paths_ok = c > 0 && c < (int)sizeof(cache); + result->foreign_path_length = strlen(chain[depth]); + } + if (result->paths_ok) { + result->seam_set = + cbm_daemon_ipc_posix_set_foreign_owned_dir_for_test(chain[depth], foreign_uid); + } + if (result->seam_set) { + result->refused = !cbm_daemon_ipc_private_directory_secure(cache); + const char *why = cbm_daemon_ipc_validation_detail(); + (void)snprintf(result->detail, sizeof(result->detail), "%s", why ? why : ""); + struct stat probe; + result->cache_created = lstat(cache, &probe) == 0; + cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test(); + /* Control: with real ownership the same path is accepted, so the + * refusal above is the foreign owner and nothing else. */ + result->secured_without_seam = cbm_daemon_ipc_private_directory_secure(cache); + (void)rmdir(cache); + } + cbm_daemon_ipc_posix_clear_foreign_owned_dir_for_test(); + while (made > 0) { + (void)rmdir(chain[--made]); + } + if (depth <= IPC_TEST_FOREIGN_MAX_DEPTH) { + ipc_test_remove_flat_dir(parent); + } +} + +TEST(daemon_ipc_posix_foreign_owned_ancestor_refusal_names_directory_issue1717) { + /* A uid no account resolves to: the detail names the uid alone. */ + unsigned long foreign_uid = (unsigned long)geteuid() + 4242UL; + ASSERT_TRUE(getpwuid((uid_t)foreign_uid) == NULL); /* precondition */ + ipc_test_foreign_result_t result; + ipc_test_foreign_ancestor_run("posix-foreign-ancestor", 0, foreign_uid, &result); + char owner_text[64]; + (void)snprintf(owner_text, sizeof(owner_text), "owned by uid %lu,", foreign_uid); + + ASSERT_TRUE(result.paths_ok); + ASSERT_TRUE(result.seam_set); + ASSERT_TRUE(result.refused); + ASSERT_TRUE(!result.cache_created); /* nothing is created below a foreign ancestor */ + ASSERT_NOT_NULL(strstr(result.detail, "/shared-fs'")); + ASSERT_NOT_NULL(strstr(result.detail, owner_text)); + ASSERT_NOT_NULL(strstr(result.detail, "CBM_CACHE_DIR")); + ASSERT_TRUE(strstr(result.detail, "errno") == NULL); + ASSERT_TRUE(result.secured_without_seam); + PASS(); +} + +/* #1717: the owner is named when the account resolves. uid 1 is a local system + * account in /etc/passwd on macOS and on the Linux images we test ("daemon" on + * both; Alpine calls it "bin"), so the expected name is taken from the same + * local database rather than hardcoded. */ +TEST(daemon_ipc_posix_foreign_owned_ancestor_names_owner_account_issue1717) { + const unsigned long named_uid = 1; + struct passwd *expected = getpwuid((uid_t)named_uid); + if (!expected || !expected->pw_name || !expected->pw_name[0]) { + SKIP_PLATFORM("uid 1 has no local account name on this system"); + } + char owner_text[96]; + (void)snprintf(owner_text, sizeof(owner_text), "owned by uid %lu (%s),", named_uid, + expected->pw_name); + ipc_test_foreign_result_t result; + ipc_test_foreign_ancestor_run("posix-foreign-named", 0, named_uid, &result); + + ASSERT_TRUE(result.paths_ok); + ASSERT_TRUE(result.seam_set); + ASSERT_TRUE(result.refused); + ASSERT_NOT_NULL(strstr(result.detail, owner_text)); + ASSERT_NOT_NULL(strstr(result.detail, "/shared-fs'")); + ASSERT_TRUE(result.secured_without_seam); + PASS(); +} + +/* #1717: the detail buffer is fixed (384 bytes). A long path must not push the + * remedy out: the path is shortened from the left, so the refusing component + * and the whole remedy survive. */ +TEST(daemon_ipc_posix_foreign_owned_ancestor_long_path_keeps_remedy_issue1717) { + const unsigned long named_uid = 1; + ipc_test_foreign_result_t result; + ipc_test_foreign_ancestor_run("posix-foreign-long", IPC_TEST_FOREIGN_MAX_DEPTH, named_uid, + &result); + static const char remedy[] = + "Use a location whose parent directories are yours or root's (e.g. set CBM_CACHE_DIR)"; + + ASSERT_TRUE(result.paths_ok); + ASSERT_TRUE(result.foreign_path_length > 384); /* the input really overflows */ + ASSERT_TRUE(result.refused); + ASSERT_TRUE(strlen(result.detail) < 384); + ASSERT_TRUE(strncmp(result.detail, "'...", 4) == 0); + ASSERT_NOT_NULL(strstr(result.detail, "/shared-fs' is owned by uid 1")); + size_t detail_length = strlen(result.detail); + ASSERT_TRUE(detail_length >= sizeof(remedy) - 1); + ASSERT_STR_EQ(result.detail + detail_length - (sizeof(remedy) - 1), remedy); + PASS(); +} +#endif + +#ifdef CBM_ENABLE_TEST_SEAMS /* #1830: /proc/self/uid_map single-uid detection. A single-uid map is exactly * one line " 1" whose inside id is our euid; anything else — * the init map, a count other than 1, a foreign inside id, extra lines, or junk @@ -5496,6 +5638,65 @@ static void ipc_test_log_capture_sink(const char *line) { ipc_test_log_capture[ipc_test_log_capture_used] = '\0'; } +#ifdef CBM_ENABLE_TEST_SEAMS +/* #1717: a cache root on a network filesystem (shared HPC home, NFS/SMB mount) + * is ACCEPTED, but SQLite WAL is unreliable there, so startup logs ONE warning + * with the local-storage advice. The seam supplies the statfs f_type on every + * platform (real detection is Linux-only), so the mapping and the + * once-per-process rule are bound deterministically here. */ +static bool ipc_test_network_warning_emitted(unsigned long magic, const char *fs_name) { + ipc_test_log_capture_used = 0; + ipc_test_log_capture[0] = '\0'; + cbm_daemon_ipc_set_fs_magic_for_test(true, magic); + cbm_log_set_sink_ex(ipc_test_log_capture_sink, CBM_LOG_SINK_REPLACE); + bool warned = cbm_daemon_ipc_warn_if_network_cache_root("/shared/fs/me/.cache/cbm"); + cbm_log_set_sink(NULL); + char expected[32]; + (void)snprintf(expected, sizeof(expected), "fs=%s", fs_name); + const char *line = strstr(ipc_test_log_capture, "daemon.cache_root_network_fs"); + return warned && line && strstr(line, expected) && strstr(line, "CBM_CACHE_DIR") && + strstr(line, "path=/shared/fs/me/.cache/cbm"); +} + +TEST(daemon_ipc_network_cache_root_warns_once_issue1717) { + CBMLogLevel saved_level = cbm_log_get_level(); + cbm_log_set_level(CBM_LOG_WARN); + bool nfs = ipc_test_network_warning_emitted(0x6969UL, "NFS"); + bool cifs = ipc_test_network_warning_emitted(0xFF534D42UL, "CIFS"); + bool smb2 = ipc_test_network_warning_emitted(0xFE534D42UL, "SMB2"); + bool lustre = ipc_test_network_warning_emitted(0x0BD00BD0UL, "Lustre"); + bool gpfs = ipc_test_network_warning_emitted(0x47504653UL, "GPFS"); + + /* Once per process: a second startup check stays silent. */ + cbm_daemon_ipc_set_fs_magic_for_test(true, 0x6969UL); + bool first = cbm_daemon_ipc_warn_if_network_cache_root("/nfs/cache"); + bool second = cbm_daemon_ipc_warn_if_network_cache_root("/nfs/cache"); + + /* Local filesystems (ext4, xfs, tmpfs) never warn. */ + cbm_daemon_ipc_set_fs_magic_for_test(true, 0xEF53UL); + bool ext4 = cbm_daemon_ipc_warn_if_network_cache_root("/home/me/.cache/cbm"); + cbm_daemon_ipc_set_fs_magic_for_test(true, 0x58465342UL); + bool xfs = cbm_daemon_ipc_warn_if_network_cache_root("/home/me/.cache/cbm"); + cbm_daemon_ipc_set_fs_magic_for_test(true, 0x01021994UL); + bool tmpfs = cbm_daemon_ipc_warn_if_network_cache_root("/home/me/.cache/cbm"); + + cbm_daemon_ipc_set_fs_magic_for_test(false, 0); + cbm_log_set_level(saved_level); + + ASSERT_TRUE(nfs); + ASSERT_TRUE(cifs); + ASSERT_TRUE(smb2); + ASSERT_TRUE(lustre); + ASSERT_TRUE(gpfs); + ASSERT_TRUE(first); + ASSERT_TRUE(!second); + ASSERT_TRUE(!ext4); + ASSERT_TRUE(!xfs); + ASSERT_TRUE(!tmpfs); + PASS(); +} +#endif + /* #1828: a full /tmp made every daemon start die at pending publication, and * the only durable trace was `daemon.ipc.listen_failed stage=pending_publication` * -- no syscall, no errno, no path. The reporter needed hours (and a wrong @@ -5599,6 +5800,9 @@ SUITE(daemon_ipc) { RUN_TEST(daemon_ipc_posix_world_writable_ancestor_still_refused_issue1537); #ifdef CBM_ENABLE_TEST_SEAMS RUN_TEST(daemon_ipc_posix_wsl_drvfs_refusal_names_remedy_issue1687); + RUN_TEST(daemon_ipc_posix_foreign_owned_ancestor_refusal_names_directory_issue1717); + RUN_TEST(daemon_ipc_posix_foreign_owned_ancestor_names_owner_account_issue1717); + RUN_TEST(daemon_ipc_posix_foreign_owned_ancestor_long_path_keeps_remedy_issue1717); RUN_TEST(daemon_ipc_posix_uid_map_single_uid_parse_issue1830); RUN_TEST(daemon_ipc_posix_overflow_ancestor_tolerated_only_in_single_uid_ns_issue1830); RUN_TEST(daemon_ipc_posix_overflow_uid_is_never_cached_issue1830); @@ -5633,6 +5837,9 @@ SUITE(daemon_ipc) { RUN_TEST(daemon_ipc_posix_rejects_non_socket_and_symlink_endpoints); #ifndef _WIN32 RUN_TEST(daemon_ipc_listen_failure_names_errno_and_path); +#ifdef CBM_ENABLE_TEST_SEAMS + RUN_TEST(daemon_ipc_network_cache_root_warns_once_issue1717); +#endif #endif #endif }