From b9b8afda2710e45238a0ff86bd4a2415c583940c Mon Sep 17 00:00:00 2001 From: Adil Alperen Ciftci Date: Thu, 29 Oct 2026 14:15:24 +0300 Subject: [PATCH 01/13] tests: record sweep SIGTERM delivery directly Record termination in the native foreground and background helpers instead of depending on when the shell emits trap output. Keep the timeout status, process-group cleanup, and Windows no-signal assertions unchanged. Signed-off-by: Adil Alperen Ciftci --- ...-fixed-macos-sweep-timeout-signal-check.md | 3 + .../sweep_timeout/test_sweep_timeout.sh | 55 ++++++++++++++----- 2 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 new_changelogs/20261029T112500Z-fixed-macos-sweep-timeout-signal-check.md diff --git a/new_changelogs/20261029T112500Z-fixed-macos-sweep-timeout-signal-check.md b/new_changelogs/20261029T112500Z-fixed-macos-sweep-timeout-signal-check.md new file mode 100644 index 000000000..a4fe08283 --- /dev/null +++ b/new_changelogs/20261029T112500Z-fixed-macos-sweep-timeout-signal-check.md @@ -0,0 +1,3 @@ +- **The sweep timeout integration test no longer depends on macOS Bash trap timing.** + Its foreground and background helpers now record SIGTERM delivery directly, + while the test still verifies process-group cleanup and the timeout status. diff --git a/tests/integration/sweep_timeout/test_sweep_timeout.sh b/tests/integration/sweep_timeout/test_sweep_timeout.sh index 050790581..9d3586a6b 100755 --- a/tests/integration/sweep_timeout/test_sweep_timeout.sh +++ b/tests/integration/sweep_timeout/test_sweep_timeout.sh @@ -14,11 +14,12 @@ # A deliberately hung native program runs under a 3 s limit twice over, in # the foreground of a bash that traps SIGTERM and in the background beside # it, inside a "sweep" shell that reports what it saw. The sweep shell has to -# survive and see 124, both copies of the program have to be gone, and on -# Windows the trap must not have run, since nothing may be signalled there. -# Elsewhere the limit is timeout(1), whose SIGTERM is exactly what the trap -# sees. The program gives up by itself after 60 s, so a limit that failed -# cannot leave it behind for long. +# survive and see 124, and both copies of the program have to be gone. On +# Windows the shell trap must not have run, since nothing may be signalled +# there. Elsewhere each native copy records the SIGTERM it receives directly; +# this does not depend on when a particular bash prints or runs its trap. The +# program gives up by itself after 60 s, so a limit that failed cannot leave +# it behind for long. # # #2772: where neither timeout(1) nor gtimeout exists (GitHub's macOS # runners) the limit is sweep_timeout_posix, the same bound in perl; before @@ -50,11 +51,36 @@ cat > "$TMP/hang.c" <<'EOF' #define SELF ((unsigned long)GetCurrentProcessId()) #define NAP() Sleep(1000) #else +#include +#include #include #define SELF ((unsigned long)getpid()) #define NAP() sleep(1) + +static volatile sig_atomic_t term_fd = -1; + +static void on_term(int signal) { + static const char marker[] = "SIGTERM\n"; + int fd = (int)term_fd; + if (fd >= 0) { + (void)write(fd, marker, sizeof marker - 1); + close(fd); + } + _exit(128 + signal); +} #endif int main(int argc, char** argv) { +#ifndef _WIN32 + if (argc > 2) { + term_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (term_fd < 0) return 2; + struct sigaction action = {0}; + sigemptyset(&action.sa_mask); + action.sa_handler = on_term; + action.sa_flags = 0; + if (sigaction(SIGTERM, &action, NULL) != 0) return 2; + } +#endif FILE* f = argc > 1 ? fopen(argv[1], "a") : NULL; if (f) { fprintf(f, "%lu\n", SELF); fclose(f); } for (int i = 0; i < 60; i++) NAP(); @@ -72,14 +98,14 @@ ${CC:-cc} -o "$hang" "$TMP/hang.c" 2>"$TMP/cc.log" \ # land on the same offset on Windows, and a pid would be lost. cat > "$TMP/hung_test.sh" <<'EOF' trap 'echo "SIGTERM reached the test"' TERM -"$1" "$2.background" & -"$1" "$2.foreground" +"$1" "$2.background" "$3.background" & +"$1" "$2.foreground" "$3.foreground" EOF cat > "$TMP/sweep.sh" <<'EOF' . "$1/tests/lib/sweep_timeout.sh" sweep_timeout_bound TO "$1" 3 -$TO bash "$2" "$3" "$4" +$TO bash "$2" "$3" "$4" "$5" echo "rc=$?" echo "sweep shell still running" EOF @@ -88,12 +114,13 @@ EOF # picks with _SWEEP_TIMEOUT_TOOLS set to TOOLS ("default" leaves it unset). check_hung() { label="$1" - rm -f "$TMP/pid.background" "$TMP/pid.foreground" + rm -f "$TMP/pid.background" "$TMP/pid.foreground" \ + "$TMP/term.background" "$TMP/term.foreground" start=$(date +%s) if [ "$2" = default ]; then - out=$(bash "$TMP/sweep.sh" "$ROOT" "$TMP/hung_test.sh" "$hang" "$TMP/pid" 2>&1) + out=$(bash "$TMP/sweep.sh" "$ROOT" "$TMP/hung_test.sh" "$hang" "$TMP/pid" "$TMP/term" 2>&1) else - out=$(_SWEEP_TIMEOUT_TOOLS="$2" bash "$TMP/sweep.sh" "$ROOT" "$TMP/hung_test.sh" "$hang" "$TMP/pid" 2>&1) + out=$(_SWEEP_TIMEOUT_TOOLS="$2" bash "$TMP/sweep.sh" "$ROOT" "$TMP/hung_test.sh" "$hang" "$TMP/pid" "$TMP/term" 2>&1) fi sweep_rc=$? elapsed=$(( $(date +%s) - start )) @@ -123,8 +150,10 @@ check_hung() { printf '%s\n' "$out" | grep -q "SIGTERM reached the test" \ && fail "$label: the limit signalled the test on Windows, where a signal can reach unrelated processes (#2758)" else - printf '%s\n' "$out" | grep -q "SIGTERM reached the test" \ - || fail "$label: the limit's SIGTERM never reached the test: $out" + for copy in background foreground; do + grep -qx "SIGTERM" "$TMP/term.$copy" 2>/dev/null \ + || fail "$label: SIGTERM never reached the $copy program: $out" + done fi } From 07edd76d67425209f7fcc0add3d23eb797326f2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:46:40 +0300 Subject: [PATCH 02/13] fix(compiler): fail closed at effect analysis limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- compiler/analysis/typechecker.c | 65 ++++++++++++------ ...21500Z-fixed-effect-tag-analysis-limits.md | 3 + .../test_effect_tag_limits.sh | 67 +++++++++++++++++++ 3 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 new_changelogs/20261029T121500Z-fixed-effect-tag-analysis-limits.md create mode 100755 tests/integration/effect_tag_limits/test_effect_tag_limits.sh diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 7533c2850..5c68c2080 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -11848,10 +11848,20 @@ static ASTNode* effect_resolve_callee(ASTNode* program, const char* name) { /* Walk `node`; return the first forbidden capability reached (recursing into * user-function callees), writing the offending `ns.fn` spelling to `outcall`. * `forbid` is the comma list of forbidden caps; `visited` guards recursion. */ +enum { + EFFECT_MAX_DEPTH = 128, + EFFECT_MAX_VISITED = 512 +}; + static const char* effect_scan(ASTNode* program, ASTNode* node, const char* forbid, const char** visited, int* nvisited, - char* outcall, size_t outsz, int depth) { - if (!node || depth > 128) return NULL; + char* outcall, size_t outsz, int depth, + int* incomplete) { + if (!node) return NULL; + if (depth > EFFECT_MAX_DEPTH) { + *incomplete = 1; + return NULL; + } if (node->type == AST_FUNCTION_CALL && node->value) { const char* dot = strchr(node->value, '.'); if (dot) { @@ -11873,21 +11883,26 @@ static const char* effect_scan(ASTNode* program, ASTNode* node, const char* forb int seen = 0; for (int i = 0; i < *nvisited; i++) if (strcmp(visited[i], callee) == 0) { seen = 1; break; } - if (!seen && *nvisited < 512) { + if (!seen) { if (def && def->child_count > 0) { - visited[(*nvisited)++] = callee; - for (int i = 0; i < program->child_count; i++) { - ASTNode* clause = program->children[i]; - if (!clause || - (clause->type != AST_FUNCTION_DEFINITION && - clause->type != AST_BUILDER_FUNCTION) || - !clause->value || strcmp(clause->value, callee) != 0 || - clause->child_count == 0) - continue; - ASTNode* body = clause->children[clause->child_count - 1]; - const char* c = effect_scan(program, body, forbid, visited, - nvisited, outcall, outsz, depth + 1); - if (c) return c; + if (*nvisited >= EFFECT_MAX_VISITED) { + *incomplete = 1; + } else { + visited[(*nvisited)++] = callee; + for (int i = 0; i < program->child_count; i++) { + ASTNode* clause = program->children[i]; + if (!clause || + (clause->type != AST_FUNCTION_DEFINITION && + clause->type != AST_BUILDER_FUNCTION) || + !clause->value || strcmp(clause->value, callee) != 0 || + clause->child_count == 0) + continue; + ASTNode* body = clause->children[clause->child_count - 1]; + const char* c = effect_scan(program, body, forbid, visited, + nvisited, outcall, outsz, depth + 1, + incomplete); + if (c) return c; + } } } } @@ -11895,7 +11910,7 @@ static const char* effect_scan(ASTNode* program, ASTNode* node, const char* forb } for (int i = 0; i < node->child_count; i++) { const char* c = effect_scan(program, node->children[i], forbid, visited, - nvisited, outcall, outsz, depth); + nvisited, outcall, outsz, depth, incomplete); if (c) return c; } return NULL; @@ -11914,11 +11929,12 @@ static void check_effect_tags(ASTNode* program) { if (!eff) continue; const char* forbid = eff + 7; /* "fs,net,os" or a subset */ if (fn->child_count == 0) continue; - const char* visited[512]; + const char* visited[EFFECT_MAX_VISITED]; int nv = 0; if (fn->value) visited[nv++] = fn->value; /* no self-recursion loop */ char offending[160]; offending[0] = '\0'; + int incomplete = 0; const char* cap = NULL; for (int j = 0; j < program->child_count && !cap; j++) { ASTNode* clause = program->children[j]; @@ -11930,7 +11946,7 @@ static void check_effect_tags(ASTNode* program) { continue; ASTNode* body = clause->children[clause->child_count - 1]; cap = effect_scan(program, body, forbid, visited, &nv, - offending, sizeof(offending), 0); + offending, sizeof(offending), 0, &incomplete); } if (cap) { /* #2729: named as the source spells it (`checks.shell`), not by @@ -11945,6 +11961,17 @@ static void check_effect_tags(ASTNode* program) { g_tc_file = fn->source_file; /* #2064: the definition's own file */ type_error(msg, fn->line, fn->column); g_tc_file = NULL; + } else if (incomplete) { + char shown[160]; + const char* name = ast_source_name(fn, shown, sizeof(shown)); + char msg[400]; + snprintf(msg, sizeof(msg), + "function '%s' carries an effect tag, but effect analysis exceeded " + "its call-graph limit (%d levels or %d functions). Validation is refused.", + name, EFFECT_MAX_DEPTH, EFFECT_MAX_VISITED); + g_tc_file = fn->source_file; + type_error(msg, fn->line, fn->column); + g_tc_file = NULL; } } } diff --git a/new_changelogs/20261029T121500Z-fixed-effect-tag-analysis-limits.md b/new_changelogs/20261029T121500Z-fixed-effect-tag-analysis-limits.md new file mode 100644 index 000000000..cc4824f9a --- /dev/null +++ b/new_changelogs/20261029T121500Z-fixed-effect-tag-analysis-limits.md @@ -0,0 +1,3 @@ +- **Effect-tag validation now fails closed at analyzer limits.** A call graph + deeper than 128 levels or wider than the 512-function visited set is + rejected instead of treating the unexamined path as capability-free. diff --git a/tests/integration/effect_tag_limits/test_effect_tag_limits.sh b/tests/integration/effect_tag_limits/test_effect_tag_limits.sh new file mode 100755 index 000000000..00b769292 --- /dev/null +++ b/tests/integration/effect_tag_limits/test_effect_tag_limits.sh @@ -0,0 +1,67 @@ +#!/bin/sh +# Effect-tag validation must fail closed when a call graph exceeds the +# analyzer's bounded recursion or visited-function storage. +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -x "$AE" ] || { echo " [SKIP] effect_tag_limits: $AE not built"; exit 0; } + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +deep="$TMPDIR/deep.ae" +{ + printf 'import std.os\n\n' + printf 'deep130() -> os.system("echo hi")\n' + i=129 + while [ "$i" -ge 0 ]; do + next=$((i + 1)) + [ "$i" -eq 0 ] && printf '\n@no_os\n' + printf 'deep%s() -> deep%s()\n' "$i" "$next" + i=$((i - 1)) + done + printf '\nmain() { deep0() }\n' +} >"$deep" + +wide="$TMPDIR/wide.ae" +{ + printf 'import std.os\n\n' + i=0 + while [ "$i" -lt 511 ]; do + printf 'leaf%s() -> 0\n' "$i" + i=$((i + 1)) + done + printf 'leaf511() -> os.system("echo hi")\n\n' + printf '@no_os\nwide() {\n' + i=0 + while [ "$i" -lt 512 ]; do + printf ' leaf%s()\n' "$i" + i=$((i + 1)) + done + printf '}\n\nmain() { wide() }\n' +} >"$wide" + +failed=0 +check_rejected() { + name="$1" + src="$2" + log="$TMPDIR/$name.log" + if "$AE" build "$src" -o "$TMPDIR/$name" >"$log" 2>&1; then + echo " [FAIL] effect_tag_limits: $name compiled after analysis stopped" + failed=1 + return + fi + if ! grep -q "effect analysis exceeded its call-graph limit" "$log"; then + echo " [FAIL] effect_tag_limits: $name failed without the analysis-limit diagnostic" + sed 's/^/ /' "$log" | head -10 + failed=1 + fi +} + +check_rejected deep "$deep" +check_rejected wide "$wide" +[ "$failed" -eq 0 ] || exit 1 + +echo " [PASS] effect_tag_limits: depth and visited-function limits fail closed" From 7a526227b3164b75d3584ee5e3848e8cd7ebca1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:52:53 +0300 Subject: [PATCH 03/13] fix(compiler): enforce hide and seal on destructuring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- compiler/analysis/typechecker.c | 17 +++++++++ ...029T123000Z-fixed-hide-seal-destructure.md | 3 ++ .../hide_seal_directives/test_hide_reject.sh | 38 +++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 5c68c2080..5602191ec 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -14265,6 +14265,23 @@ int typecheck_statement(ASTNode* stmt, SymbolTable* table) { var->node_type = clone_type(rhs_type->tuple_types[j]); // Don't register _ (discard) in symbol table if (var->value && strcmp(var->value, "_") != 0) { + /* A destructure slot re-binds a visible name, or declares + * a new local. It must not recover an outer binding that + * this scope hid or sealed out. A local already visible + * below the directive remains a valid re-bind, and a + * genuinely new local remains valid in a sealed block. */ + Symbol* visible = lookup_symbol(table, var->value); + if (!visible && + (scope_name_is_hidden(table, var->value) || + name_blocked_by_hide(table, var->value))) { + char msg[256]; + snprintf(msg, sizeof(msg), + "'%s' is hidden in this scope by `hide` or `seal except`", + var->value); + type_error(msg, var->line, var->column); + slots_ok = 0; + continue; + } /* #2762. A refused slot is bound already; the others are * still registered, so their uses report nothing more. */ if (!destructure_slot_rebind(var, table, stmt->children[var_count])) { diff --git a/new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md b/new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md new file mode 100644 index 000000000..785fc3d0b --- /dev/null +++ b/new_changelogs/20261029T123000Z-fixed-hide-seal-destructure.md @@ -0,0 +1,3 @@ +- **Tuple destructuring now respects lexical containment.** A slot can no + longer re-bind an outer name excluded by `hide` or `seal except`; visible + locals and new locals inside a sealed block keep their existing behavior. diff --git a/tests/integration/hide_seal_directives/test_hide_reject.sh b/tests/integration/hide_seal_directives/test_hide_reject.sh index fb4a8c07b..db2c712a8 100755 --- a/tests/integration/hide_seal_directives/test_hide_reject.sh +++ b/tests/integration/hide_seal_directives/test_hide_reject.sh @@ -332,6 +332,44 @@ main() { EOF run_reject "hide blocks DSL receiver fallback across nested scopes" $AE_TMP/ae_hide_dsl_receiver_outer.ae +# Cases 20-22: tuple destructuring is a write to every named slot. It must +# not re-bind an outer name that the current scope hid or sealed out. +cat > $AE_TMP/ae_hide_destructure.ae << 'EOF' +pair() -> (string, int) { return "changed", 1 } +main() { + secret = "original" + { + hide secret + secret, _ = pair() + } +} +EOF +run_reject "hide blocks tuple-destructure write" $AE_TMP/ae_hide_destructure.ae + +cat > $AE_TMP/ae_seal_destructure.ae << 'EOF' +pair() -> (string, int) { return "changed", 1 } +main() { + secret = "original" + { + seal except pair + secret, _ = pair() + } +} +EOF +run_reject "seal except blocks tuple-destructure write" $AE_TMP/ae_seal_destructure.ae + +cat > $AE_TMP/ae_seal_local_destructure.ae << 'EOF' +pair() -> (string, int) { return "changed", 1 } +main() { + { + seal except pair + local = "original" + local, fresh = pair() + } +} +EOF +run_accept "seal except permits local tuple-destructure bindings" $AE_TMP/ae_seal_local_destructure.ae + # Cleanup rm -f $AE_TMP/ae_hide_*.ae $AE_TMP/ae_seal_*.ae $AE_TMP/ae_hide_out From e7dc33aaba6552b3bf8dfda8854dd8b0749ae240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 11:57:44 +0300 Subject: [PATCH 04/13] fix(compiler): retain enum prefixes for containment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- compiler/analysis/typechecker.c | 40 ++++++++++- ...9T124500Z-fixed-enum-containment-prefix.md | 3 + .../enum_containment/test_enum_containment.sh | 71 +++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261029T124500Z-fixed-enum-containment-prefix.md create mode 100755 tests/integration/enum_containment/test_enum_containment.sh diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 5602191ec..24e8d8492 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -12485,6 +12485,26 @@ static void enum_rewrite_ast(ASTNode* nd, const char** names, int n) { enum_rewrite_ast(nd->children[i], names, n); } +/* A qualified enum member is lowered to one identifier before lexical scopes + * are checked. Preserve the prefix the source wrote so hide/seal decisions use + * `Color`, not the generated `Color_Red` constant name. */ +static int enum_member_source_prefix(const ASTNode* nd, char* out, size_t outsz) { + static const char marker[] = "enum_prefix:"; + if (!nd || !nd->annotation || !out || outsz == 0) return 0; + size_t marker_len = sizeof(marker) - 1; + for (const char* p = nd->annotation; (p = strstr(p, marker)) != NULL; p += marker_len) { + if (p != nd->annotation && p[-1] != ';') continue; + const char* value = p + marker_len; + const char* end = strchr(value, ';'); + size_t len = end ? (size_t)(end - value) : strlen(value); + if (len == 0 || len >= outsz) return 0; + memcpy(out, value, len); + out[len] = '\0'; + return 1; + } + return 0; +} + // #1044: rewrite `EnumName.Member` member-access nodes into a plain identifier // naming the C enum constant `EnumName_Member`, with the enum's type. Done up // front (in the resolve pass) so the identifier is in place before the @@ -12504,6 +12524,9 @@ static void enum_rewrite_member_access(ASTNode* nd) { } else { char qualified[512]; snprintf(qualified, sizeof(qualified), "%s_%s", ename, nd->value); + char marker[512]; + snprintf(marker, sizeof(marker), "enum_prefix:%s", ename); + nd->annotation = annotation_add_marker(nd->annotation, marker); Type* et = create_type(TYPE_ENUM); et->struct_name = strdup(ename); free(nd->value); @@ -16885,7 +16908,22 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { return typecheck_function_call(expr, table); case AST_IDENTIFIER: { - Symbol* symbol = lookup_symbol(table, expr->value); + char enum_prefix[512]; + int qualified_enum = enum_member_source_prefix(expr, enum_prefix, + sizeof(enum_prefix)); + if (qualified_enum && qualified_prefix_blocked(table, enum_prefix)) { + char error_msg[256]; + snprintf(error_msg, sizeof(error_msg), + "'%s' is hidden in this scope by `hide` or `seal except`", + enum_prefix); + type_error(error_msg, expr->line, expr->column); + return 0; + } + /* The source prefix passed containment. Resolve the generated + * constant without applying hide/seal a second time to its C name; + * `seal except Color` grants `Color.Red`, not `Color_Red`. */ + Symbol* symbol = qualified_enum ? lookup_member_symbol(table, expr->value) + : lookup_symbol(table, expr->value); if (!symbol) { char error_msg[256]; if (expr->value && name_blocked_by_hide(table, expr->value)) { diff --git a/new_changelogs/20261029T124500Z-fixed-enum-containment-prefix.md b/new_changelogs/20261029T124500Z-fixed-enum-containment-prefix.md new file mode 100644 index 000000000..71f811fb4 --- /dev/null +++ b/new_changelogs/20261029T124500Z-fixed-enum-containment-prefix.md @@ -0,0 +1,3 @@ +- **Qualified enum members now retain their lexical prefix.** `hide Color` + blocks `Color.Red`, while `seal except Color` permits it; containment no + longer checks the generated `Color_Red` constant name. diff --git a/tests/integration/enum_containment/test_enum_containment.sh b/tests/integration/enum_containment/test_enum_containment.sh new file mode 100755 index 000000000..d8a1a0452 --- /dev/null +++ b/tests/integration/enum_containment/test_enum_containment.sh @@ -0,0 +1,71 @@ +#!/bin/sh +# Qualified enum members retain their written prefix through enum lowering so +# lexical hide/seal checks see the same name the source used. +set -eu + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +AE="$ROOT/build/ae" +[ -x "$AE" ] || { echo " [SKIP] enum_containment: $AE not built"; exit 0; } + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +cat >"$TMPDIR/hide.ae" <<'AE' +enum Color { Red, Blue } +main() { + { + hide Color + c = Color.Red + } +} +AE + +cat >"$TMPDIR/seal.ae" <<'AE' +enum Color { Red, Blue } +main() { + keep = 1 + { + seal except keep + c = Color.Red + } +} +AE + +cat >"$TMPDIR/allowed.ae" <<'AE' +enum Color { Red, Blue } +main() { + { + seal except Color + c = Color.Red + } +} +AE + +failed=0 +reject() { + name="$1" + if "$AE" build "$TMPDIR/$name.ae" -o "$TMPDIR/$name" >"$TMPDIR/$name.log" 2>&1; then + echo " [FAIL] enum_containment: $name accepted a blocked enum prefix" + failed=1 + return + fi + if ! grep -q "'Color' is hidden in this scope" "$TMPDIR/$name.log"; then + echo " [FAIL] enum_containment: $name failed without the containment diagnostic" + sed 's/^/ /' "$TMPDIR/$name.log" | head -10 + failed=1 + fi +} + +reject hide +reject seal + +if ! "$AE" build "$TMPDIR/allowed.ae" -o "$TMPDIR/allowed" >"$TMPDIR/allowed.log" 2>&1; then + echo " [FAIL] enum_containment: whitelisted enum prefix was rejected" + sed 's/^/ /' "$TMPDIR/allowed.log" | head -10 + failed=1 +fi + +[ "$failed" -eq 0 ] || exit 1 + +echo " [PASS] enum_containment: hidden and sealed prefixes reject; whitelist accepts" From 1a83fe0e49b338a751db30ed9a733c5ffc39e9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 12:32:56 +0300 Subject: [PATCH 05/13] runtime: keep audit entry fields on one snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I reproduced the wrapped audit ring advancing between the category, resource, and verdict accessors, which combined fields from different entries. Copy the selected entry under the ring lock and keep the accessor sequence in thread-local storage. Add a synchronized overwrite regression test for the std.audit read order. Signed-off-by: Adil Alperen Çiftci --- ...T093143Z-security-audit-entry-snapshots.md | 5 ++ runtime/sandbox/aether_audit.c | 53 +++++++++++---- runtime/sandbox/aether_audit.h | 9 ++- std/audit/module.ae | 5 +- .../audit_entry_snapshot/harness.c | 64 +++++++++++++++++++ .../test_audit_entry_snapshot.sh | 41 ++++++++++++ 6 files changed, 157 insertions(+), 20 deletions(-) create mode 100644 new_changelogs/20261029T093143Z-security-audit-entry-snapshots.md create mode 100644 tests/integration/audit_entry_snapshot/harness.c create mode 100755 tests/integration/audit_entry_snapshot/test_audit_entry_snapshot.sh diff --git a/new_changelogs/20261029T093143Z-security-audit-entry-snapshots.md b/new_changelogs/20261029T093143Z-security-audit-entry-snapshots.md new file mode 100644 index 000000000..c1fff7254 --- /dev/null +++ b/new_changelogs/20261029T093143Z-security-audit-entry-snapshots.md @@ -0,0 +1,5 @@ +- **`std.audit` now returns coherent entries during concurrent writes.** Its + field accessors previously released the ring lock between category, + resource, and verdict reads, so a wrapped ring could advance and combine + fields from different entries. Each accessor sequence now reads one + thread-local snapshot. diff --git a/runtime/sandbox/aether_audit.c b/runtime/sandbox/aether_audit.c index 8dbce0429..63a768238 100644 --- a/runtime/sandbox/aether_audit.c +++ b/runtime/sandbox/aether_audit.c @@ -40,6 +40,17 @@ static audit_entry ring[AUDIT_RING_CAP]; static int ring_head = 0; // index of next write static int ring_count = 0; // entries currently held (<= AUDIT_RING_CAP) +/* Field accessors form one entry read in category/resource/allowed order. + * Keep that read in thread-local storage so a concurrent append cannot move + * the logical index between fields. This object is linked into executables + * and emit-lib shared objects, so it needs the shared-library-safe TLS model. */ +#define QUERY_NONE 0 +#define QUERY_CATEGORY 1 +#define QUERY_RESOURCE 2 +static AETHER_TLS_SHARED audit_entry query_snapshot; +static AETHER_TLS_SHARED int query_index = -1; +static AETHER_TLS_SHARED int query_phase = QUERY_NONE; + // One mutex guards both the sink-mode resolution and the ring buffer. // Audit is off the hot path of normal execution (it fires only inside // a sandbox block), so a single coarse lock is fine. @@ -140,6 +151,16 @@ static int logical_to_physical(int i) { return (ring_head + i) % AUDIT_RING_CAP; // wrapped } +// Copy one logical entry while the caller holds audit_lock. +static void snapshot_entry_locked(int i) { + memset(&query_snapshot, 0, sizeof(query_snapshot)); + query_snapshot.allowed = -1; + if (i >= 0 && i < ring_count) { + query_snapshot = ring[logical_to_physical(i)]; + } + query_index = i; +} + int aether_audit_count(void) { pthread_mutex_lock(audit_lock_get()); int n = ring_count; @@ -149,26 +170,32 @@ int aether_audit_count(void) { const char* aether_audit_entry_category(int i) { pthread_mutex_lock(audit_lock_get()); - const char* r = ""; - if (i >= 0 && i < ring_count) r = ring[logical_to_physical(i)].cat; + snapshot_entry_locked(i); pthread_mutex_unlock(audit_lock_get()); - return r; + query_phase = QUERY_CATEGORY; + return query_snapshot.cat; } const char* aether_audit_entry_resource(int i) { - pthread_mutex_lock(audit_lock_get()); - const char* r = ""; - if (i >= 0 && i < ring_count) r = ring[logical_to_physical(i)].res; - pthread_mutex_unlock(audit_lock_get()); - return r; + if (query_phase != QUERY_CATEGORY || query_index != i) { + pthread_mutex_lock(audit_lock_get()); + snapshot_entry_locked(i); + pthread_mutex_unlock(audit_lock_get()); + } + query_phase = QUERY_RESOURCE; + return query_snapshot.res; } int aether_audit_entry_allowed(int i) { - pthread_mutex_lock(audit_lock_get()); - int r = -1; - if (i >= 0 && i < ring_count) r = ring[logical_to_physical(i)].allowed; - pthread_mutex_unlock(audit_lock_get()); - return r; + if ((query_phase != QUERY_CATEGORY && query_phase != QUERY_RESOURCE) || + query_index != i) { + pthread_mutex_lock(audit_lock_get()); + snapshot_entry_locked(i); + pthread_mutex_unlock(audit_lock_get()); + } + int allowed = query_snapshot.allowed; + query_phase = QUERY_NONE; + return allowed; } void aether_audit_clear(void) { diff --git a/runtime/sandbox/aether_audit.h b/runtime/sandbox/aether_audit.h index 3584c7a70..bb1445f60 100644 --- a/runtime/sandbox/aether_audit.h +++ b/runtime/sandbox/aether_audit.h @@ -31,11 +31,10 @@ void aether_sandbox_audit(const char* category, const char* resource, int aether_audit_count(void); // Field accessors for entry `i` (0-based, oldest first). Out-of-range -// `i` yields "" / -1. The returned string points into the ring buffer -// and stays valid only until that slot is overwritten by a later -// aether_sandbox_audit() call or dropped by aether_audit_clear(). A -// caller that records audit entries concurrently with querying should -// copy the string promptly; the std.audit wrappers do. +// `i` yields "" / -1. Reading category, resource, then allowed forms one +// per-thread snapshot, so a concurrent append cannot mix fields from +// different entries. The returned strings stay valid until the next category +// or resource accessor on the same thread; the std.audit wrappers copy them. const char* aether_audit_entry_category(int i); const char* aether_audit_entry_resource(int i); int aether_audit_entry_allowed(int i); // 1 allowed, 0 denied diff --git a/std/audit/module.ae b/std/audit/module.ae index a3f84dd6a..b3765d7a3 100644 --- a/std/audit/module.ae +++ b/std/audit/module.ae @@ -44,8 +44,9 @@ extern string_concat(a: string, b: string) -> string @heap extern aether_audit_count() -> int // Field accessors for buffered entry `i` (0-based, oldest first). -// The category/resource strings are borrowed — copy before the slot -// is overwritten. The `entry()` wrapper below does that for you. +// Category, resource, and allowed share one per-thread snapshot when called +// in that order. The strings are borrowed from that snapshot; the `entry()` +// wrapper below copies them before beginning another read. extern aether_audit_entry_category(i: int) -> string extern aether_audit_entry_resource(i: int) -> string extern aether_audit_entry_allowed(i: int) -> int // 1 allowed, 0 denied, -1 out of range diff --git a/tests/integration/audit_entry_snapshot/harness.c b/tests/integration/audit_entry_snapshot/harness.c new file mode 100644 index 000000000..615e3f0ee --- /dev/null +++ b/tests/integration/audit_entry_snapshot/harness.c @@ -0,0 +1,64 @@ +#include +#include +#include + +#include "aether_audit.h" + +#define RING_CAP 256 + +static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t changed = PTHREAD_COND_INITIALIZER; +static int category_read; +static int entry_overwritten; + +static void* overwrite_oldest(void* unused) { + (void)unused; + + pthread_mutex_lock(&lock); + while (!category_read) pthread_cond_wait(&changed, &lock); + pthread_mutex_unlock(&lock); + + aether_sandbox_audit("cat-new", "res-new", 1); + + pthread_mutex_lock(&lock); + entry_overwritten = 1; + pthread_cond_signal(&changed); + pthread_mutex_unlock(&lock); + return NULL; +} + +int main(void) { + aether_audit_clear(); + for (int i = 0; i < RING_CAP; i++) { + char category[32]; + char resource[32]; + snprintf(category, sizeof(category), "cat-%03d", i); + snprintf(resource, sizeof(resource), "res-%03d", i); + aether_sandbox_audit(category, resource, i & 1); + } + + pthread_t writer; + if (pthread_create(&writer, NULL, overwrite_oldest, NULL) != 0) return 2; + + const char* category = aether_audit_entry_category(0); + + pthread_mutex_lock(&lock); + category_read = 1; + pthread_cond_signal(&changed); + while (!entry_overwritten) pthread_cond_wait(&changed, &lock); + pthread_mutex_unlock(&lock); + + const char* resource = aether_audit_entry_resource(0); + int allowed = aether_audit_entry_allowed(0); + pthread_join(writer, NULL); + + if (strcmp(category, "cat-000") != 0 || + strcmp(resource, "res-000") != 0 || allowed != 0) { + fprintf(stderr, "FAIL mixed audit entry: category=%s resource=%s allowed=%d\n", + category, resource, allowed); + return 1; + } + + puts("PASS: audit entry fields come from one snapshot"); + return 0; +} diff --git a/tests/integration/audit_entry_snapshot/test_audit_entry_snapshot.sh b/tests/integration/audit_entry_snapshot/test_audit_entry_snapshot.sh new file mode 100755 index 000000000..e5d7c192f --- /dev/null +++ b/tests/integration/audit_entry_snapshot/test_audit_entry_snapshot.sh @@ -0,0 +1,41 @@ +#!/bin/sh +# Reading one audit entry must not combine fields from different ring slots +# when another thread overwrites the oldest entry between accessor calls. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD|Darwin) ;; + *) + echo " [SKIP] audit_entry_snapshot: POSIX thread harness" + exit 0 ;; +esac +if [ ! -f "$ROOT/build/libaether.a" ]; then + echo " [SKIP] audit_entry_snapshot: build/libaether.a not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror \ + -I"$ROOT/runtime/sandbox" -o "$TMP/harness" "$SCRIPT_DIR/harness.c" \ + "$ROOT/build/libaether.a" -lpthread -lm 2>"$TMP/cc.err"; then + echo " [FAIL] audit_entry_snapshot: harness did not compile" + sed 's/^/ /' "$TMP/cc.err" | head -10 + exit 1 +fi + +out="$($TMP/harness 2>&1)" +rc=$? +if [ "$rc" -eq 0 ] && printf '%s' "$out" | grep -q \ + '^PASS: audit entry fields come from one snapshot$'; then + echo " [PASS] audit entry fields come from one snapshot" + echo "PASS: audit_entry_snapshot" + exit 0 +fi + +echo " [FAIL] audit entry fields came from different snapshots" +printf '%s\n' "$out" | sed 's/^/ /' | head -10 +echo "FAIL: audit_entry_snapshot" +exit 1 From b32ce7b536d0754a9579528e41aa180690121e96 Mon Sep 17 00:00:00 2001 From: Adil Alperen Ciftci Date: Thu, 29 Oct 2026 11:34:58 +0300 Subject: [PATCH 06/13] runtime: intersect embedded and preload sandbox policies Treat in-process grant levels and preload grants as independent boundaries. Continue to file or shared-memory grants after an in-process allow, while denying immediately when either policy refuses the operation. Update the regression harness to exercise the current runtime hooks and cover both policy orderings. Signed-off-by: Adil Alperen Ciftci --- ...0Z-security-sandbox-policy-intersection.md | 3 + runtime/libaether_sandbox_preload.c | 11 ++-- .../sandbox_policy_intersection/harness.c | 47 ++++++++++++++ .../test_sandbox_policy_intersection.sh | 62 +++++++++++++++++++ 4 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 new_changelogs/20261029T083420Z-security-sandbox-policy-intersection.md create mode 100644 tests/integration/sandbox_policy_intersection/harness.c create mode 100644 tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh diff --git a/new_changelogs/20261029T083420Z-security-sandbox-policy-intersection.md b/new_changelogs/20261029T083420Z-security-sandbox-policy-intersection.md new file mode 100644 index 000000000..c1d6e0c91 --- /dev/null +++ b/new_changelogs/20261029T083420Z-security-sandbox-policy-intersection.md @@ -0,0 +1,3 @@ +- **Embedded and preload sandbox policies now intersect.** An allowing + in-process checker no longer bypasses a stricter shared-memory or file grant + list; an operation must satisfy every active containment layer. diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index 5c646380f..029b6634b 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -104,15 +104,16 @@ static int check_grant(const char* category, const char* resource) { match_target = resolved; } - // In-process levels take priority (embedded Python mode) + // An embedded policy and preload grants are independent boundaries. + // When both are active, the operation must satisfy both policies. if (aether_sandbox_check_resolved) { int result = aether_sandbox_check_resolved(category, match_target); - if (result >= 0) { - if (!result) log_deny(category, match_target); - return result; + if (result == 0) { + log_deny(category, match_target); + return 0; } } - // Fall back to file-based grants (LD_PRELOAD mode) + // Apply file/shared-memory grants as the second policy when active. if (!sandbox_active) return 1; for (int i = 0; i < grant_count; i++) { if (grants[i].cat[0] == '*' && grants[i].pat[0] == '*') return 1; diff --git a/tests/integration/sandbox_policy_intersection/harness.c b/tests/integration/sandbox_policy_intersection/harness.c new file mode 100644 index 000000000..d7548c2bd --- /dev/null +++ b/tests/integration/sandbox_policy_intersection/harness.c @@ -0,0 +1,47 @@ +#include +#include +#include +#include +#include + +typedef int (*aether_sandbox_check_fn)(const char*, const char*); + +static int allow_check(const char* category, const char* resource) { + (void)category; + (void)resource; + return 1; +} + +static int deny_check(const char* category, const char* resource) { + (void)category; + (void)resource; + return 0; +} + +static aether_sandbox_check_fn active_check = allow_check; + +int aether_sandbox_check_resolved(const char* category, const char* resource) { + return active_check(category, resource); +} + +int aether_sandbox_levels_here(void) { + return 1; +} + +int main(int argc, char** argv) { + if (argc != 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 2; + } + active_check = strcmp(argv[1], "deny") == 0 + ? deny_check : allow_check; + + int fd = open(argv[2], O_RDONLY); + if (fd < 0) return errno == EACCES ? 3 : 4; + char buf[64]; + ssize_t n = read(fd, buf, sizeof buf); + close(fd); + if (n < 0) return 5; + if (write(STDOUT_FILENO, buf, (size_t)n) != n) return 6; + return 0; +} diff --git a/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh b/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh new file mode 100644 index 000000000..10c3e16a2 --- /dev/null +++ b/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# When both the embedded checker and preload grants are active, an operation +# must satisfy both policies. Neither policy may replace the other. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LIB="$ROOT/build/libaether_sandbox.so" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_policy_intersection: preload is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$LIB" ]; then + echo " [SKIP] sandbox_policy_intersection: preload library not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +if ! ${CC:-cc} -std=c11 -Wall -Wextra -Werror -rdynamic \ + "$SCRIPT_DIR/harness.c" -o "$TMP/harness"; then + echo " [FAIL] sandbox_policy_intersection: harness did not compile" + exit 1 +fi + +printf 'intersection sentinel\n' > "$TMP/data.txt" +printf 'exec:/not-used\n' > "$TMP/deny-read.grants" +printf 'fs_read:%s\n' "$TMP/data.txt" > "$TMP/allow-read.grants" +fail=0 + +run_case() { + label="$1" + checker="$2" + grants="$3" + expected="$4" + if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$grants" \ + AETHER_SANDBOX_LOG=none "$TMP/harness" "$checker" "$TMP/data.txt" \ + >"$TMP/out" 2>"$TMP/err"; then + got=allow + else + got=deny + fi + if [ "$got" = "$expected" ]; then + echo " [PASS] $label" + else + echo " [FAIL] $label: got $got, expected $expected" + fail=1 + fi +} + +run_case "preload denial survives an allowing embedded checker" \ + allow "$TMP/deny-read.grants" deny +run_case "embedded denial survives an allowing preload policy" \ + deny "$TMP/allow-read.grants" deny +run_case "both policies allowing permits the read" \ + allow "$TMP/allow-read.grants" allow + +echo "" +echo "sandbox_policy_intersection: $([ "$fail" -eq 0 ] && echo PASS || echo FAIL)" +[ "$fail" -eq 0 ] From d46ff41b16f15bb4989b8dd8c3a10f2ece97fe24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 12:06:27 +0300 Subject: [PATCH 07/13] runtime: honor zero sandbox verbosity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Adil Alperen Çiftci --- ...1029T090501Z-fixed-sandbox-verbose-zero.md | 3 ++ runtime/libaether_sandbox_preload.c | 3 +- .../test_sandbox_verbose_flag.sh | 47 +++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 new_changelogs/20261029T090501Z-fixed-sandbox-verbose-zero.md create mode 100755 tests/integration/sandbox_verbose_flag/test_sandbox_verbose_flag.sh diff --git a/new_changelogs/20261029T090501Z-fixed-sandbox-verbose-zero.md b/new_changelogs/20261029T090501Z-fixed-sandbox-verbose-zero.md new file mode 100644 index 000000000..f258c72a4 --- /dev/null +++ b/new_changelogs/20261029T090501Z-fixed-sandbox-verbose-zero.md @@ -0,0 +1,3 @@ +- **A zero sandbox verbosity flag stays quiet.** + `AETHER_SANDBOX_VERBOSE=0`, the default supplied to sandboxed children, no + longer overrides the selected log mode or enables stderr diagnostics. diff --git a/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index 029b6634b..92085697e 100644 --- a/runtime/libaether_sandbox_preload.c +++ b/runtime/libaether_sandbox_preload.c @@ -178,7 +178,8 @@ static void __attribute__((constructor)) sandbox_init(void) { else log_mode = LOG_FILE; } // Legacy: AETHER_SANDBOX_VERBOSE=1 sets stderr mode - if (real_getenv("AETHER_SANDBOX_VERBOSE")) log_mode = LOG_STDERR; + const char* verbose_env = real_getenv("AETHER_SANDBOX_VERBOSE"); + if (verbose_env && strcmp(verbose_env, "1") == 0) log_mode = LOG_STDERR; // An explicitly configured source is a containment request. If every // configured source fails to load, leave the sandbox active with zero diff --git a/tests/integration/sandbox_verbose_flag/test_sandbox_verbose_flag.sh b/tests/integration/sandbox_verbose_flag/test_sandbox_verbose_flag.sh new file mode 100755 index 000000000..be939a9e3 --- /dev/null +++ b/tests/integration/sandbox_verbose_flag/test_sandbox_verbose_flag.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# AETHER_SANDBOX_VERBOSE is a legacy boolean flag. Only the value 1 may +# override AETHER_SANDBOX_LOG; the zero value set by spawn_sandboxed is quiet. + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +LIB="$ROOT/build/libaether_sandbox.so" + +case "$(uname -s 2>/dev/null)" in + Linux|FreeBSD) ;; + *) + echo " [SKIP] sandbox_verbose_flag: preload is Linux and FreeBSD only" + exit 0 ;; +esac +if [ ! -f "$LIB" ]; then + echo " [SKIP] sandbox_verbose_flag: preload library not built" + exit 0 +fi + +TMP="$(cd "$(mktemp -d)" && pwd -P)" +trap 'rm -rf "$TMP"' EXIT +: > "$TMP/empty.grants" +fail=0 + +LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/empty.grants" \ + AETHER_SANDBOX_LOG=none AETHER_SANDBOX_VERBOSE=0 \ + /bin/true 2>"$TMP/zero.err" +if grep -q '^AETHER_SANDBOX:' "$TMP/zero.err"; then + echo " [FAIL] AETHER_SANDBOX_VERBOSE=0 enabled stderr logging" + fail=1 +else + echo " [PASS] AETHER_SANDBOX_VERBOSE=0 remains quiet" +fi + +LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$TMP/empty.grants" \ + AETHER_SANDBOX_LOG=none AETHER_SANDBOX_VERBOSE=1 \ + /bin/true 2>"$TMP/one.err" +if grep -q '^AETHER_SANDBOX: active with 0 grants$' "$TMP/one.err"; then + echo " [PASS] AETHER_SANDBOX_VERBOSE=1 enables stderr logging" +else + echo " [FAIL] AETHER_SANDBOX_VERBOSE=1 did not enable stderr logging" + fail=1 +fi + +echo "" +echo "sandbox_verbose_flag: $([ "$fail" -eq 0 ] && echo PASS || echo FAIL)" +[ "$fail" -eq 0 ] From 5f58e9f495e50feacdc5d18fd81b9b3e91a5d723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adil=20Alperen=20=C3=87iftci?= Date: Thu, 29 Oct 2026 13:02:09 +0300 Subject: [PATCH 08/13] tests: run shared-map hosts with sandbox ABI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compile the in-process bridges with sandbox checker support and export each detected runtime library path before execution. This lets versioned Debian and Ubuntu runtimes reach the grant-map assertions instead of failing during setup. Signed-off-by: Adil Alperen Çiftci --- ...29T102000Z-fixed-shared-map-host-test-build.md | 4 ++++ tests/sandbox/detect_host_langs.sh | 15 +++++++++++++++ tests/sandbox/test_shared_map_all.sh | 10 +++++----- 3 files changed, 24 insertions(+), 5 deletions(-) create mode 100644 new_changelogs/20261029T102000Z-fixed-shared-map-host-test-build.md diff --git a/new_changelogs/20261029T102000Z-fixed-shared-map-host-test-build.md b/new_changelogs/20261029T102000Z-fixed-shared-map-host-test-build.md new file mode 100644 index 000000000..e927408a7 --- /dev/null +++ b/new_changelogs/20261029T102000Z-fixed-shared-map-host-test-build.md @@ -0,0 +1,4 @@ +- **Shared-map host tests now exercise provisioned language runtimes.** The + Lua, JavaScript, Python, Perl, and Ruby bridges now compile with the + sandbox ABI enabled and receive their detected runtime library paths, + instead of failing before their grant-map checks run. diff --git a/tests/sandbox/detect_host_langs.sh b/tests/sandbox/detect_host_langs.sh index a9c805edc..78a049409 100644 --- a/tests/sandbox/detect_host_langs.sh +++ b/tests/sandbox/detect_host_langs.sh @@ -8,6 +8,9 @@ for v in lua5.4 lua5.3 lua5.2; do if pkg-config --exists $v 2>/dev/null; then LUA_CFLAGS=$(pkg-config --cflags $v) LUA_LIBS=$(pkg-config --libs $v) + LUA_LIBNAME=$(pkg-config --libs-only-l $v | sed -n 's/.*-l\([^ ]*\).*/lib\1.so/p') + AETHER_LUA_SONAME=$(ldconfig -p 2>/dev/null | awk -v lib="$LUA_LIBNAME" '$1 == lib { print $NF; exit }') + export AETHER_LUA_SONAME LUA_CMD=$(echo $v | tr -d '.') # lua54, lua53, etc — some systems use this # Try common command names for cmd in $v lua; do @@ -26,6 +29,8 @@ for v in python3 python3.12 python3.11 python3.10; do PY_CFLAGS=$($cfg --includes 2>/dev/null) PY_LIBS="-l$(echo $v | tr -d .)" # -lpython311 etc doesn't work, try embed PY_LIBS=$($cfg --ldflags --embed 2>/dev/null || $cfg --ldflags 2>/dev/null) + AETHER_PYTHON_SONAME=$($v -c 'import os, sysconfig; print(os.path.join(sysconfig.get_config_var("LIBDIR") or "", sysconfig.get_config_var("INSTSONAME") or ""))' 2>/dev/null) + export AETHER_PYTHON_SONAME PY_AVAILABLE=1 break fi @@ -36,6 +41,8 @@ if [ $PY_AVAILABLE -eq 0 ]; then if pkg-config --exists $v 2>/dev/null; then PY_CFLAGS=$(pkg-config --cflags $v) PY_LIBS=$(pkg-config --libs $v) + AETHER_PYTHON_SONAME=$(python3 -c 'import os, sysconfig; print(os.path.join(sysconfig.get_config_var("LIBDIR") or "", sysconfig.get_config_var("INSTSONAME") or ""))' 2>/dev/null) + export AETHER_PYTHON_SONAME PY_AVAILABLE=1 break fi @@ -53,6 +60,8 @@ if perl -MExtUtils::Embed -e ccopts >/dev/null 2>&1; then PERL_SO=$(find /usr/lib -name "$PERL_LIBDIR" 2>/dev/null | head -1) if [ -n "$PERL_SO" ]; then PERL_LIBS="$PERL_SO -ldl -lm -lpthread -lcrypt" + AETHER_PERL_SONAME="$PERL_SO" + export AETHER_PERL_SONAME fi fi # Fallback: try -lperl @@ -68,6 +77,10 @@ for v in ruby-3.3 ruby-3.2 ruby-3.1 ruby-3.0 ruby; do if pkg-config --exists $v 2>/dev/null; then RUBY_CFLAGS=$(pkg-config --cflags $v) RUBY_LIBS=$(pkg-config --libs $v) + if command -v ruby >/dev/null 2>&1; then + AETHER_RUBY_SONAME=$(ruby -rrbconfig -e 'print File.join(RbConfig::CONFIG["libdir"], RbConfig::CONFIG["LIBRUBY_SO"])' 2>/dev/null) + export AETHER_RUBY_SONAME + fi RUBY_AVAILABLE=1 break fi @@ -78,6 +91,8 @@ JS_AVAILABLE=0 if [ -f /usr/include/duktape.h ]; then JS_CFLAGS="" JS_LIBS="-lduktape" + AETHER_DUKTAPE_SONAME=$(ldconfig -p 2>/dev/null | awk '/libduktape\.so/ { print $NF; exit }') + export AETHER_DUKTAPE_SONAME JS_AVAILABLE=1 fi diff --git a/tests/sandbox/test_shared_map_all.sh b/tests/sandbox/test_shared_map_all.sh index 5b4a59faa..43e02db05 100755 --- a/tests/sandbox/test_shared_map_all.sh +++ b/tests/sandbox/test_shared_map_all.sh @@ -39,7 +39,7 @@ LUA_OUT=$(gcc -o $AE_TMP/test_map_lua \ "$ROOT/contrib/host/lua/aether_host_lua.c" \ "$ROOT/runtime/aether_sandbox.c" \ "$ROOT/runtime/aether_shared_map.c" \ - $LUA_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_LUA \ + $LUA_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_LUA -DAETHER_HAS_SANDBOX \ -L"$ROOT/build" -laether $LUA_LIBS -ldl -lm -lrt \ -Wno-discarded-qualifiers \ -xc - 2>/dev/null << 'CEOF' @@ -88,7 +88,7 @@ JS_OUT=$(gcc -o $AE_TMP/test_map_js \ "$ROOT/contrib/host/duktape/aether_host_duktape.c" \ "$ROOT/runtime/aether_sandbox.c" \ "$ROOT/runtime/aether_shared_map.c" \ - $JS_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_DUKTAPE \ + $JS_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_DUKTAPE -DAETHER_HAS_SANDBOX \ -L"$ROOT/build" -laether $JS_LIBS -ldl -lm -lrt \ -Wno-discarded-qualifiers \ -xc - 2>/dev/null << 'CEOF' @@ -135,7 +135,7 @@ PY_OUT=$(gcc -o $AE_TMP/test_map_py \ "$ROOT/contrib/host/python/aether_host_python.c" \ "$ROOT/runtime/aether_sandbox.c" \ "$ROOT/runtime/aether_shared_map.c" \ - $PY_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_PYTHON \ + $PY_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_PYTHON -DAETHER_HAS_SANDBOX \ -L"$ROOT/build" -laether $PY_LIBS -ldl -lm -lrt \ -Wno-discarded-qualifiers \ -xc - 2>/dev/null << 'CEOF' @@ -186,7 +186,7 @@ PERL_OUT=$(gcc -o $AE_TMP/test_map_perl \ "$ROOT/contrib/host/perl/aether_host_perl.c" \ "$ROOT/runtime/aether_sandbox.c" \ "$ROOT/runtime/aether_shared_map.c" \ - $PERL_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_PERL \ + $PERL_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_PERL -DAETHER_HAS_SANDBOX \ -L"$ROOT/build" -laether $PERL_LIBS -lrt \ -Wno-discarded-qualifiers \ -xc - 2>/dev/null << 'CEOF' @@ -233,7 +233,7 @@ RUBY_OUT=$(gcc -o $AE_TMP/test_map_ruby \ "$ROOT/contrib/host/ruby/aether_host_ruby.c" \ "$ROOT/runtime/aether_sandbox.c" \ "$ROOT/runtime/aether_shared_map.c" \ - $RUBY_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_RUBY \ + $RUBY_CFLAGS -I"$ROOT/runtime" -DAETHER_HAS_RUBY -DAETHER_HAS_SANDBOX \ -L"$ROOT/build" -laether $RUBY_LIBS -ldl -lm -lrt \ -Wno-discarded-qualifiers \ -xc - 2>/dev/null << 'CEOF' From 8ece9838c7c529ffc4fdc58b1838d548b2b58234 Mon Sep 17 00:00:00 2001 From: Adil Alperen Ciftci Date: Thu, 29 Oct 2026 12:54:28 +0300 Subject: [PATCH 09/13] contrib.host.python: restore nested map context Preserve the previous shared-map token around nested run_sandboxed_with_map calls and restore it when the inner call returns. Reject an over-depth entry before activating or freezing the new map context. Add an embedded CPython regression covering the inner map, restored outer map, and final token cleanup. Signed-off-by: Adil Alperen Ciftci --- contrib/host/python/README.md | 8 +- contrib/host/python/aether_host_python.c | 7 +- ...9T101500Z-fixed-python-host-map-context.md | 4 + .../test_host_python_map_context.sh | 143 ++++++++++++++++++ 4 files changed, 158 insertions(+), 4 deletions(-) create mode 100644 new_changelogs/20261029T101500Z-fixed-python-host-map-context.md create mode 100755 tests/integration/host_python_map_context/test_host_python_map_context.sh diff --git a/contrib/host/python/README.md b/contrib/host/python/README.md index b1921e046..861b5340a 100644 --- a/contrib/host/python/README.md +++ b/contrib/host/python/README.md @@ -127,7 +127,7 @@ main() { ## Testing -Three tests exercise this host, each covering a different facet: +Four tests exercise this host, each covering a different facet: - **Namespace / FFI**: [`tests/integration/namespace_python/`](../../../tests/integration/namespace_python/), @@ -154,6 +154,12 @@ Three tests exercise this host, each covering a different facet: the write landed and the frozen input is untampered. SKIPs when the Python dev package isn't installed. +- **Nested shared-map context**: + [`tests/integration/host_python_map_context/`](../../../tests/integration/host_python_map_context/) + enters `run_sandboxed_with_map` recursively through the embedded CPython + process. It verifies that the inner run sees only its map, the outer map is + restored afterward, and no token remains active after the outer return. + - **Build smoke**: [`tests/scripts/contrib_build.sh`](../../../tests/scripts/contrib_build.sh), run by `make contrib`. It probes for Python via `python3-config` and, diff --git a/contrib/host/python/aether_host_python.c b/contrib/host/python/aether_host_python.c index ba3a66598..581fa59a6 100644 --- a/contrib/host/python/aether_host_python.c +++ b/contrib/host/python/aether_host_python.c @@ -264,17 +264,18 @@ int python_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_to if (python_init() != 0) return -1; register_map_module(); + if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; + // Freeze inputs and set active token extern void aether_shared_map_freeze_inputs_by_token(uint64_t); aether_shared_map_freeze_inputs_by_token(map_token); + uint64_t previous_map_token = py_current_map_token; py_current_map_token = map_token; // Inject convenience imports so user code just calls aether_map_get/put const char* preamble = "from _aether_map import aether_map_get, aether_map_put\n"; - if (aether_sandbox_depth() >= AETHER_SANDBOX_MAX_LEVELS) return -1; - // The grants as a level of this thread's, in the runtime (#2955) aether_sandbox_push(perms); @@ -284,7 +285,7 @@ int python_run_sandboxed_with_map(void* perms, const char* code, uint64_t map_to // Restore aether_sandbox_pop(); - py_current_map_token = 0; + py_current_map_token = previous_map_token; return result; } diff --git a/new_changelogs/20261029T101500Z-fixed-python-host-map-context.md b/new_changelogs/20261029T101500Z-fixed-python-host-map-context.md new file mode 100644 index 000000000..ae5226690 --- /dev/null +++ b/new_changelogs/20261029T101500Z-fixed-python-host-map-context.md @@ -0,0 +1,4 @@ +- **The embedded Python host now restores nested shared-map contexts.** + `python.run_sandboxed_with_map` preserves the caller's map token across a + nested hosted run and still clears the token when the outermost run returns. + A rejected over-depth call no longer replaces the active map context. diff --git a/tests/integration/host_python_map_context/test_host_python_map_context.sh b/tests/integration/host_python_map_context/test_host_python_map_context.sh new file mode 100755 index 000000000..b0ca1638c --- /dev/null +++ b/tests/integration/host_python_map_context/test_host_python_map_context.sh @@ -0,0 +1,143 @@ +#!/bin/sh +# The in-process Python host keeps the shared-map token scoped to each +# run_sandboxed_with_map call. A nested run must restore the outer token, and +# the outer return must leave no token active. +set -u + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +case "$(uname -s 2>/dev/null)" in + MINGW*|MSYS*|CYGWIN*|Windows_NT) + echo " [SKIP] host_python_map_context on Windows" + exit 0 + ;; +esac + +if ! command -v python3-config >/dev/null 2>&1 || ! command -v python3 >/dev/null 2>&1; then + echo " [SKIP] host_python_map_context: python3 development files unavailable" + exit 0 +fi + +PY_CFLAGS="$(python3-config --embed --cflags 2>/dev/null || python3-config --cflags 2>/dev/null)" +PY_LIBS="$(python3-config --embed --ldflags 2>/dev/null || python3-config --ldflags 2>/dev/null)" +PY_SONAME="$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("INSTSONAME") or "")' 2>/dev/null)" +if [ -z "$PY_CFLAGS" ] || [ -z "$PY_LIBS" ] || [ -z "$PY_SONAME" ]; then + echo " [SKIP] host_python_map_context: libpython build metadata unavailable" + exit 0 +fi +if [ ! -f "$ROOT/build/libaether.a" ]; then + echo " [SKIP] host_python_map_context: libaether.a not built" + exit 0 +fi +export AETHER_PYTHON_SONAME="$PY_SONAME" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +if ! gcc -o "$TMPDIR/test_python_map_context" \ + "$ROOT/contrib/host/python/aether_host_python.c" \ + "$ROOT/runtime/aether_sandbox.c" \ + "$ROOT/runtime/aether_shared_map.c" \ + $PY_CFLAGS -I"$ROOT" -I"$ROOT/runtime" \ + -DAETHER_HAS_PYTHON -DAETHER_HAS_SANDBOX -rdynamic \ + -L"$ROOT/build" -laether $PY_LIBS -ldl -lm -lrt -lpthread \ + -Wno-discarded-qualifiers \ + -xc - 2>"$TMPDIR/cc.log" <<'CEOF' +#include +#include +#include +#include + +#include "contrib/host/python/aether_host_python.h" +#include "runtime/aether_shared_map.h" + +extern void *list_new(void); +extern void list_free(void *); + +void aether_args_init(int argc, char **argv) +{ + (void)argc; + (void)argv; +} + +void *_aether_ctx_stack[64]; +int _aether_ctx_depth = 0; + +static const char *shown(const char *value) +{ + return value ? value : "(null)"; +} + +int main(void) +{ + uint64_t outer_token = 0; + uint64_t inner_token = 0; + AetherSharedMap *outer = aether_shared_map_new(&outer_token); + AetherSharedMap *inner = aether_shared_map_new(&inner_token); + void *perms = list_new(); + char code[4096]; + + if (!outer || !inner || !perms) + { + fprintf(stderr, "FAIL setup\n"); + return 1; + } + + aether_shared_map_put(outer, "scope", "outer"); + aether_shared_map_put(inner, "scope", "inner"); + + int written = snprintf( + code, sizeof(code), + "import ctypes\n" + "nested = ctypes.PyDLL(None).python_run_sandboxed_with_map\n" + "nested.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint64]\n" + "nested.restype = ctypes.c_int\n" + "assert aether_map_get('scope') == 'outer'\n" + "rc = nested(None, b\"assert aether_map_get('scope') == 'inner'\\naether_map_put('inner_result', 'ok')\\n\", %llu)\n" + "assert rc == 0\n" + "restored = aether_map_get('scope')\n" + "aether_map_put('outer_result', 'missing' if restored is None else restored)\n", + (unsigned long long)inner_token); + if (written < 0 || (size_t)written >= sizeof(code)) + { + fprintf(stderr, "FAIL generated Python source\n"); + return 1; + } + + int run_rc = python_run_sandboxed_with_map(perms, code, outer_token); + const char *inner_result = aether_shared_map_get(inner, "inner_result"); + const char *outer_result = aether_shared_map_get(outer, "outer_result"); + int cleared_rc = python_run( + "assert aether_map_get('scope') is None, 'map token survived outer return'"); + + printf("run=%d inner=%s outer=%s cleared=%d\n", + run_rc, shown(inner_result), shown(outer_result), cleared_rc); + + int ok = run_rc == 0 && inner_result && outer_result && + cleared_rc == 0 && strcmp(inner_result, "ok") == 0 && + strcmp(outer_result, "outer") == 0; + + aether_shared_map_revoke_token(inner_token); + aether_shared_map_revoke_token(outer_token); + aether_shared_map_free(inner); + aether_shared_map_free(outer); + python_finalize(); + list_free(perms); + + if (!ok) + { + fprintf(stderr, "FAIL Python host did not restore the outer map context\n"); + return 1; + } + puts("PASS Python host restored and cleared nested map contexts"); + return 0; +} +CEOF +then + echo " [SKIP] host_python_map_context: harness compile/link failed" + head -12 "$TMPDIR/cc.log" | sed 's/^/ /' + exit 0 +fi + +"$TMPDIR/test_python_map_context" From fb54bb18efb88d6240e33a88edb53bc5b6159422 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 07:54:52 +0000 Subject: [PATCH 10/13] tests: sandbox_policy_intersection requires EACCES and covers the no-level case Deny cases now require the harness's EACCES status (exit 3) instead of any non-zero exit, so an unrelated failure (open errno, read error) no longer passes as a denial. Add two cases where the embedded checker returns -1 (no sandbox level on this thread): the preload file grants must decide, denying when the grant list lacks the resource and allowing when it has it. Co-Authored-By: Claude Opus 5.5 --- .../sandbox_policy_intersection/harness.c | 15 ++++++++-- .../test_sandbox_policy_intersection.sh | 28 +++++++++++-------- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/tests/integration/sandbox_policy_intersection/harness.c b/tests/integration/sandbox_policy_intersection/harness.c index d7548c2bd..4a5476484 100644 --- a/tests/integration/sandbox_policy_intersection/harness.c +++ b/tests/integration/sandbox_policy_intersection/harness.c @@ -18,6 +18,14 @@ static int deny_check(const char* category, const char* resource) { return 0; } +// -1 means "no sandbox level on this thread": the embedded checker has no +// opinion and the preload file grants must decide. +static int none_check(const char* category, const char* resource) { + (void)category; + (void)resource; + return -1; +} + static aether_sandbox_check_fn active_check = allow_check; int aether_sandbox_check_resolved(const char* category, const char* resource) { @@ -30,11 +38,12 @@ int aether_sandbox_levels_here(void) { int main(int argc, char** argv) { if (argc != 3) { - fprintf(stderr, "usage: %s \n", argv[0]); + fprintf(stderr, "usage: %s \n", argv[0]); return 2; } - active_check = strcmp(argv[1], "deny") == 0 - ? deny_check : allow_check; + if (strcmp(argv[1], "deny") == 0) active_check = deny_check; + else if (strcmp(argv[1], "none") == 0) active_check = none_check; + else active_check = allow_check; int fd = open(argv[2], O_RDONLY); if (fd < 0) return errno == EACCES ? 3 : 4; diff --git a/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh b/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh index 10c3e16a2..e0702e1b2 100644 --- a/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh +++ b/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh @@ -30,32 +30,36 @@ printf 'exec:/not-used\n' > "$TMP/deny-read.grants" printf 'fs_read:%s\n' "$TMP/data.txt" > "$TMP/allow-read.grants" fail=0 +# expected: 0 (read allowed) or 3 (harness exit code for EACCES, the denial +# status the sandbox returns). Any other status, e.g. 4 for a different +# errno, is a failure rather than a denial. run_case() { label="$1" checker="$2" grants="$3" expected="$4" - if LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$grants" \ - AETHER_SANDBOX_LOG=none "$TMP/harness" "$checker" "$TMP/data.txt" \ - >"$TMP/out" 2>"$TMP/err"; then - got=allow - else - got=deny - fi - if [ "$got" = "$expected" ]; then + LD_PRELOAD="$LIB" AETHER_SANDBOX_GRANTS="$grants" \ + AETHER_SANDBOX_LOG=none "$TMP/harness" "$checker" "$TMP/data.txt" \ + >"$TMP/out" 2>"$TMP/err" + got=$? + if [ "$got" -eq "$expected" ]; then echo " [PASS] $label" else - echo " [FAIL] $label: got $got, expected $expected" + echo " [FAIL] $label: exit $got, expected $expected" fail=1 fi } run_case "preload denial survives an allowing embedded checker" \ - allow "$TMP/deny-read.grants" deny + allow "$TMP/deny-read.grants" 3 run_case "embedded denial survives an allowing preload policy" \ - deny "$TMP/allow-read.grants" deny + deny "$TMP/allow-read.grants" 3 run_case "both policies allowing permits the read" \ - allow "$TMP/allow-read.grants" allow + allow "$TMP/allow-read.grants" 0 +run_case "no thread level (-1): file grants lacking the resource deny" \ + none "$TMP/deny-read.grants" 3 +run_case "no thread level (-1): file grants holding the resource allow" \ + none "$TMP/allow-read.grants" 0 echo "" echo "sandbox_policy_intersection: $([ "$fail" -eq 0 ] && echo PASS || echo FAIL)" From 0a3c22e90a2c68c81f00558f97e5db9419985d91 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 07:54:53 +0000 Subject: [PATCH 11/13] tests: host_python_map_context fails on a broken build, links portably When python3-config is found but the harness fails to compile or link, FAIL rather than SKIP so a broken link on a real runner cannot pass silently. Pass -lrt only on Linux so macOS builds do not receive a flag they lack, and honour $CC so the test runs under gcc and clang. Co-Authored-By: Claude Opus 5.5 --- .../test_host_python_map_context.sh | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/integration/host_python_map_context/test_host_python_map_context.sh b/tests/integration/host_python_map_context/test_host_python_map_context.sh index b0ca1638c..139d5dafa 100755 --- a/tests/integration/host_python_map_context/test_host_python_map_context.sh +++ b/tests/integration/host_python_map_context/test_host_python_map_context.sh @@ -32,16 +32,21 @@ if [ ! -f "$ROOT/build/libaether.a" ]; then fi export AETHER_PYTHON_SONAME="$PY_SONAME" +# librt exists (and is needed for shm_open) on older Linux glibc only; macOS +# has no -lrt. +RT_LIB="" +[ "$(uname -s 2>/dev/null)" = "Linux" ] && RT_LIB="-lrt" + TMPDIR="$(mktemp -d)" trap 'rm -rf "$TMPDIR"' EXIT -if ! gcc -o "$TMPDIR/test_python_map_context" \ +if ! ${CC:-gcc} -o "$TMPDIR/test_python_map_context" \ "$ROOT/contrib/host/python/aether_host_python.c" \ "$ROOT/runtime/aether_sandbox.c" \ "$ROOT/runtime/aether_shared_map.c" \ $PY_CFLAGS -I"$ROOT" -I"$ROOT/runtime" \ -DAETHER_HAS_PYTHON -DAETHER_HAS_SANDBOX -rdynamic \ - -L"$ROOT/build" -laether $PY_LIBS -ldl -lm -lrt -lpthread \ + -L"$ROOT/build" -laether $PY_LIBS -ldl -lm $RT_LIB -lpthread \ -Wno-discarded-qualifiers \ -xc - 2>"$TMPDIR/cc.log" <<'CEOF' #include @@ -135,9 +140,10 @@ int main(void) } CEOF then - echo " [SKIP] host_python_map_context: harness compile/link failed" + # python3-config was found, so a build failure is a real failure. + echo " [FAIL] host_python_map_context: harness compile/link failed" head -12 "$TMPDIR/cc.log" | sed 's/^/ /' - exit 0 + exit 1 fi "$TMPDIR/test_python_map_context" From 638714ece662b509025a0c9ed2c33072a013170c Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 07:56:55 +0000 Subject: [PATCH 12/13] compiler: size the enum-prefix hidden-name message buffer for the prefix gcc -Werror=format-truncation rejects snprintf of a 511-byte enum_prefix into a 256-byte message buffer (typechecker.c, from the enum containment change). Enlarge the buffer to hold the worst case; no behaviour change. Co-Authored-By: Claude Opus 5.5 --- compiler/analysis/typechecker.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 24e8d8492..d159dbe3b 100644 --- a/compiler/analysis/typechecker.c +++ b/compiler/analysis/typechecker.c @@ -16912,7 +16912,7 @@ int typecheck_expression(ASTNode* expr, SymbolTable* table) { int qualified_enum = enum_member_source_prefix(expr, enum_prefix, sizeof(enum_prefix)); if (qualified_enum && qualified_prefix_blocked(table, enum_prefix)) { - char error_msg[256]; + char error_msg[640]; snprintf(error_msg, sizeof(error_msg), "'%s' is hidden in this scope by `hide` or `seal except`", enum_prefix); From bbdf92308ec4980ade38fd68490f40b0bea2d7f5 Mon Sep 17 00:00:00 2001 From: paul Date: Fri, 30 Oct 2026 17:33:41 +0000 Subject: [PATCH 13/13] tests: abi_matrix expects ELF's merged runtimes; python host finds a framework libpython On ELF a library with a runtime of its own binds that runtime's calls to the importer's, which every Aether program exports (-rdynamic); the copies merge, which binary_import_actors relies on for its exit drain. An Aether consumer of a library made for another runtime ABI therefore runs it on the importer's runtime, and the library's ABI check refuses it. The matrix expected those pairs to run, so it failed on Linux on main and on the 0.861.0, 0.862.0 and 0.863.0 releases, once the tree was ahead of the latest release (0.860.0). Where the static libraries' recorded IDs differ on ELF it now expects the refusal and its reason; Mach-O and PE keep the copies apart and still run both ways. host_python_map_context resolves a relative INSTSONAME (a framework Python on macOS: Python.framework/Versions/3.x/Python) to the libpython it names, so the macOS legs can dlopen it. Co-Authored-By: Claude Opus 5.5 --- ...Z-fixed-elf-library-own-runtime-binding.md | 5 +++ .../integration/abi_matrix/test_abi_matrix.sh | 35 +++++++++++++++++-- .../test_host_python_map_context.sh | 16 +++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 new_changelogs/20261030T180000Z-fixed-elf-library-own-runtime-binding.md diff --git a/new_changelogs/20261030T180000Z-fixed-elf-library-own-runtime-binding.md b/new_changelogs/20261030T180000Z-fixed-elf-library-own-runtime-binding.md new file mode 100644 index 000000000..eaa14af6d --- /dev/null +++ b/new_changelogs/20261030T180000Z-fixed-elf-library-own-runtime-binding.md @@ -0,0 +1,5 @@ +- **The cross-version ABI matrix expects ELF's merged runtimes.** On Linux and + FreeBSD a library with a runtime of its own binds to its importer's runtime, + so an Aether consumer of a library from another release is refused by the + library's ABI check, with its reason; the matrix failed on every release + since 0.860.0 by expecting the pair to run. diff --git a/tests/integration/abi_matrix/test_abi_matrix.sh b/tests/integration/abi_matrix/test_abi_matrix.sh index 6a7e956c6..67f15d2c6 100644 --- a/tests/integration/abi_matrix/test_abi_matrix.sh +++ b/tests/integration/abi_matrix/test_abi_matrix.sh @@ -162,8 +162,37 @@ run_app() { expect "$1" "$(cd "$2" && PATH="$2:$PATH" LD_LIBRARY_PATH="$2${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ DYLD_LIBRARY_PATH="$2${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}" ./app$exe 2>"$2/app.err" | tr -d '\r')" "$app_want" } -run_app "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" -run_app "the $old_version importer, this library" "$TMP/new" "$OLD_HOME" "$OLD_AE" +# On ELF a library with a runtime of its own binds that runtime's calls to +# the importer's, which every Aether program exports (-rdynamic): the copies +# merge, as binary_import_actors relies on. A library made for another +# runtime ABI then runs on the importer's runtime, so its check must refuse +# it and say why. Mach-O and PE keep the copies apart, and a level-0 library +# records no ID and has no check. +static_abi() { "$TMP/host_new$exe" "$1" "$1" --runtime-abi 2>/dev/null; } +run_app_refused() { + cp "$SCRIPT_DIR/app.ae" "$2/app.ae" + if ! (cd "$2" && AETHER_HOME="$3" "$4" build app.ae -o app > app_build.log 2>&1); then + bad "$1: app.ae did not build" "$2/app_build.log" + return + fi + # The refusal is a panic: an inner shell keeps its "Aborted" off the log. + got="$(cd "$2" && LD_LIBRARY_PATH="$2${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" \ + sh -c './app 2>app.err; exit $?' 2>/dev/null)" + if [ -n "$got" ] || ! grep -q "the library was made for Aether runtime" "$2/app.err"; then + bad "$1 (another runtime ABI, merged on ELF): not refused with its reason" "$2/app.err" + fi +} +old_static_abi="$(static_abi "$TMP/old/libmlib.$ext")" +if [ "$ext" = so ] && [ -n "$old_static_abi" ] && + [ "$old_static_abi" != "$(static_abi "$TMP/new/libmlib.$ext")" ]; then + run_app_refused "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" + run_app_refused "the $old_version importer, this library" "$TMP/new" "$OLD_HOME" "$OLD_AE" + app_note=" (refused across runtime ABIs, the ELF copies merging)" +else + run_app "this importer, the $old_version library" "$TMP/old" "$ROOT" "$AE" + run_app "the $old_version importer, this library" "$TMP/new" "$OLD_HOME" "$OLD_AE" + app_note="" +fi # The shared runtime. $1 = case dir, $2 = library dir, $3 = runtime dir, # $4 = optional host mode: @@ -221,6 +250,6 @@ else fi if [ "$fail" = 0 ]; then - echo " [PASS] abi_matrix: against $old_version (host ABI level $old_level): C hosts of either header and level, two libraries with a runtime each in one process (an account suspended and resumed beside the old one), and Aether consumers both ways run typed calls, strings given back with no byte kept, errors and deadlines at their levels$shared_note; shared-runtime pairs checked by runtime ABI ID" + echo " [PASS] abi_matrix: against $old_version (host ABI level $old_level): C hosts of either header and level, two libraries with a runtime each in one process (an account suspended and resumed beside the old one), and Aether consumers both ways$app_note run typed calls, strings given back with no byte kept, errors and deadlines at their levels$shared_note; shared-runtime pairs checked by runtime ABI ID" fi exit $fail diff --git a/tests/integration/host_python_map_context/test_host_python_map_context.sh b/tests/integration/host_python_map_context/test_host_python_map_context.sh index 139d5dafa..94a210a61 100755 --- a/tests/integration/host_python_map_context/test_host_python_map_context.sh +++ b/tests/integration/host_python_map_context/test_host_python_map_context.sh @@ -30,6 +30,22 @@ if [ ! -f "$ROOT/build/libaether.a" ]; then echo " [SKIP] host_python_map_context: libaether.a not built" exit 0 fi +# A framework Python (macOS) reports INSTSONAME relative to its framework +# prefix, e.g. Python.framework/Versions/3.13/Python, which dlopen cannot +# find on its own; resolve a relative name to the file it names. +case "$PY_SONAME" in + /*) ;; + *) + for PY_DIR in \ + "$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("PYTHONFRAMEWORKPREFIX") or "")' 2>/dev/null)" \ + "$(python3 -c 'import sysconfig; print(sysconfig.get_config_var("LIBDIR") or "")' 2>/dev/null)"; do + if [ -n "$PY_DIR" ] && [ -f "$PY_DIR/$PY_SONAME" ]; then + PY_SONAME="$PY_DIR/$PY_SONAME" + break + fi + done + ;; +esac export AETHER_PYTHON_SONAME="$PY_SONAME" # librt exists (and is needed for shm_open) on older Linux glibc only; macOS