Merge pull request #2381 from wcy12378/fix/221-windows-cli-detection

fix(cli): resolve Windows CLI detection for long PATH and npm/mise/scoop shims (#221)
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-10-25 19:37:14 +01:00
commit ced508f6a4
5 files changed
+435 -28

No files matched your search

+87 -28
View File
@@ -23,6 +23,7 @@
#include "foundation/platform.h"
#include "foundation/constants.h"
#include "foundation/log.h"
#include "foundation/mem_core.h"
#include "foundation/sha256.h"
#include "foundation/str_util.h"
#include "cli/client_adapter.h"
@@ -1125,36 +1126,50 @@ static bool is_executable(const char *path) {
#endif
}
/* Windows ships CLIs as extension-bearing shims -- .cmd from npm, .ps1 from
* mise, .exe from scoop -- so a bare-name probe never matches there. One
* definition serves both the PATH scan and the fallback-directory probe (#221). */
#ifdef _WIN32
static const char *const WIN_EXEC_EXTS[] = {".exe", ".cmd", ".bat", ".ps1", NULL};
#endif
/* Search for an executable named `name` in the PATH environment variable.
* Returns the full path in `out` (max out_sz) if found, else empty string. */
* Returns the full path in `out` (max out_sz) if found, else empty string.
*
* The PATH copy is sized to the REAL value, not to a fixed 4 KB stack buffer.
* That buffer was the first root cause of #221: a Windows dev machine's PATH
* routinely runs past 4096 characters, and cbm_safe_getenv() REFUSES a value it
* cannot fit rather than truncating it -- so find_in_path() returned false
* before looking at a single directory, and opencode (installed through
* mise/npm/scoop and present on PATH) read as "not installed". The same 4 KB
* cap hid entries on macOS/Linux too; the fix is not Windows-specific. */
static bool find_in_path(const char *name, char *out, size_t out_sz) {
char path_copy[CLI_BUF_4K];
if (!cbm_safe_getenv("PATH", path_copy, sizeof(path_copy), NULL)) {
char *path_copy = cbm_env_dup("PATH");
if (!path_copy) {
return false;
}
char *saveptr;
/* One exit. PATH is a full live copy of the environment, the scan has
* several ways to stop, and the previous shape returned straight out of the
* loop -- which is exactly what leaked a PATH-sized allocation per call. */
bool found = false;
char *saveptr = NULL;
char *dir = strtok_r(path_copy, PATH_DELIM, &saveptr);
while (dir) {
while (dir != NULL && !found) {
snprintf(out, out_sz, "%s/%s", dir, name);
if (is_executable(out)) {
return true;
}
found = is_executable(out);
#ifdef _WIN32
/* On Windows executables carry an extension (PATHEXT). A CLI like
* opencode is often installed as a .cmd / .ps1 / .exe shim (e.g. via
* mise or npm), so the bare-name probe above misses it (#221). Try the
* common executable extensions before moving to the next PATH entry. */
static const char *const win_exts[] = {".exe", ".cmd", ".bat", ".ps1", NULL};
for (int i = 0; win_exts[i]; i++) {
snprintf(out, out_sz, "%s/%s%s", dir, name, win_exts[i]);
if (is_executable(out)) {
return true;
}
/* PATHEXT variants for THIS entry, before moving to the next one. */
for (int i = 0; !found && WIN_EXEC_EXTS[i] != NULL; i++) {
snprintf(out, out_sz, "%s/%s%s", dir, name, WIN_EXEC_EXTS[i]);
found = is_executable(out);
}
#endif
dir = strtok_r(NULL, PATH_DELIM, &saveptr);
}
return false;
cbm_free(CBM_MEM_CLASS_OTHER, path_copy);
return found;
}
const char *cbm_find_cli(const char *name, const char *home_dir) {
@@ -1168,22 +1183,66 @@ const char *cbm_find_cli(const char *name, const char *home_dir) {
if (!home_dir || !home_dir[0]) {
return "";
}
/* PATH-independent fallbacks, tried in order. Every home-derived entry is
* built from the `home_dir` ARGUMENT and not from cbm_get_home_dir(): a
* dry-run scan or a test passes a synthetic home and must not be shown the
* host's agents. `/` separators are fine on Windows -- the wide-safe file
* layer accepts them, and the rest of this file already composes paths so. */
enum { NUM_PATHS = 5 };
char paths[NUM_PATHS][CLI_BUF_512];
snprintf(paths[0], sizeof(paths[0]), "/usr/local/bin/%s", name);
snprintf(paths[1], sizeof(paths[1]), "%s/.npm/bin/%s", home_dir, name);
snprintf(paths[2], sizeof(paths[2]), "%s/.local/bin/%s", home_dir, name);
snprintf(paths[3], sizeof(paths[3]), "%s/.cargo/bin/%s", home_dir, name);
#ifdef __APPLE__
snprintf(paths[4], sizeof(paths[4]), "/opt/homebrew/bin/%s", name);
memset(paths, 0, sizeof(paths));
int n = 0;
#ifdef _WIN32
/* Locations that were already probed on Windows before #221 and are real
* there: pipx installs into ~/.local/bin and cargo into ~/.cargo/bin, so
* replacing the list rather than extending it would make pipx- and
* cargo-installed agents disappear. */
snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name);
/* Windows-shaped locations (issue #221, second root cause): npm puts its
* global bin under %APPDATA%\npm, and mise and scoop expose shims under
* %LOCALAPPDATA%\mise\shims and %USERPROFILE%\scoop\shims. Each is read
* through its own environment seam, so the list stays testable. */
const char *appdata = cbm_app_config_dir();
if (appdata) {
snprintf(paths[n++], sizeof(paths[0]), "%s/npm/%s", appdata, name);
}
const char *localapp = cbm_app_local_dir();
if (localapp) {
snprintf(paths[n++], sizeof(paths[0]), "%s/mise/shims/%s", localapp, name);
}
snprintf(paths[n++], sizeof(paths[0]), "%s/scoop/shims/%s", home_dir, name);
#else
paths[4][0] = '\0';
snprintf(paths[n++], sizeof(paths[0]), "/usr/local/bin/%s", name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.npm/bin/%s", home_dir, name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.local/bin/%s", home_dir, name);
snprintf(paths[n++], sizeof(paths[0]), "%s/.cargo/bin/%s", home_dir, name);
#ifdef __APPLE__
snprintf(paths[n++], sizeof(paths[0]), "/opt/homebrew/bin/%s", name);
#endif
for (int i = 0; i < NUM_RETRIES; i++) {
if (paths[i][0] && is_executable(paths[i])) {
#endif
/* NUM_PATHS, not NUM_RETRIES: both are 5 today, so the old bound only
* looked right by coincidence -- what the loop means is the list length. */
for (int i = 0; i < NUM_PATHS; i++) {
if (!paths[i][0]) {
continue;
}
if (is_executable(paths[i])) {
snprintf(buf, sizeof(buf), "%s", paths[i]);
return buf;
}
#ifdef _WIN32
/* The npm/mise/scoop shims carry an extension, so every fallback
* directory needs the same PATHEXT probe the PATH scan got. */
for (int e = 0; WIN_EXEC_EXTS[e] != NULL; e++) {
char extpath[CLI_BUF_512];
snprintf(extpath, sizeof(extpath), "%s%s", paths[i], WIN_EXEC_EXTS[e]);
if (is_executable(extpath)) {
snprintf(buf, sizeof(buf), "%s", extpath);
return buf;
}
}
#endif
}
return "";
}
+77
View File
@@ -7,6 +7,7 @@
#include "foundation/compat.h"
#include "foundation/constants.h"
#include "foundation/mem_core.h"
#include "foundation/platform_internal.h"
#include <ctype.h>
#include <errno.h>
@@ -467,6 +468,82 @@ const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const
return NULL;
}
/* See platform.h. The long-value sibling of cbm_safe_getenv: there is no caller
* buffer to overrun, so a PATH past 4 KB is read in full instead of being
* refused (#221). Both the wide staging buffer and the UTF-8 result are
* allocated through the memory core, which is also the only allocator the
* caller may release them with -- keeping the memory-core ratchet honest means
* this file adds no raw allocation site to pay for the new read. */
char *cbm_env_dup(const char *name) {
if (!name || !name[0]) {
return NULL;
}
/* The same view cbm_safe_getenv gives: the startup settings resolver
* answers first, and a handled NULL masks the variable. */
const char *resolved = NULL;
if (environment_resolver && environment_resolver(name, &resolved)) {
return resolved ? cbm_mem_strdup(CBM_MEM_CLASS_OTHER, resolved) : NULL;
}
#ifdef _WIN32
/* Same wide route as cbm_safe_getenv: _environ holds ANSI-code-page bytes,
* so a non-ASCII PATH would arrive mojibake'd or with '?' substituted --
* invalid in a Windows path and fatal to every file API downstream. */
wchar_t wname[CBM_SZ_256];
int wn = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, name, -1, wname, CBM_SZ_256);
if (wn <= 0) {
return NULL;
}
SetLastError(ERROR_SUCCESS);
DWORD needed = GetEnvironmentVariableW(wname, NULL, 0U);
DWORD lookup_error = GetLastError();
if (needed == 0U) {
if (lookup_error != ERROR_ENVVAR_NOT_FOUND) {
/* Present but empty. Distinct from unset, and callers are entitled
* to tell the two apart, so return "" and not NULL. */
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, "");
}
return NULL;
}
wchar_t *wvalue = cbm_calloc(CBM_MEM_CLASS_OTHER, (size_t)needed * sizeof(*wvalue));
if (!wvalue) {
return NULL;
}
SetLastError(ERROR_SUCCESS);
DWORD got = GetEnvironmentVariableW(wname, wvalue, needed);
DWORD read_error = GetLastError();
if (got >= needed || (got == 0U && read_error != ERROR_SUCCESS)) {
cbm_free(CBM_MEM_CLASS_OTHER, wvalue);
return NULL;
}
int utf8_size =
WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, NULL, 0, NULL, NULL);
char *utf8 = utf8_size > 0 ? cbm_alloc(CBM_MEM_CLASS_OTHER, (size_t)utf8_size) : NULL;
int converted = utf8 ? WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wvalue, -1, utf8,
utf8_size, NULL, NULL)
: 0;
if (!utf8 || converted != utf8_size) {
cbm_free(CBM_MEM_CLASS_OTHER, utf8);
cbm_free(CBM_MEM_CLASS_OTHER, wvalue);
return NULL;
}
cbm_free(CBM_MEM_CLASS_OTHER, wvalue);
return utf8;
#else
/* Same environ walk as cbm_safe_getenv: getenv() returns a pointer another
* thread can invalidate with setenv(), so the value is copied out here. */
char **env = CBM_ENVIRON;
if (env) {
size_t name_len = strlen(name);
for (; *env; env++) {
if (strncmp(*env, name, name_len) == 0 && (*env)[name_len] == '=') {
return cbm_mem_strdup(CBM_MEM_CLASS_OTHER, *env + name_len + SKIP_ONE);
}
}
}
return NULL;
#endif
}
/* See platform.h. The shape here is the one src/main.c:1104 already uses for
* --port=: an end pointer says where the read stopped, errno catches a number
* too large, and *end == '\0' catches anything left over. */
+15
View File
@@ -147,6 +147,21 @@ const char *cbm_runtime_getenv(const char *name);
/* Explicit inherited environment access for bootstrap/settings provenance. */
const char *cbm_native_getenv(const char *name, char *buf, size_t buf_sz, const char *fallback);
/* Allocating sibling of cbm_safe_getenv, for a value that must not be capped at
* a caller-chosen size. A Windows PATH routinely runs past the 4096-byte buffer
* cbm_safe_getenv's callers pass, and the copy helper REPORTS that as failure
* rather than truncating -- so the whole PATH was skipped and every directory
* past the cut became invisible (#221).
*
* Returns a UTF-8 copy owned by the memory core: release it with
* cbm_free(CBM_MEM_CLASS_OTHER, ptr), never free(). NULL when the variable is
* unset or cannot be read; a variable that exists but is empty yields "".
* On Windows the value is read with the wide API and converted, the same route
* cbm_safe_getenv takes, so a non-ASCII value survives intact. Like
* cbm_safe_getenv, an installed settings resolver answers before the process
* environment does. */
char *cbm_env_dup(const char *name);
/* Read an environment variable as a whole number.
*
* Answers true only when the variable is set, is not empty, and reads cleanly
+208
View File
@@ -4245,6 +4245,212 @@ TEST(cli_find_cli_fallback_paths) {
PASS();
}
/* ── #221 regression: PATH longer than the old 4 KB copy ───────────────── */
/* Snapshot/restore one environment variable. The suite swaps PATH and the
* Windows profile roots, and a leaked swap would poison every later suite. */
static char *cli_issue221_save_env(const char *name) {
const char *value = getenv(name);
return value ? strdup(value) : NULL;
}
static void cli_issue221_restore_env(const char *name, char *saved) {
if (saved) {
cbm_setenv(name, saved, 1);
free(saved);
} else {
cbm_unsetenv(name);
}
}
/*
* cli_find_cli_long_path_beyond_four_kb_issue221
*
* find_in_path() used to copy PATH into a fixed 4096-byte stack buffer through
* cbm_safe_getenv(). That helper does not truncate -- it REFUSES a value it
* cannot fit -- so on a machine whose PATH exceeds 4096 characters the whole
* PATH was skipped and every agent on it read as "not installed". That is the
* layout the #221 reporter had: mise, npm and scoop each append entries.
*
* The fixture directory is the LAST PATH entry, so any truncation of the copy
* hides exactly the directory under test and nothing else. This case is not
* Windows-specific -- the same 4 KB cap hid entries on macOS and Linux.
*
* RED before the fix (empty result), GREEN after it.
*/
TEST(cli_find_cli_long_path_beyond_four_kb_issue221) {
const char *created = th_mktempdir("repro221long");
if (!created) {
FAIL("th_mktempdir failed");
}
char tmpdir[512];
snprintf(tmpdir, sizeof(tmpdir), "%s", created);
#ifdef _WIN32
const char *fixture_name = "cbm-repro221-long.cmd";
const char *fixture_body = "@echo off\r\nrem fake long-PATH agent\r\n";
const char path_delim = ';';
#else
const char *fixture_name = "cbm-repro221-long";
const char *fixture_body = "#!/bin/sh\n# fake long-PATH agent\n";
const char path_delim = ':';
#endif
char fixture_path[1024];
snprintf(fixture_path, sizeof(fixture_path), "%s/%s", tmpdir, fixture_name);
if (th_write_file(fixture_path, fixture_body) != 0) {
th_cleanup(tmpdir);
FAIL("could not write the long-PATH fixture");
}
th_make_executable(fixture_path);
/*
* Filler entries name directories that do not exist: the scan has to walk
* past every one of them to reach the fixture, which is appended after the
* filler so that it always sits beyond the 4096-byte mark.
*/
enum { FILLER_TARGET = 4600, PATH_CAP = FILLER_TARGET + 1024 };
char long_path[PATH_CAP];
size_t used = 0U;
while (used < (size_t)FILLER_TARGET) {
int written = snprintf(long_path + used, sizeof(long_path) - used,
"/nonexistent-repro221-filler%05zu%c", used, path_delim);
if (written <= 0 || (size_t)written >= sizeof(long_path) - used) {
th_cleanup(tmpdir);
FAIL("could not build the long PATH filler");
}
used += (size_t)written;
}
ASSERT_GT(used, 4096);
int tail = snprintf(long_path + used, sizeof(long_path) - used, "%s", tmpdir);
if (tail <= 0 || (size_t)tail >= sizeof(long_path) - used) {
th_cleanup(tmpdir);
FAIL("could not append the fixture directory to the long PATH");
}
char *saved_path = cli_issue221_save_env("PATH");
cbm_setenv("PATH", long_path, 1);
/* A home that does not exist keeps the PATH-independent fallbacks out of
* the way: the fixture's directory is the only place that can satisfy it. */
const char *result = cbm_find_cli("cbm-repro221-long", "/nonexistent-repro221-home");
cli_issue221_restore_env("PATH", saved_path);
ASSERT_NOT_NULL(result);
ASSERT(result[0] != '\0');
ASSERT(strncmp(result, tmpdir, strlen(tmpdir)) == 0);
ASSERT(strstr(result, "cbm-repro221-long") != NULL);
th_cleanup(tmpdir);
PASS();
}
/* ── #221 regression: the Windows fallback shim locations ───────────────── */
/*
* cli_find_cli_windows_fallback_shims_issue221
*
* An agent the PATH scan cannot see must still be found where Windows actually
* puts CLIs: %APPDATA%\npm (npm's global bin), %LOCALAPPDATA%\mise\shims,
* %USERPROFILE%\scoop\shims, plus the pipx and cargo homes (~\.local\bin,
* ~\.cargo\bin) that were already probed before #221 passed and must not be
* dropped by the fix.
*
* Each location is reached through its own seam -- APPDATA / LOCALAPPDATA, or
* the home_dir argument -- so this never reads the developer's real profile.
* Every shim carries a .cmd extension, which is why the fallback probe needs
* the same PATHEXT treatment the PATH scan got.
*
* Windows-only: the directory layout it asserts is the one Windows installers
* produce. */
TEST(cli_find_cli_windows_fallback_shims_issue221) {
#ifndef _WIN32
SKIP_PLATFORM("Windows-only: npm/mise/scoop shim fallback layout");
#else
const char *created = th_mktempdir("repro221fb");
if (!created) {
FAIL("th_mktempdir failed");
}
char root[512];
snprintf(root, sizeof(root), "%s", created);
char *saved_appdata = cli_issue221_save_env("APPDATA");
char *saved_localappdata = cli_issue221_save_env("LOCALAPPDATA");
char *saved_userprofile = cli_issue221_save_env("USERPROFILE");
char *saved_path = cli_issue221_save_env("PATH");
/* APPDATA/LOCALAPPDATA/USERPROFILE all point at the fixture root. The
* scoop, pipx and cargo entries are built from the home_dir ARGUMENT, so
* USERPROFILE here mirrors what a real profile would supply. */
cbm_setenv("APPDATA", root, 1);
cbm_setenv("LOCALAPPDATA", root, 1);
cbm_setenv("USERPROFILE", root, 1);
cbm_setenv("PATH", "/nonexistent-repro221-path", 1);
/* One shim at a time, so the returned path proves which entry matched
* rather than merely that something did. Each case removes its shim before
* the next one runs, so the earlier entries cannot shadow the later ones. */
const struct {
const char *subdir;
const char *label;
} cases[] = {
{".local/bin", "pipx home"}, {".cargo/bin", "cargo home"}, {"npm", "APPDATA npm"},
{"mise/shims", "LOCALAPPDATA mise"}, {"scoop/shims", "scoop home"},
};
const char *shim_name = "cbm-repro221-fb.cmd";
const size_t case_count = sizeof(cases) / sizeof(cases[0]);
for (size_t i = 0; i < case_count; i++) {
char dir[512];
snprintf(dir, sizeof(dir), "%s/%s", root, cases[i].subdir);
if (th_mkdir_p(dir) != 0) {
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
FAIL("could not create the fallback fixture directory");
}
char shim[1024];
snprintf(shim, sizeof(shim), "%s/%s", dir, shim_name);
if (th_write_file(shim, "@echo off\r\nrem fake fallback shim\r\n") != 0) {
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
FAIL("could not write the fallback shim");
}
const char *result = cbm_find_cli("cbm-repro221-fb", root);
char expected_prefix[512];
snprintf(expected_prefix, sizeof(expected_prefix), "%s/%s/", root, cases[i].subdir);
bool matched = result != NULL && result[0] != '\0' &&
strncmp(result, expected_prefix, strlen(expected_prefix)) == 0;
(void)remove(shim);
if (!matched) {
printf(" fallback case %zu (%s) did not resolve in %s\n", i, cases[i].label, dir);
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
FAIL("a Windows fallback shim location was not probed");
}
}
cli_issue221_restore_env("PATH", saved_path);
cli_issue221_restore_env("USERPROFILE", saved_userprofile);
cli_issue221_restore_env("LOCALAPPDATA", saved_localappdata);
cli_issue221_restore_env("APPDATA", saved_appdata);
th_cleanup(root);
PASS();
#endif
}
/* ═══════════════════════════════════════════════════════════════════
* Dry-run flag parsing (port of TestDryRun)
* ═══════════════════════════════════════════════════════════════════ */
@@ -18307,6 +18513,8 @@ SUITE(cli) {
RUN_TEST(cli_find_cli_result_is_thread_local);
RUN_TEST(cli_find_cli_on_path);
RUN_TEST(cli_find_cli_fallback_paths);
RUN_TEST(cli_find_cli_long_path_beyond_four_kb_issue221);
RUN_TEST(cli_find_cli_windows_fallback_shims_issue221);
/* Dry-run flag parsing (1 test — install_test.go) */
RUN_TEST(cli_dry_run_flags);
+48
View File
@@ -7,6 +7,7 @@
#include "../src/foundation/compat_regex.h"
#include "../src/foundation/constants.h"
#include "../src/foundation/compat_thread.h"
#include "../src/foundation/mem_core.h"
#include "../src/foundation/platform.h"
#include "../src/foundation/platform_internal.h"
#include "../src/foundation/system_info_internal.h"
@@ -895,6 +896,52 @@ TEST(platform_env_long_refuses_what_it_cannot_read) {
PASS();
}
/* cbm_env_dup (#221) gives the same environment view as cbm_safe_getenv: an
* installed settings resolver answers first, a handled NULL masks the
* variable, and an unhandled name falls through to the process environment. */
static bool platform_env_dup_test_resolver(const char *name, const char **value) {
if (strcmp(name, "CBM_TEST_ENV_DUP_RESOLVED") == 0) {
*value = "from-settings";
return true;
}
if (strcmp(name, "CBM_TEST_ENV_DUP_MASKED") == 0) {
*value = NULL;
return true;
}
return false;
}
TEST(platform_env_dup_honours_the_settings_resolver) {
ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_RESOLVED", "from-environment", 1), 0);
ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_MASKED", "from-environment", 1), 0);
ASSERT_EQ(cbm_setenv("CBM_TEST_ENV_DUP_NATIVE", "from-environment", 1), 0);
cbm_set_environment_resolver(platform_env_dup_test_resolver);
char *resolved = cbm_env_dup("CBM_TEST_ENV_DUP_RESOLVED");
char *masked = cbm_env_dup("CBM_TEST_ENV_DUP_MASKED");
char *native = cbm_env_dup("CBM_TEST_ENV_DUP_NATIVE");
char safe[64];
const char *safe_value = cbm_safe_getenv("CBM_TEST_ENV_DUP_RESOLVED", safe, sizeof(safe), NULL);
cbm_set_environment_resolver(NULL);
bool safe_ok = safe_value && strcmp(safe_value, "from-settings") == 0;
bool resolved_ok = resolved && strcmp(resolved, "from-settings") == 0;
bool masked_ok = masked == NULL;
bool native_ok = native && strcmp(native, "from-environment") == 0;
cbm_free(CBM_MEM_CLASS_OTHER, resolved);
cbm_free(CBM_MEM_CLASS_OTHER, masked);
cbm_free(CBM_MEM_CLASS_OTHER, native);
(void)cbm_unsetenv("CBM_TEST_ENV_DUP_RESOLVED");
(void)cbm_unsetenv("CBM_TEST_ENV_DUP_MASKED");
(void)cbm_unsetenv("CBM_TEST_ENV_DUP_NATIVE");
ASSERT_TRUE(safe_ok);
ASSERT_TRUE(resolved_ok);
ASSERT_TRUE(masked_ok);
ASSERT_TRUE(native_ok);
PASS();
}
#ifdef _WIN32
/* cbm_safe_getenv reads Windows' wide environment as UTF-8. Its matching
* setter must update that same wide environment; _putenv_s alone interprets
@@ -1420,6 +1467,7 @@ SUITE(platform) {
RUN_TEST(platform_default_workers_env_unset);
RUN_TEST(platform_env_long_reads_a_clean_number);
RUN_TEST(platform_env_long_refuses_what_it_cannot_read);
RUN_TEST(platform_env_dup_honours_the_settings_resolver);
RUN_TEST(platform_system_info);
RUN_TEST(regex_compile_refuses_oversized_expansion);
RUN_TEST(regex_compile_accepts_expansion_under_budget);