diff --git a/compiler/analysis/typechecker.c b/compiler/analysis/typechecker.c index 7533c2850..d159dbe3b 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; } } } @@ -12458,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 @@ -12477,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); @@ -14238,6 +14288,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])) { @@ -16841,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[640]; + 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/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/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/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/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/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/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/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/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/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/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/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/runtime/libaether_sandbox_preload.c b/runtime/libaether_sandbox_preload.c index 5c646380f..92085697e 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; @@ -177,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/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/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/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 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" 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" 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 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..94a210a61 --- /dev/null +++ b/tests/integration/host_python_map_context/test_host_python_map_context.sh @@ -0,0 +1,165 @@ +#!/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 +# 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 +# has no -lrt. +RT_LIB="" +[ "$(uname -s 2>/dev/null)" = "Linux" ] && RT_LIB="-lrt" + +TMPDIR="$(mktemp -d)" +trap 'rm -rf "$TMPDIR"' EXIT + +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 $RT_LIB -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 + # 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 1 +fi + +"$TMPDIR/test_python_map_context" diff --git a/tests/integration/sandbox_policy_intersection/harness.c b/tests/integration/sandbox_policy_intersection/harness.c new file mode 100644 index 000000000..4a5476484 --- /dev/null +++ b/tests/integration/sandbox_policy_intersection/harness.c @@ -0,0 +1,56 @@ +#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; +} + +// -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) { + 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; + } + 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; + 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..e0702e1b2 --- /dev/null +++ b/tests/integration/sandbox_policy_intersection/test_sandbox_policy_intersection.sh @@ -0,0 +1,66 @@ +#!/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 + +# 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" + 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: exit $got, expected $expected" + fail=1 + fi +} + +run_case "preload denial survives an allowing embedded checker" \ + allow "$TMP/deny-read.grants" 3 +run_case "embedded denial survives an allowing preload policy" \ + deny "$TMP/allow-read.grants" 3 +run_case "both policies allowing permits the read" \ + 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)" +[ "$fail" -eq 0 ] 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 ] 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 } 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'