diff --git a/Makefile b/Makefile index 482b74e3..50877048 100644 --- a/Makefile +++ b/Makefile @@ -345,6 +345,12 @@ $(BUILD_DIR)/test-stdio-nonblock-host: tests/test-stdio-nonblock-host.c | $(BUIL @echo " CC $<" $(Q)$(CC) $(CFLAGS) -Itests -o $@ $< +# test-rlimit-unchanged-host launches elfuse with ELFUSE_HOST_RLIMIT_CHECK set +# and reads what the check reports, so it is a host binary too. +$(BUILD_DIR)/test-rlimit-unchanged-host: tests/test-rlimit-unchanged-host.c | $(BUILD_DIR) + @echo " CC $<" + $(Q)$(CC) $(CFLAGS) -Itests -o $@ $< + # Guest test binaries (cross-compiled, aarch64-linux) # Only used when GUEST_TEST_BINARIES is not set. @@ -402,6 +408,12 @@ $(BUILD_DIR)/test-usbdev-ioctl-departed: tests/test-usbdev-ioctl-departed.c \ @echo " CROSS $<" $(Q)$(CROSS_COMPILE)gcc $(CROSS_TEST_CFLAGS) -I$(BUILD_DIR) -o $@ $< +# test-rlimit-enforce starts a second thread to show SIGXFSZ is sent to the +# thread that wrote. +$(BUILD_DIR)/test-rlimit-enforce: tests/test-rlimit-enforce.c | $(BUILD_DIR) + @echo " CROSS $< (with -lpthread)" + $(Q)$(CROSS_COMPILE)gcc $(CROSS_TEST_CFLAGS) -o $@ $< -lpthread + # test-usbdev-ioctl churns open/read/close on one usbdevfs node from four # threads, so a close and a sibling's open contend for the same fd number. $(BUILD_DIR)/test-usbdev-ioctl: tests/test-usbdev-ioctl.c | $(BUILD_DIR) diff --git a/docs/internals.md b/docs/internals.md index 3adbc9b4..bb5058a7 100644 --- a/docs/internals.md +++ b/docs/internals.md @@ -188,8 +188,7 @@ Low, fixed addresses: ``` 0x00400000 - varies: ELF LOAD segments (PIE_LOAD_BASE for ET_DYN) 0x01000000: brk base (16 MiB) -0x07800000 - 0x07800FFF: Stack guard page (PROT_NONE, dynamic position) -0x07801000 - 0x07FFFFFF: Stack (8 MiB, 4 x 2 MiB blocks, RW, grows down) +0x07800000 - 0x07FFFFFF: Default stack window (8 MiB, dynamic position) 0x10000000 - 0x101FFFFF: mmap RX region (initial 2 MiB, pre-mapped RX) 0x10200000 - mmap_limit: mmap RX growth area 0x000200000000 - 0x0002001FFFFF: mmap RW region (initial 2 MiB at 8 GiB, RW) @@ -200,6 +199,8 @@ Within-32-bit values are rendered with 8-digit padding; values that extend above 32 bits use 12-digit padding so a reader can tell the range class at a glance. +The stack grows on demand, as it does on Linux. `execve` maps the argument block plus up to 128 KiB at the top of the window as the `[stack]` region and leaves everything below it without page-table entries; the extra 128 KiB is cut to what `RLIMIT_STACK` and the 8 MiB window leave, and never below what the argument block already took. The first access to a page under the region, by the guest or by a syscall acting on a guest pointer, grows the region down to that page when the grown size fits `RLIMIT_STACK` and `RLIMIT_AS` and stays 1 MiB clear of an accessible mapping below; otherwise it is a `SIGSEGV`, or `EFAULT` for the syscall. The break never enters the 8 MiB window, so a default stack always has its room, and a mapping whose address `mmap` chooses, an address hint included, stays 1 MiB below the region, as the kernel's `vm_start_gap` keeps it. A larger limit lets the stack grow below the window toward the heap, which bounds it at roughly 110 MiB with the default layout; Linux has no such bound. + High addresses, anchored to `interp_base` (computed from `guest_size`, see below). The runtime infrastructure reserve is a 16 MiB region placed at `[interp_base - INFRA_RESERVE, interp_base)` -- in the dead zone above @@ -356,6 +357,10 @@ from the one the frame was entered with. Vectors enter EL1 from EL0 traps and forward them to the host through HVC. DC ZVA is emulated inside the shim (it zeroes 64 bytes at the cache-line- aligned address from the `Rt` register); HVF traps DC ZVA via `HCR_EL2.TDZ=1`. +The shim first probes the block for EL0 write access. A block that fails the +probe is forwarded on HVC #11 as the data abort the instruction would have +taken, so the host grows the stack or materializes the page and the instruction +runs again, or the guest gets `SIGSEGV`. | HVC # | Purpose | Registers | |-------|---------|-----------| diff --git a/docs/testing.md b/docs/testing.md index 16989e29..ab23ad0f 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -220,6 +220,7 @@ What they do: expressible `proved/gva.h` preconditions are checked on every call the suite makes. Separate from `make check` because those checks sit on the `guest_read` / `guest_write` hot path +- `make check-locks`: rebuild with `-DELFUSE_LOCK_ASSERT`, which counts the locks each thread holds, and run `make check` on that build. It aborts when a thread calls `mmap_lock_acquire` while it holds a counted lock or is inside a `mmap_lock_nowait_begin` scope, and when a thread reads or writes guest memory while it holds a counted lock, unless it owns `mmap_lock` or is inside such a scope. Run it after adding a lock or a guest access under one - `make lint`: static analysis through `clang-tidy` ## Quick Iteration @@ -504,6 +505,11 @@ interface declares `bInterfaceNumber` 200: every byte is well formed and the range is the field's own, but nothing anyone can plug in emits it, so it is the only way to reach the paths that index by that number. +`ELFUSE_HOST_RLIMIT_CHECK` points at observation: no other process can read +the rlimits of an elfuse process, so with the variable set each one records its +host limits at start and, when one has moved by exit, writes a line to stderr +and exits 86. `test-rlimit-unchanged-host` runs `test-proc-limits` under it. + The lanes these drive: | Lane | Property | diff --git a/mk/config.mk b/mk/config.mk index d1a97edc..8d30da83 100644 --- a/mk/config.mk +++ b/mk/config.mk @@ -67,6 +67,7 @@ NATIVE_TESTS := tests/test-multi-vcpu.c tests/test-rwx.c \ tests/test-string-builder-host.c \ tests/test-wakeup-pipe-host.c \ tests/test-stdio-nonblock-host.c \ + tests/test-rlimit-unchanged-host.c \ tests/test-guest-env-host.c \ tests/test-usb-desc-host.c \ tests/test-usbdev-urb-host.c \ diff --git a/mk/tests.mk b/mk/tests.mk index 069972eb..5d1b53cf 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -249,7 +249,8 @@ endef CHECK_HOST_UNIT_BINS := $(addprefix $(BUILD_DIR)/, \ test-tlbi-encoder-host test-fork-ipc-protocol-host \ test-vcpu-run-hooks-host test-identity-override-host \ - test-teardown-live-vcpu-host test-stdio-nonblock-host test-casefold-host \ + test-teardown-live-vcpu-host test-stdio-nonblock-host \ + test-rlimit-unchanged-host test-casefold-host \ test-casefold-walk-host test-absock-names-host \ test-dynamic-array-host test-string-builder-host \ test-wakeup-pipe-host test-guest-env-host \ @@ -273,6 +274,7 @@ $(call run-host-unit,test-dynamic-array-host,dynamic array unit test) $(call run-host-unit,test-string-builder-host,string builder unit test) $(call run-host-unit,test-wakeup-pipe-host,wakeup pipe concurrency unit test) $(call run-host-unit,test-stdio-nonblock-host,launcher stdio flags across a guest) +$(call run-host-unit,test-rlimit-unchanged-host,host rlimits across a guest) $(call run-host-unit,test-guest-env-host,guest environment merge cross product) $(call run-host-unit,test-usb-desc-host,USB descriptor blob walk unit test) $(call run-host-unit,test-usbdev-urb-host,usbdevfs URB bookkeeping unit test) diff --git a/mk/verify.mk b/mk/verify.mk index fc6de4af..b8099ded 100644 --- a/mk/verify.mk +++ b/mk/verify.mk @@ -1,6 +1,6 @@ # Frama-C WP proofs -.PHONY: verify check-contracts verify-mutants check-char-signedness \ +.PHONY: verify check-contracts check-locks verify-mutants check-char-signedness \ check-stub-constants check-stub-shadow print-verify-targets \ print-verify-profiles @@ -751,6 +751,18 @@ verify: # elfuse" would silently relink them: a default-looking binary carrying the # checks on its hot path. A separate tree also means no -B is needed, since it # starts empty. +## Rebuild with the held-lock count live, then run the suite +# +# src/debug/lock-assert.h is force-included, so every pthread mutex and rwlock +# site in the tree is counted with no change to it. Separate from "make check" +# and built into its own directory for the reasons check-contracts gives below: +# the count sits on every lock and every guest access. +check-locks: + @echo " LOCKS nothing below mmap_lock held where a thread waits on it" + $(Q)$(MAKE) BUILD_DIR=$(BUILD_DIR)/locks \ + EXTRA_CFLAGS="-DELFUSE_LOCK_ASSERT -include $(CURDIR)/src/debug/lock-assert.h $(EXTRA_CFLAGS)" \ + check + check-contracts: @echo " CONTRACT proved/gva.h call-site preconditions (5 of 9 clauses)" $(Q)$(MAKE) BUILD_DIR=$(BUILD_DIR)/contracts \ diff --git a/scripts/check-eintr-contract.py b/scripts/check-eintr-contract.py index 6e5de8ec..96f80b90 100644 --- a/scripts/check-eintr-contract.py +++ b/scripts/check-eintr-contract.py @@ -127,6 +127,12 @@ "restartable", "Polled retry helper; reports before the operation it guards runs.", ), + "syscall/io.c::copy_past_fsize": ( + "restartable", + "Waits for a byte in an empty pipe before the write path can refuse " + "it, and only while the copy has moved nothing. It reads nothing " + "itself, so a restart re-runs the call over the same input.", + ), "syscall/io.c::io_wait_fd_or_interrupted": ( "restartable", "Reports readiness or EINTR without transferring anything itself. It " diff --git a/src/core/bootstrap.c b/src/core/bootstrap.c index 34231236..8934aa0f 100644 --- a/src/core/bootstrap.c +++ b/src/core/bootstrap.c @@ -32,9 +32,11 @@ #include "syscall/linux-wire.h" #include "syscall/fuse.h" #include "syscall/internal.h" +#include "syscall/mem.h" #include "syscall/path.h" #include "syscall/proc.h" #include "syscall/signal.h" +#include "syscall/sys.h" #include "debug/log.h" @@ -94,18 +96,16 @@ static void register_elf_segment_regions(guest_t *g, uint64_t seg_start = info->segments[i].gpa + va_load_base; uint64_t seg_end = seg_start + info->segments[i].memsz; uint64_t seg_gpa = info->segments[i].gpa + gpa_load_base; - guest_region_add_ex_gpa(g, seg_start, seg_end, seg_gpa, - elf_pf_to_prot(info->segments[i].flags), - LINUX_MAP_PRIVATE, info->segments[i].offset, - path, -1); + guest_region_add_elf_segment(g, seg_start, seg_end, seg_gpa, + elf_pf_to_prot(info->segments[i].flags), + info->segments[i].offset, path); } } -/* Publish shim, shim-data, heap, stack-guard, and stack regions to - * /proc/self/maps view, and invalidate the null page and stack-guard PTEs. - * Shared by guest_bootstrap_prepare and guest_bootstrap_rosetta_post_reset; the - * caller registers ELF or rosetta segments separately because those differ - * between aarch64 and rosetta guests. +/* Publish shim, shim-data, heap, and stack regions to /proc/self/maps view, and + * invalidate the null page PTEs. Shared by guest_bootstrap_prepare and + * guest_bootstrap_rosetta_post_reset; the caller registers ELF or rosetta + * segments separately because those differ between aarch64 and rosetta guests. */ static void register_runtime_regions(guest_t *g, size_t shim_bin_len) { @@ -122,17 +122,23 @@ static void register_runtime_regions(guest_t *g, size_t shim_bin_len) guest_region_add(g, g->shim_data_base, g->shim_data_base + BLOCK_2MIB, LINUX_PROT_NONE, LINUX_MAP_PRIVATE, 0, "[shim-data]"); + /* The vDSO page, which Linux lists as [vdso]. Tracking it also keeps a + * hinted mmap from being placed over it. + */ + guest_region_add(g, VDSO_BASE, VDSO_BASE + VDSO_SIZE, + LINUX_PROT_READ | LINUX_PROT_EXEC, LINUX_MAP_PRIVATE, 0, + "[vdso]"); + if (g->brk_base < g->brk_current) { guest_region_add(g, g->brk_base, g->brk_current, LINUX_PROT_READ | LINUX_PROT_WRITE, LINUX_MAP_PRIVATE | LINUX_MAP_ANONYMOUS, 0, "[heap]"); } - guest_invalidate_ptes(g, g->stack_base, g->stack_base + STACK_GUARD_SIZE); - guest_region_add(g, g->stack_base, g->stack_base + STACK_GUARD_SIZE, - LINUX_PROT_NONE, LINUX_MAP_PRIVATE | LINUX_MAP_ANONYMOUS, - 0, "[stack-guard]"); - guest_region_add(g, g->stack_base + STACK_GUARD_SIZE, g->stack_top, + /* The whole window the image is built in; mem_stack_init_extent cuts it + * down to what Linux maps at execve once the stack pointer is known. + */ + guest_region_add(g, g->stack_base, g->stack_top, LINUX_PROT_READ | LINUX_PROT_WRITE, LINUX_MAP_PRIVATE | LINUX_MAP_ANONYMOUS, 0, "[stack]"); guest_invalidate_ptes(g, 0, 0x1000); @@ -583,6 +589,7 @@ int guest_bootstrap_prepare(guest_t *g, t0 = startup_trace_now_ns(); syscall_init(); proc_init(); + sys_rlimit_init(); { char self_path[LINUX_PATH_MAX]; @@ -644,6 +651,7 @@ int guest_bootstrap_prepare(guest_t *g, return -1; } g->start_stack = boot->stack_pointer; + mem_stack_init_extent(g, boot->stack_pointer); startup_trace_step("build_linux_stack", t0); /* rosetta_argv was copied into the guest stack; the host allocation is no @@ -872,7 +880,7 @@ int guest_bootstrap_rosetta_post_reset(guest_t *g, /* Re-publish /proc/self/maps style metadata. Mirrors the bootstrap path so * the post-exec view reports rosetta-as-anonymous-mapping plus the heap, - * stack, stack-guard, shim, and shim-data. + * stack, shim, and shim-data. */ register_elf_segment_regions(g, &rr.rosetta_info, 0, g->rosetta_guest_base - g->rosetta_va_base, @@ -905,6 +913,7 @@ int guest_bootstrap_rosetta_post_reset(guest_t *g, return -1; } g->start_stack = sp; + mem_stack_init_extent(g, sp); proc_set_auxv(auxv.words, auxv.nwords * sizeof(auxv.words[0])); *out_entry_point = rr.entry_point; diff --git a/src/core/guest.c b/src/core/guest.c index 6d84f0f3..aecc9431 100644 --- a/src/core/guest.c +++ b/src/core/guest.c @@ -48,6 +48,7 @@ #include "utils.h" #include "runtime/futex.h" /* futex_interrupt_request */ #include "runtime/thread.h" /* thread_destroy_all_vcpus */ +#include "syscall/mem.h" /* mem_stack_grow_for_host */ #include "syscall/proc.h" /* proc_request_exit_group */ #include "syscall/signal.h" #include "syscall/wakeup-pipe.h" @@ -1619,6 +1620,15 @@ static uint64_t gva_contiguous_avail(const guest_t *g, return total; } +bool guest_range_any_mapped(const guest_t *g, uint64_t start, uint64_t end) +{ + gva_translation_t t; + for (uint64_t off = start; off < end; off += GUEST_PAGE_SIZE) + if (gva_translate_perm(g, g->ipa_base + off, 0, &t) == 0) + return true; + return false; +} + /* Resolve a guest virtual address to a host pointer and available size. * Returns NULL if the address is not in any mapping. * @@ -1637,10 +1647,20 @@ static void *gva_resolve_perm(const guest_t *g, * identity-mapped (GVA == GPA == offset), but L2 block descriptors carry * permission bits and L3 page tables have per-4KiB permissions after * guest_split_block. Skipping the walk would bypass W^X enforcement for all - * normal guest addresses. - */ + * normal guest addresses. An address just below the stack is not a miss + * until the stack has refused to grow to it: Linux grows the stack for a + * kernel access the way it does for a guest one, so read(2) into a stack + * buffer the guest has not touched yet succeeds. Growing writes page tables + * and the region table, which is why the guest is not const there; every + * guest_t this is called with is a writable object. + */ +#ifdef ELFUSE_LOCK_ASSERT + mmap_lock_assert_guest_access(); +#endif gva_translation_t first; - if (gva_translate_perm(g, gva, required_perms, &first) < 0) + if (gva_translate_perm(g, gva, required_perms, &first) < 0 && + (!mem_stack_grow_for_host((guest_t *) g, gva) || + gva_translate_perm(g, gva, required_perms, &first) < 0)) return NULL; /* Computed once and clamped once per branch below. Writing *avail twice @@ -2005,10 +2025,19 @@ int guest_get_used_regions(const guest_t *g, n++; } - /* Stack (dynamic position, stored in guest_t) */ + /* Stack (dynamic position, stored in guest_t): the default reserve, and + * whatever a larger RLIMIT_STACK let it grow to below that. + */ if (n < max) { - out[n].offset = g->stack_base; - out[n].size = g->stack_top - g->stack_base; + uint64_t stack_lo = g->stack_base; + for (int i = 0; i < g->nregions; i++) { + const guest_region_t *r = &g->regions[i]; + if (r->start < stack_lo && r->end <= g->stack_top && + !strcmp(r->name, "[stack]")) + stack_lo = r->start; + } + out[n].offset = stack_lo; + out[n].size = g->stack_top - stack_lo; n++; } @@ -2231,6 +2260,24 @@ int guest_region_add_ex_gpa(guest_t *g, 0); } +int guest_region_add_elf_segment(guest_t *g, + uint64_t start, + uint64_t end, + uint64_t gpa_base, + int prot, + uint64_t offset, + const char *name) +{ + uint64_t lead = start & (GUEST_PAGE_SIZE - 1); + start -= lead; + end = PAGE_ALIGN_UP(end); + if (guest_region_remove(g, start, end) < 0) + return -1; + return guest_region_add_ex_gpa( + g, start, end, gpa_base - lead, prot, LINUX_MAP_PRIVATE, + offset >= lead ? offset - lead : 0, name, -1); +} + static uint64_t allocate_vma_id(guest_t *g) { uint64_t candidate = g->next_vma_id; @@ -3706,6 +3753,48 @@ int guest_install_va_pages(guest_t *g, return 0; } +int guest_map_pages(guest_t *g, uint64_t start, uint64_t end, int perms) +{ + if (end <= start) + return 0; + + /* Only the two end blocks can be partly covered. One that has no mapping + * yet is about to get a whole-block descriptor, so note it before the + * extend and cut it back to the range after. + */ + uint64_t head = ALIGN_2MIB_DOWN(start), tail = ALIGN_2MIB_DOWN(end - 1); + const uint64_t *head_l2 = find_l2_entry(g, head); + const uint64_t *tail_l2 = find_l2_entry(g, tail); + bool trim_head = start > head && !(head_l2 && (*head_l2 & PT_VALID)); + bool trim_tail = + end < tail + BLOCK_2MIB && !(tail_l2 && (*tail_l2 & PT_VALID)); + + int rc = guest_extend_page_tables(g, start, end, perms); + if (rc == 0 && trim_head && + (guest_split_block(g, head) < 0 || + guest_invalidate_ptes(g, head, start) < 0)) + rc = -1; + if (rc == 0 && trim_tail && + (guest_split_block(g, tail) < 0 || + guest_invalidate_ptes(g, end, tail + BLOCK_2MIB) < 0)) + rc = -1; + if (rc == 0) + rc = guest_update_perms(g, start, end, perms); + + /* A failure can leave a fresh end block mapped whole, which is the + * over-mapping this function exists to prevent. Such a block held nothing + * before the call, so it is unmapped whole; clearing a whole block takes no + * page-table page and so cannot fail the way a split does. + */ + if (rc < 0) { + if (trim_head) + (void) guest_invalidate_ptes(g, head, head + BLOCK_2MIB); + if (trim_tail) + (void) guest_invalidate_ptes(g, tail, tail + BLOCK_2MIB); + } + return rc; +} + /* Lazy page materialization for MAP_NORESERVE. */ int guest_materialize_lazy(guest_t *g, uint64_t fault_offset) diff --git a/src/core/guest.h b/src/core/guest.h index 9054e5fe..175357c2 100644 --- a/src/core/guest.h +++ b/src/core/guest.h @@ -104,7 +104,11 @@ _Static_assert(sizeof(_Atomic uint64_t) == sizeof(uint64_t) && #define PIE_LOAD_BASE 0x00400000ULL /* PIE (ET_DYN) executable base (4MiB) */ #define BRK_BASE_DEFAULT 0x01000000ULL /* Default brk start (16MiB) */ -/* 8MiB stack (four 2MiB blocks); unused HVF backing pages consume no RAM. */ +/* The 8MiB window (four 2MiB blocks) a new image's stack is built in and a + * default RLIMIT_STACK lets it grow to. The break never enters it. The stack + * region itself starts as the argument block plus 128KiB and grows on demand + * (mem_stack_grow), past the window when the limit is larger. + */ #define STACK_SIZE 0x00800000ULL /* Used when brk_start is below 128MiB; otherwise placed above brk. */ @@ -119,7 +123,8 @@ _Static_assert(sizeof(_Atomic uint64_t) == sizeof(uint64_t) && * STACK_TOP_DEFAULT and keeps the layout it had. */ #define BRK_MIN_WINDOW 0x00200000ULL -#define STACK_GUARD_SIZE 0x00001000ULL /* 4KiB guard at stack bottom */ +/* Lowest part of the window, which build_linux_stack leaves unused. */ +#define STACK_GUARD_SIZE 0x00001000ULL /* mmap RX region for PROT_EXEC; placed below 8GiB to leave the high mmap region * clear for runtimes that demand a specific minimum heap address. @@ -478,7 +483,7 @@ typedef struct { uint64_t elf_load_min; uint64_t brk_base; /* Initial brk (set after ELF load) */ uint64_t brk_current; /* Current brk position */ - uint64_t stack_base; /* Bottom of stack region (dynamic, above brk) */ + uint64_t stack_base; /* Bottom of the stack window (dynamic, above brk) */ uint64_t stack_top; /* Top of stack (stack grows down from here) */ uint64_t start_stack; /* Initial userspace SP for /proc//stat */ @@ -851,6 +856,16 @@ static inline bool guest_range_hits_infra(const guest_t *g, return start < infra_hi && end > infra_lo; } +/* Whether a tracked region is address space a Linux process would have. The + * shim and its data sit in the infra reserve, which Linux has no mapping for, + * so they count toward neither VmSize nor RLIMIT_AS and RLIMIT_DATA. + */ +static inline bool guest_region_in_process_vm(const guest_t *g, + const guest_region_t *r) +{ + return !guest_range_hits_infra(g, r->start, r->end); +} + /* True iff a single address (PC, hint, etc.) falls inside the infra reserve. * Used by rt_sigreturn to reject forged frames that would redirect EL0 PC into * EL1 shim or page-table memory. Covers the full reserve, matching @@ -1057,6 +1072,12 @@ int guest_install_va_pages(guest_t *g, */ bool guest_va_block_mapped(const guest_t *g, uint64_t va); +/* Whether any page of [start, end), in region coordinates, has a translation + * EL0 can use. This reads the page tables and not the region table, so it also + * sees a mapping whose record the table has lost. + */ +bool guest_range_any_mapped(const guest_t *g, uint64_t start, uint64_t end); + /* Returns true when the VA range [va, va+size) overlaps the user-VA kbuf alias * window [KBUF_USER_VA, KBUF_USER_VA+KBUF_SIZE). Callers that install TTBR0 * mappings (the future rosetta_finalize, sys_mmap MAP_FIXED touching this @@ -1296,6 +1317,20 @@ int guest_region_add_ex_gpa(guest_t *g, const char *name, int backing_fd); +/* Track one PT_LOAD segment, [@start, @end) as p_vaddr and p_vaddr + p_memsz, + * the way Linux maps it (elf_map, fs/binfmt_elf.c): from the page holding + * @start to the page past @end, with @gpa_base and @offset moved back by the + * same amount. A page two segments share goes to the later one, as the later + * MAP_FIXED takes it in Linux. + */ +int guest_region_add_elf_segment(guest_t *g, + uint64_t start, + uint64_t end, + uint64_t gpa_base, + int prot, + uint64_t offset, + const char *name); + /* Like guest_region_add_ex, but consumes owned_backing_fd on success or * failure. inherited_at_fork identifies bytes copied from the latest fork * snapshot. vma_id preserves logical-VMA provenance across tracker splits and @@ -1427,3 +1462,12 @@ bool guest_region_range_has_ro_shared_backing(const guest_t *g, * in a noreserve region. */ int guest_materialize_lazy(guest_t *g, uint64_t fault_offset); + +/* Map exactly the pages of [start, end) with @perms. A 2 MiB block that had no + * mapping comes out split, with its pages outside the range left invalid, where + * guest_extend_page_tables alone would map the whole block. Caller holds + * mmap_lock. + * + * Returns 0 on success, -1 on failure. + */ +int guest_map_pages(guest_t *g, uint64_t start, uint64_t end, int perms); diff --git a/src/core/shim.S b/src/core/shim.S index 964a31d4..8688ff14 100644 --- a/src/core/shim.S +++ b/src/core/shim.S @@ -21,7 +21,9 @@ * the host built a signal frame and the saved fault * frame must be dropped. * #10 BRK from EL0 SIGTRAP or ptrace-stop; GPRs in the frame - * #11 EL0 fault SIGSEGV or SIGILL; GPRs in the frame. Returns the + * #11 EL0 fault SIGSEGV or SIGILL; GPRs in the frame. Also taken + * for a DC ZVA on a block EL0 cannot write, with + * ESR_EL1 still that of the trap. Returns the * same X8 wire code as #5 but only 0/1/3/4, since * there is no frame to drop; a stray 2 falls through * to the full flush. The lazy MAP_NORESERVE @@ -512,7 +514,8 @@ getsid_fast: * word differs from expected. It answers EAGAIN once an EL0 read proves the * caller will not block, and EFAULT when that read faults * (handle_el1_data_abort_recover below). Anything that could need a waiter, and - * every other shape, bails to the host. + * every other shape, bails to the host, and so does a timeout that cannot be + * read. * * The authority this must match is futex_should_block in runtime/futex.c, not * the Linux source: reordering the checks there is what would silently make @@ -624,7 +627,10 @@ futex_uaddr_tag: * no window between check and access for a sibling vCPU to unmap into. A * translation or permission fault lands in handle_el1_data_abort_recover * and returns EFAULT, matching what the host answers for an address it - * cannot resolve. + * cannot resolve. That includes a page under the stack that nothing has + * touched: Linux does not grow the stack for a futex word either + * (futex_get_value_locked reads it with page faults off, and the fault-in + * that follows does not expand a stack). * * A host-side SIGBUS (the truncated MAP_SHARED overlay HOST_SIGBUS_GUARD * covers) is not a stage-1 fault and does not arrive here. Any FUTEX_WAIT @@ -660,8 +666,10 @@ futex_uaddr_tag: /* Timed shape. The timespec is read through the same LDTR the word is, so * an unreadable one takes the EL0 permission fault in hardware and - * handle_el1_data_abort_recover answers EFAULT, which is what Linux answers - * for it and in the same order. + * handle_el1_data_abort_recover hands the call to the host. Linux reads the + * timeout with copy_from_user, which grows the stack to a page under it, + * and only the host can do that; for an address that stays unreadable the + * host answers EFAULT, in the order Linux does. * * Validity is deliberately narrower than the host's. futex.c accepts tv_sec * up to INT64_MAX/4; this accepts up to 2^31, about 68 years, and hands @@ -1260,10 +1268,10 @@ not_svc: cmp x10, #0x3C b.eq handle_brk - /* EC=0x25: the urandom store or futex LDTR can fault after a sibling vCPU - * revokes the page. + /* EC=0x25: the urandom store, the futex LDTR or a DC ZVA store can fault + * after a sibling vCPU revokes the page. * - * Return EFAULT rather than halting the VM. + * Recover rather than halting the VM. */ cmp x10, #0x25 b.eq handle_el1_data_abort_recover @@ -1276,8 +1284,11 @@ not_svc: cbnz x11, restore_and_bad /* EL1 (M>=4): genuine shim bug */ b handle_el0_fault /* EL0 (M=0): forward for signal delivery */ -/* handle_el1_data_abort_recover: tag a data abort whose faulting PC sits inside - * the futex ldtr or the urandom-copy strb region as a recoverable EFAULT. +/* handle_el1_data_abort_recover: recover from a data abort whose faulting PC + * sits inside the futex ldtr, the urandom-copy strb region or the DC ZVA + * stores. The urandom store and the futex word load answer EFAULT; the futex + * timeout load forwards the syscall to the host; a DC ZVA store has the guest + * run the instruction again. * * Layout invariants exploited here: * - SAVE_GPRS allocated 256 bytes for THIS (inner) entry on top of @@ -1291,8 +1302,9 @@ not_svc: * futex range holds no lock and no exclusive monitor, which is the whole * difference between the two tails below. * - * If the fault PC is outside the two futex ldtr ranges and the urandom strb - * ranges this is a genuine shim bug; fall back to restore_and_bad. + * If the fault PC is outside the two futex ldtr ranges, the DC ZVA stores and + * the urandom strb ranges this is a genuine shim bug; fall back to + * restore_and_bad. */ handle_el1_data_abort_recover: mrs x11, elr_el1 @@ -1301,20 +1313,7 @@ handle_el1_data_abort_recover: b.lo 4f adr x12, futex_load_end cmp x11, x12 - b.lo 5f -4: - /* The timespec loads on the timed shape. Same recovery as the word load - * below and for the same reason: they are LDTRs under the same pushed slot, - * they hold no lock and no exclusive monitor, and an unreadable timeout is - * EFAULT on Linux before the word is looked at. - */ - adr x12, futex_ts_load_start - cmp x11, x12 - b.lo 3f - adr x12, futex_ts_load_end - cmp x11, x12 - b.hs 3f -5: + b.hs 4f /* The futex load holds no lock or exclusive monitor. Its recovery slot is * otherwise the same shape as urandom's: discard the nested exception @@ -1330,7 +1329,43 @@ handle_el1_data_abort_recover: COUNTER_INC CB_FUTEX_EFAULT_HIT b svc_restore_eret +4: + /* The timespec loads on the timed shape: LDTRs under the same pushed slot, + * with no lock and no exclusive monitor held. The slot is dropped the same + * way, and the call then goes to the host as if the fast path had bailed on + * its shape. A fault here does not mean a bad address: a page just under + * the stack has no translation until something touches it. + */ + adr x12, futex_ts_load_start + cmp x11, x12 + b.lo 3f + adr x12, futex_ts_load_end + cmp x11, x12 + b.hs 3f + add sp, sp, #256 + ldp x9, x10, [sp], #16 + msr elr_el1, x9 + msr spsr_el1, x10 + b futex_shape_bail + 3: + /* The DC ZVA stores: no lock, no exclusive monitor. Drop the nested frame, + * restore the trap's return state from the slot and return to the DC ZVA + * itself, which traps again and is then refused by its probe. + */ + adr x12, dczva_store_start + cmp x11, x12 + b.lo 5f + adr x12, dczva_store_end + cmp x11, x12 + b.hs 5f + add sp, sp, #256 + ldp x9, x10, [sp], #16 + msr elr_el1, x9 + msr spsr_el1, x10 + b restore_eret + +5: adr x12, urandom_strb_1byte_start cmp x11, x12 b.lo 2f @@ -1431,11 +1466,36 @@ handle_sysreg_trap: lsl x10, x10, #3 /* Byte offset in frame */ ldr x11, [sp, x10] /* x11 = VA from Xt */ and x11, x11, #~63 /* Align down to 64-byte cache line */ + + /* The stores below run at EL1, so check first that EL0 may write the block, + * which a 64-byte aligned block lets one probe settle. A block EL0 cannot + * write is a fault of the DC ZVA itself: the host takes it as a data abort + * at that address, grows the stack or materializes the page when that is + * what was missing and has the instruction run again, and delivers SIGSEGV + * otherwise. Without the probe an unmapped block took an EL1 abort outside + * the recovery ranges and halted the VM. + */ + at s1e0w, x11 + isb + mrs x12, par_el1 + tbnz x12, #0, handle_el0_fault + + /* A sibling vCPU can still revoke the page between the probe and a store. + * The slot keeps the trap's return state where the nested abort cannot + * overwrite it, and handle_el1_data_abort_recover runs the DC ZVA again, so + * the probe above fails and the guest takes the fault. + */ + mrs x12, elr_el1 + mrs x13, spsr_el1 + stp x12, x13, [sp, #-16]! /* Zero 64 bytes using four STP pairs */ +dczva_store_start: stp xzr, xzr, [x11, #0] stp xzr, xzr, [x11, #16] stp xzr, xzr, [x11, #32] stp xzr, xzr, [x11, #48] +dczva_store_end: + add sp, sp, #16 6: /* Forward to host for counting, then I-cache maintenance */ 5: /* Pass the source register value to host. Cache maintenance instructions diff --git a/src/core/stack.c b/src/core/stack.c index f4199648..407edfd9 100644 --- a/src/core/stack.c +++ b/src/core/stack.c @@ -186,12 +186,10 @@ uint64_t build_linux_stack(guest_t *g, /* Floor for the whole descent: every step below goes through stack_take, * whose postcondition is that the pointer never crosses this. * - * The floor is the first WRITABLE byte, not stack_base: the low - * STACK_GUARD_SIZE bytes of the region are the PROT_NONE stack guard (the - * [stack-guard] region bootstrap.c and sys_execve both install), and the - * writable stack starts above it. Using stack_base would let the descent - * walk into the guard and only fail later inside guest_write, with a worse - * diagnostic. + * The image is built inside the default stack window, [stack_base, + * stack_top), which is mapped whole until mem_stack_init_extent cuts it + * down. The lowest page of the window is kept out of the build, so the + * stack a new image starts on always has a page left to grow into. */ const uint64_t stack_floor = g->stack_base + STACK_GUARD_SIZE; if (str_ptr < stack_floor) diff --git a/src/debug/lock-assert.h b/src/debug/lock-assert.h new file mode 100644 index 00000000..45ba2e8b --- /dev/null +++ b/src/debug/lock-assert.h @@ -0,0 +1,82 @@ +/* + * Held-lock count for the mmap_lock ordering checks + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Only a build with -DELFUSE_LOCK_ASSERT (make check-locks) sees this file: it + * is force-included there, ahead of every source, and counts the pthread + * mutexes and rwlocks the calling thread holds. The count answers one question, + * whether this thread may block on mmap_lock, and is read by nothing but + * assertions. It never decides whether a caller waits. + * + * mmap_lock and proc_tmpdir_lock rank at and above mmap_lock, so their own + * sites call the pthread functions through a parenthesized name, which the + * macros below do not match, and stay out of the count. + */ + +#pragma once + +#ifdef ELFUSE_LOCK_ASSERT + +#include + +/* Weak, so the host unit tests that link a single object need no definition. */ +__attribute__((weak)) _Thread_local unsigned lock_assert_held; + +static inline int lock_assert_mutex_lock(pthread_mutex_t *m) +{ + int rc = pthread_mutex_lock(m); + if (rc == 0) + lock_assert_held++; + return rc; +} + +static inline int lock_assert_mutex_trylock(pthread_mutex_t *m) +{ + int rc = pthread_mutex_trylock(m); + if (rc == 0) + lock_assert_held++; + return rc; +} + +static inline int lock_assert_mutex_unlock(pthread_mutex_t *m) +{ + int rc = pthread_mutex_unlock(m); + if (rc == 0) + lock_assert_held--; + return rc; +} + +static inline int lock_assert_rwlock_rdlock(pthread_rwlock_t *l) +{ + int rc = pthread_rwlock_rdlock(l); + if (rc == 0) + lock_assert_held++; + return rc; +} + +static inline int lock_assert_rwlock_wrlock(pthread_rwlock_t *l) +{ + int rc = pthread_rwlock_wrlock(l); + if (rc == 0) + lock_assert_held++; + return rc; +} + +static inline int lock_assert_rwlock_unlock(pthread_rwlock_t *l) +{ + int rc = pthread_rwlock_unlock(l); + if (rc == 0) + lock_assert_held--; + return rc; +} + +#define pthread_mutex_lock(m) lock_assert_mutex_lock(m) +#define pthread_mutex_trylock(m) lock_assert_mutex_trylock(m) +#define pthread_mutex_unlock(m) lock_assert_mutex_unlock(m) +#define pthread_rwlock_rdlock(l) lock_assert_rwlock_rdlock(l) +#define pthread_rwlock_wrlock(l) lock_assert_rwlock_wrlock(l) +#define pthread_rwlock_unlock(l) lock_assert_rwlock_unlock(l) + +#endif /* ELFUSE_LOCK_ASSERT */ diff --git a/src/linux-limits.h b/src/linux-limits.h index 4860f85f..bb621dd9 100644 --- a/src/linux-limits.h +++ b/src/linux-limits.h @@ -31,9 +31,13 @@ /* execve argument limits. * * LINUX_MAX_ARG_STRLEN is the kernel's MAX_ARG_STRLEN, 32 pages on a 4 KiB - * kernel. The other two are elfuse's own caps, chosen so a guest cannot drive - * an unbounded host allocation; the entry count shares MAX_ARG_STRLEN's value - * by coincidence, not by derivation, which is why it is named separately. + * kernel. LINUX_ARG_MAX and LINUX_ARG_BYTES_CEILING bound what + * bprm_stack_limits (fs/exec.c) lets the strings and their pointers take, a + * quarter of RLIMIT_STACK held between the two: ARG_MAX, and three quarters of + * _STK_LIM, the 8 MiB default stack. ELFUSE_MAX_ARG_STRINGS is elfuse's own + * cap, chosen so a guest cannot drive an unbounded host allocation; it shares + * MAX_ARG_STRLEN's value by coincidence, not by derivation, which is why it is + * named separately. * * Shared rather than per-file: read_string_array (src/syscall/exec.c) enforces * the count while building the arrays, build_linux_stack (src/core/stack.c) @@ -42,5 +46,6 @@ * number let a change to one leave the other two disagreeing in silence. */ #define LINUX_MAX_ARG_STRLEN 131072 +#define LINUX_ARG_MAX 131072 +#define LINUX_ARG_BYTES_CEILING (6 * 1024 * 1024) #define ELFUSE_MAX_ARG_STRINGS 131072 -#define ELFUSE_MAX_ARG_BYTES (2048 * 1024) diff --git a/src/proved/stack.h b/src/proved/stack.h index a7ff4d72..2309f5c6 100644 --- a/src/proved/stack.h +++ b/src/proved/stack.h @@ -15,12 +15,12 @@ * core/guest.h and syscall/proc.h, which pull in Hypervisor.framework. This * header needs nothing but stdint.h, so make verify-stack proves it directly. * - * The descent is already contained today: read_string_array - * (src/syscall/exec.c) caps combined argv/envp bytes at ELFUSE_MAX_ARG_BYTES - * and the entry count at ELFUSE_MAX_ARG_STRINGS, against an 8 MiB stack. - * Routing every step through stack_take makes that containment structural, held - * by a postcondition here rather than by two caps in another file staying - * smaller than the stack. + * The descent is already contained today: sys_execve (src/syscall/exec.c) caps + * the argv/envp bytes and their pointers together at LINUX_ARG_BYTES_CEILING, 6 + * MiB, and the entry count at ELFUSE_MAX_ARG_STRINGS, against an 8 MiB stack + * window. Routing every step through stack_take makes that containment + * structural, held by a postcondition here rather than by two caps in another + * file staying smaller than the stack. */ #pragma once diff --git a/src/runtime/fork-state.h b/src/runtime/fork-state.h index 5533c968..dd76aaa4 100644 --- a/src/runtime/fork-state.h +++ b/src/runtime/fork-state.h @@ -19,7 +19,7 @@ /* Fork IPC protocol identity. Bump this whenever the header layout or ordered * fork payload changes incompatibly. */ -#define FORK_IPC_PROTOCOL_MAGIC 0x454C4651U /* "ELFQ" */ +#define FORK_IPC_PROTOCOL_MAGIC 0x454C4652U /* "ELFR" */ #define IPC_MAGIC_HEADER FORK_IPC_PROTOCOL_MAGIC #define IPC_MAGIC_SENTINEL 0x454C4F4BU /* "ELOK" */ @@ -76,12 +76,11 @@ typedef struct { uint32_t shm_is_clone; uint32_t _pad2; - /* Guest-visible NOFILE state is separate from the host capacity reserved by - * elfuse. The child restores this after installing inherited FDs so - * descriptors above a subsequently lowered soft limit survive fork. + /* Guest rlimit table (sys.h), indexed by Linux resource number. The child + * restores it after installing inherited FDs so descriptors above a lowered + * NOFILE soft limit survive fork. */ - uint64_t nofile_cur; - uint64_t nofile_max; + linux_rlimit64_t rlimits[LINUX_RLIMIT_COUNT]; } ipc_header_t; typedef struct { diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index c9eb8ec8..ed0dac4c 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -151,10 +151,8 @@ int fork_child_main(int ipc_fd, log_error("fork-child: bad magic 0x%x", hdr.magic); return 1; } - if (hdr.nofile_cur > hdr.nofile_max || hdr.nofile_max > FD_TABLE_SIZE) { - log_error("fork-child: invalid RLIMIT_NOFILE %llu/%llu", - (unsigned long long) hdr.nofile_cur, - (unsigned long long) hdr.nofile_max); + if (!sys_rlimit_table_valid(hdr.rlimits)) { + log_error("fork-child: invalid rlimit table"); return 1; } log_debug("fork-child: pid=%lld ppid=%lld", (long long) hdr.child_pid, @@ -312,13 +310,9 @@ int fork_child_main(int ipc_fd, /* Linux preserves already-open descriptors above a newly lowered soft limit * across fork. Install the inherited table under the default table limit - * first, then restore the parent's guest-visible limit. + * first, then restore the parent's guest-visible limits. */ - if (sys_nofile_restore(hdr.nofile_cur, hdr.nofile_max) < 0) { - log_error("fork-child: failed to restore RLIMIT_NOFILE"); - guest_destroy(&g); - return 1; - } + sys_rlimit_restore(hdr.rlimits); /* Must follow fork_ipc_recv_fd_table: the keepalive recv resolves each * payload guest_fd to its (now installed) child-side host master fd. @@ -1718,6 +1712,7 @@ int64_t sys_clone(hv_vcpu_t vcpu, close(vfork_notify_fds[1]); return -LINUX_ENOMEM; } + sys_rlimit_forget(child_host_pid); /* The parent keeps only its end of the control channel. Reset the closed * write end to -1 so the fail_snapshot guarded close at the bottom of the @@ -1855,9 +1850,6 @@ int64_t sys_clone(hv_vcpu_t vcpu, * but before sibling vCPUs resume. Declared up front so all goto paths to * fail_snapshot can free it unconditionally. Header */ - uint64_t nofile_cur, nofile_max; - sys_nofile_snapshot(&nofile_cur, &nofile_max); - ipc_header_t hdr = { .magic = IPC_MAGIC_HEADER, .ipa_bits = g->ipa_bits, @@ -1898,9 +1890,8 @@ int64_t sys_clone(hv_vcpu_t vcpu, flags & (LINUX_CLONE_CHILD_SETTID | LINUX_CLONE_CHILD_CLEARTID), .ctid_gva = ctid_gva, .shm_is_clone = (snapshot_shm_fd >= 0) ? 1 : 0, - .nofile_cur = nofile_cur, - .nofile_max = nofile_max, }; + sys_rlimit_snapshot(hdr.rlimits); proc_registry_publish_self(); if (fork_ipc_write_all(ipc_sock, &hdr, sizeof(hdr)) < 0) { log_error("clone: failed to send header"); diff --git a/src/runtime/futex.c b/src/runtime/futex.c index 6f93835c..4c3dd173 100644 --- a/src/runtime/futex.c +++ b/src/runtime/futex.c @@ -36,6 +36,7 @@ #include "runtime/thread.h" #include "syscall/linux-wire.h" +#include "syscall/mem.h" #include "syscall/proc.h" #include "syscall/signal.h" @@ -451,7 +452,10 @@ static int64_t futex_should_block(const guest_t *g, uint64_t uaddr, uint32_t expected) { + /* Every caller holds the bucket lock. */ + mmap_lock_nowait_begin(); uint32_t *word = (uint32_t *) guest_ptr(g, uaddr); + mmap_lock_nowait_end(); if (!word) return -LINUX_EFAULT; @@ -1356,7 +1360,9 @@ static int64_t futex_wake_op(guest_t *g, } /* Atomically modify *uaddr2 */ + mmap_lock_nowait_begin(); uint32_t *word2 = (uint32_t *) guest_ptr_w(g, uaddr2); + mmap_lock_nowait_end(); if (!word2) { if (idx1 != idx2) pthread_mutex_unlock(&b2->lock); diff --git a/src/runtime/procemu.c b/src/runtime/procemu.c index 150100ab..871033a0 100644 --- a/src/runtime/procemu.c +++ b/src/runtime/procemu.c @@ -98,6 +98,10 @@ static pthread_mutex_t shm_dir_lock = PTHREAD_MUTEX_INITIALIZER; */ static char proc_tmpdir[128]; static bool proc_tmpdir_ok; + +/* Ranked above mmap_lock, so its pthread calls are parenthesized to stay out of + * the count make check-locks keeps (debug/lock-assert.h). + */ static pthread_mutex_t proc_tmpdir_lock = PTHREAD_MUTEX_INITIALIZER; typedef struct { @@ -1361,16 +1365,16 @@ static void format_proc_net_addr(char out[33], */ static const char *ensure_proc_tmpdir(const guest_t *g) { - pthread_mutex_lock(&proc_tmpdir_lock); + (pthread_mutex_lock)(&proc_tmpdir_lock); if (proc_tmpdir_ok) { - pthread_mutex_unlock(&proc_tmpdir_lock); + (pthread_mutex_unlock)(&proc_tmpdir_lock); return proc_tmpdir; } str_copy_trunc(proc_tmpdir, "/tmp/elfuse-proc-XXXXXX", sizeof(proc_tmpdir)); if (!mkdtemp(proc_tmpdir)) { proc_tmpdir[0] = '\0'; - pthread_mutex_unlock(&proc_tmpdir_lock); + (pthread_mutex_unlock)(&proc_tmpdir_lock); return NULL; } @@ -1380,7 +1384,7 @@ static const char *ensure_proc_tmpdir(const guest_t *g) if (mkdir(pidbuf, 0755) < 0 && errno != EEXIST) { rmdir(proc_tmpdir); proc_tmpdir[0] = '\0'; - pthread_mutex_unlock(&proc_tmpdir_lock); + (pthread_mutex_unlock)(&proc_tmpdir_lock); return NULL; } @@ -1419,7 +1423,7 @@ static const char *ensure_proc_tmpdir(const guest_t *g) atexit(proc_tmpdir_cleanup); proc_tmpdir_ok = true; - pthread_mutex_unlock(&proc_tmpdir_lock); + (pthread_mutex_unlock)(&proc_tmpdir_lock); return proc_tmpdir; } @@ -2537,10 +2541,13 @@ static int intercept_open_dispatch(const guest_t *g, /* /proc/self/status -> synthetic process status */ if (!strcmp(path, "/proc/self/status")) { - /* Compute VmSize from region tracking (total virtual memory) */ + /* VmSize is total_vm, the sum RLIMIT_AS bounds (mem_rlimit_allows). */ uint64_t vm_size_kb = 0; - for (int i = 0; i < g->nregions; i++) - vm_size_kb += (g->regions[i].end - g->regions[i].start); + for (int i = 0; i < g->nregions; i++) { + const guest_region_t *r = &g->regions[i]; + if (guest_region_in_process_vm(g, r)) + vm_size_kb += r->end - r->start; + } vm_size_kb /= 1024; /* VmRSS: approximate as non-PROT_NONE regions (HVF cannot query actual @@ -2576,7 +2583,7 @@ static int intercept_open_dispatch(const guest_t *g, threads); } - /* /proc/self/limits -> resource limits from prlimit64 cache */ + /* /proc/self/limits -> the guest rlimit table */ if (!strcmp(path, "/proc/self/limits")) { char buf[2048]; int len = sys_format_limits(buf, sizeof(buf)); diff --git a/src/syscall/exec.c b/src/syscall/exec.c index 33074b2b..faa47c94 100644 --- a/src/syscall/exec.c +++ b/src/syscall/exec.c @@ -47,6 +47,7 @@ #include "syscall/path.h" #include "syscall/proc.h" #include "syscall/signal.h" +#include "syscall/sys.h" /* Force HVF to commit the sysreg/GPR writes that sys_execve performs after a @@ -312,6 +313,36 @@ static int exec_resolve_interp_host_path(const char *interp_guest_path, return 0; } +/* What argv and envp may take on the new stack, after bprm_stack_limits (Linux + * fs/exec.c): a quarter of RLIMIT_STACK, at most LINUX_ARG_BYTES_CEILING and at + * least LINUX_ARG_MAX. The strings and their pointer arrays share it. + */ +static size_t exec_arg_limit(void) +{ + uint64_t limit = sys_rlimit_cur(LINUX_RLIMIT_STACK) / 4; + if (limit > LINUX_ARG_BYTES_CEILING) + limit = LINUX_ARG_BYTES_CEILING; + if (limit < LINUX_ARG_MAX) + limit = LINUX_ARG_MAX; + return (size_t) limit; +} + +/* Whether @string_bytes of argument strings and @count pointers fit the new + * stack. Linux subtracts the pointers from the limit before it copies a string + * (bprm_stack_limits), and fails the copy with E2BIG when the stack cannot grow + * to hold it (get_arg_page), which is how RLIMIT_STACK below the strings + * themselves is refused. + */ +static bool exec_args_fit(size_t string_bytes, size_t count) +{ + size_t limit = exec_arg_limit(); + size_t ptr_bytes = (count ? count : 1) * sizeof(uint64_t); + if (limit <= ptr_bytes || string_bytes > limit - ptr_bytes) + return false; + return PAGE_ALIGN_UP(string_bytes + sizeof(uint64_t)) <= + sys_rlimit_cur(LINUX_RLIMIT_STACK); +} + /* Read a NULL-terminated pointer array from guest memory. Each pointer in the * array is a 64-bit GVA pointing to a string. * Returns the count of entries (excluding the NULL terminator), or a negative @@ -328,7 +359,8 @@ static int read_string_array(guest_t *g, char **out_buf, size_t *out_buf_size, char *temp_str, - size_t *running_bytes) + size_t *running_bytes, + size_t max_bytes) { int count = 0; while (true) { @@ -374,7 +406,7 @@ static int read_string_array(guest_t *g, size_t len = (size_t) rc; - if (*running_bytes + len + 1 > ELFUSE_MAX_ARG_BYTES) { + if (*running_bytes + len + 1 > max_bytes) { free(argv); free(buf); return -2; @@ -1206,6 +1238,13 @@ static void exec_publish_maps(guest_t *g, const exec_image_t *img) guest_region_add(g, g->shim_data_base, g->shim_data_base + BLOCK_2MIB, LINUX_PROT_NONE, LINUX_MAP_PRIVATE, 0, "[shim-data]"); + /* The vDSO page, which Linux lists as [vdso]. Tracking it also keeps a + * hinted mmap from being placed over it. + */ + guest_region_add(g, VDSO_BASE, VDSO_BASE + VDSO_SIZE, + LINUX_PROT_READ | LINUX_PROT_EXEC, LINUX_MAP_PRIVATE, 0, + "[vdso]"); + /* Same two lists, same order, same spans as exec_install_address_space. * interp->display_path was resolved before guest_reset, so naming the * interpreter here needs no filesystem lookup past the point of no return. @@ -1215,22 +1254,18 @@ static void exec_publish_maps(guest_t *g, const exec_image_t *img) const char *names[] = {path, interp->display_path}; for (size_t l = 0; l < ARRAY_SIZE(lists); l++) { for (int i = 0; i < lists[l]->num_segments; i++) { - guest_region_add(g, exec_seg_start(lists[l], i, bases[l]), - exec_seg_end(lists[l], i, bases[l]), - elf_pf_to_prot(lists[l]->segments[i].flags), - LINUX_MAP_PRIVATE, lists[l]->segments[i].offset, - names[l]); + uint64_t seg_start = exec_seg_start(lists[l], i, bases[l]); + guest_region_add_elf_segment( + g, seg_start, exec_seg_end(lists[l], i, bases[l]), seg_start, + elf_pf_to_prot(lists[l]->segments[i].flags), + lists[l]->segments[i].offset, names[l]); } } - /* Leave the lowest stack page unmapped so downward overflow faults before - * corrupting adjacent mappings. + /* The whole window the image is built in; mem_stack_init_extent cuts it + * down to what Linux maps at execve once the stack pointer is known. */ - guest_invalidate_ptes(g, g->stack_base, g->stack_base + STACK_GUARD_SIZE); - guest_region_add(g, g->stack_base, g->stack_base + STACK_GUARD_SIZE, - LINUX_PROT_NONE, LINUX_MAP_PRIVATE | LINUX_MAP_ANONYMOUS, - 0, "[stack-guard]"); - guest_region_add(g, g->stack_base + STACK_GUARD_SIZE, g->stack_top, + guest_region_add(g, g->stack_base, g->stack_top, LINUX_PROT_READ | LINUX_PROT_WRITE, LINUX_MAP_PRIVATE | LINUX_MAP_ANONYMOUS, 0, "[stack]"); @@ -1381,8 +1416,9 @@ int64_t sys_execve(hv_vcpu_t vcpu, goto fail; } + size_t arg_limit = exec_arg_limit(); int argc = read_string_array(g, argv_gva, &argv, &argv_buf, &argv_buf_size, - temp_str, &running_bytes); + temp_str, &running_bytes, arg_limit); if (argc < 0) { err = (argc == -2) ? -LINUX_E2BIG : ((argc == -3) ? -LINUX_ENOMEM : -LINUX_EFAULT); @@ -1392,7 +1428,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, int envc = 0; if (envp_gva != 0) { envc = read_string_array(g, envp_gva, &envp, &envp_buf, &envp_buf_size, - temp_str, &running_bytes); + temp_str, &running_bytes, arg_limit); if (envc < 0) { err = (envc == -2) ? -LINUX_E2BIG : ((envc == -3) ? -LINUX_ENOMEM : -LINUX_EFAULT); @@ -1416,6 +1452,15 @@ int64_t sys_execve(hv_vcpu_t vcpu, log_debug("execve resolved to \"%s\"", path); } + /* The filename is the first string Linux copies, so it counts too. It is + * sized here, after /proc/self/exe has become the path that is copied. + */ + if (!exec_args_fit(running_bytes + strlen(path) + 1, + (size_t) (argc > 0 ? argc : 1) + (size_t) envc)) { + err = -LINUX_E2BIG; + goto fail; + } + if (!host_path) { path_translation_t tx; if (path_translate_at(LINUX_AT_FDCWD, path, PATH_TR_NONE, &tx) < 0) { @@ -1564,9 +1609,17 @@ int64_t sys_execve(hv_vcpu_t vcpu, buf_off = (size_t) (argv[argc - 1] - argv_buf) + strlen(argv[argc - 1]) + 1; int prefix_end = ni - (argc > 1 ? argc - 1 : 0); + + /* The rewritten vector drops the original argv[0] (remove_arg_zero in + * Linux fs/exec.c), so its bytes and its pointer stop counting before + * the strings that replace it are added. + */ + if (argc > 0) + running_bytes -= strlen(argv[0]) + 1; for (int i = 0; i < prefix_end; i++) { size_t len = strlen(new_argv[i]); - if (running_bytes + len + 1 > ELFUSE_MAX_ARG_BYTES) { + if (!exec_args_fit(running_bytes + len + 1 + strlen(path) + 1, + (size_t) ni + (size_t) envc)) { free(new_argv); err = -LINUX_E2BIG; goto fail; @@ -1851,6 +1904,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, proc_set_fakeroot_enabled(true); shim_globals_publish_creds(g, proc_get_uid(), proc_get_euid(), proc_get_gid(), proc_get_egid()); + sys_rlimit_ids_changed(); shim_globals_attn_and(g, ~ATTN_BIT_CRED); if (0) { @@ -2140,6 +2194,7 @@ int64_t sys_execve(hv_vcpu_t vcpu, exit(128); } g->start_stack = sp; + mem_stack_init_extent(g, sp); entry_point = (interp_base != 0) ? (interp.info.entry + interp_base) : (elf_info.entry + elf_load_base); diff --git a/src/syscall/fd.c b/src/syscall/fd.c index b905da4d..48c86572 100644 --- a/src/syscall/fd.c +++ b/src/syscall/fd.c @@ -32,6 +32,7 @@ #include "syscall/linux-wire.h" #include "syscall/fd.h" #include "syscall/internal.h" +#include "syscall/mem.h" #include "syscall/io.h" #include "syscall/proc.h" #include "syscall/signal.h" @@ -241,6 +242,7 @@ int64_t sys_timerfd_settime(guest_t *g, int64_t ret = 0; pthread_mutex_lock(&sfd_lock); + mmap_lock_nowait_begin(); int slot = timerfd_find(fd); if (slot < 0) { ret = -LINUX_EBADF; @@ -362,6 +364,7 @@ int64_t sys_timerfd_settime(guest_t *g, } unlock: + mmap_lock_nowait_end(); pthread_mutex_unlock(&sfd_lock); return ret; } diff --git a/src/syscall/fdtable.c b/src/syscall/fdtable.c index 0d69bcd5..a5e74491 100644 --- a/src/syscall/fdtable.c +++ b/src/syscall/fdtable.c @@ -32,6 +32,7 @@ #include "syscall/asyncio.h" #include "syscall/internal.h" #include "syscall/poll.h" +#include "syscall/sys.h" /* Protects the FD table (fd_alloc, fd_alloc_at, fd_alloc_from, sys_close). File * descriptor operations from concurrent threads must be serialized. @@ -57,19 +58,14 @@ struct fd_lifetime { void *dir; }; -/* RLIMIT_NOFILE tracking. Guest-side soft limit for RLIMIT_NOFILE. fd_alloc - * checks this. Default matches typical Linux default (1024). Updated by - * prlimit64. +/* Guest RLIMIT_NOFILE soft limit, which every allocation below checks. The slot + * is clamped because it bounds an index into fd_table and its contract (sys.h) + * lets another process write it. */ -static _Atomic int rlimit_nofile_cur = FD_TABLE_SIZE; - -void fd_set_rlimit_nofile(int cur) +static int rlimit_nofile_cur(void) { - if (cur > FD_TABLE_SIZE) - cur = FD_TABLE_SIZE; - if (cur < 0) - cur = 0; - rlimit_nofile_cur = cur; + uint64_t cur = sys_rlimit_cur(LINUX_RLIMIT_NOFILE); + return cur > FD_TABLE_SIZE ? FD_TABLE_SIZE : (int) cur; } /* Bitmap for O(1) lowest-free-FD allocation. A set bit means the FD is free @@ -523,7 +519,7 @@ static int fd_alloc_locked(int minfd, const fd_host_probe_t *probe) { int fd = fd_bitmap_find_free(minfd); - if (fd >= 0 && fd >= rlimit_nofile_cur) + if (fd >= 0 && fd >= rlimit_nofile_cur()) fd = -1; /* RLIMIT_NOFILE exceeded */ if (fd < 0) { /* No slot, so nothing is published and the probe's O_NONBLOCK has to @@ -614,7 +610,7 @@ int fd_alloc_dir_at(int fd, { if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) return -1; - if (fd >= rlimit_nofile_cur) + if (fd >= rlimit_nofile_cur()) return -1; fd_entry_t old = {.type = FD_CLOSED}; @@ -759,7 +755,7 @@ bool fd_reexec_slot_available(int minfd) if (minfd < 0) minfd = 0; pthread_mutex_lock(&fd_lock); - int limit = rlimit_nofile_cur; + int limit = rlimit_nofile_cur(); if (limit > FD_TABLE_SIZE) limit = FD_TABLE_SIZE; bool available = false; @@ -787,7 +783,7 @@ int fd_alloc_at(int fd, { if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) return -1; - if (fd >= rlimit_nofile_cur) + if (fd >= rlimit_nofile_cur()) return -1; /* Snapshot old slot state under fd_lock, then replace atomically. Cleanup @@ -827,7 +823,7 @@ int fd_alloc_at_relaxed(int fd, { if (!RANGE_CHECK(fd, 0, FD_TABLE_SIZE)) return -1; - if (fd >= rlimit_nofile_cur) + if (fd >= rlimit_nofile_cur()) return -1; if (!thread_is_single_active()) return fd_alloc_at(fd, type, host_fd, cleanup, out_gen); diff --git a/src/syscall/fs.c b/src/syscall/fs.c index 5255fdcd..6464253e 100644 --- a/src/syscall/fs.c +++ b/src/syscall/fs.c @@ -48,12 +48,15 @@ _Static_assert(NAME_MAX == DIRENT64_NAME_MAX, #include "syscall/fuse.h" #include "syscall/fs.h" #include "syscall/internal.h" +#include "syscall/mem.h" #include "syscall/io.h" /* io_retry_backoff */ #include "syscall/net.h" /* absock_unregister_fd */ #include "syscall/path.h" #include "syscall/usbdev.h" #include "syscall/poll.h" /* epoll_dup_fd */ #include "syscall/proc.h" +#include "syscall/signal.h" +#include "syscall/sys.h" /* Linux dirent64 layout. */ typedef struct { @@ -2405,6 +2408,7 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) * stream -- see the lock field in dir_stream_t. */ pthread_mutex_lock(&ds->lock); + mmap_lock_nowait_begin(); int64_t ret; @@ -2668,6 +2672,7 @@ int64_t sys_getdents64(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) ret = (int64_t) guest_pos; out: + mmap_lock_nowait_end(); pthread_mutex_unlock(&ds->lock); dir_stream_release(ds); return ret; @@ -3605,6 +3610,31 @@ int64_t sys_faccessat(guest_t *g, /* truncate. */ +/* RLIMIT_FSIZE on a truncate, after inode_newsize_ok (Linux fs/attr.c): growing + * a regular file past the soft limit fails with EFBIG and SIGXFSZ, and + * shrinking one that is already past it does not. + * + * The kernel refuses a descriptor that is not open for writing before it looks + * at the size (do_sys_ftruncate), so such a descriptor is passed through and + * the host call reports it. + */ +static int64_t truncate_fsize_check(int host_fd, int64_t length) +{ + uint64_t limit = sys_rlimit_cur(LINUX_RLIMIT_FSIZE); + if (limit == LINUX_RLIM_INFINITY || length < 0 || + (uint64_t) length <= limit) + return 0; + + struct stat st; + if (fstat(host_fd, &st) < 0 || !S_ISREG(st.st_mode) || st.st_size >= length) + return 0; + int fl = fcntl(host_fd, F_GETFL); + if (fl < 0 || (fl & O_ACCMODE) == O_RDONLY) + return 0; + sys_rlimit_send_xfsz(); + return -LINUX_EFBIG; +} + int64_t sys_ftruncate(int fd, int64_t length) { fd_entry_t snap; @@ -3638,6 +3668,12 @@ int64_t sys_ftruncate(int fd, int64_t length) } } + int64_t fsz = truncate_fsize_check(host_ref.fd, length); + if (fsz < 0) { + host_fd_ref_close(&host_ref); + return fsz; + } + if (ftruncate(host_ref.fd, length) < 0) { host_fd_ref_close(&host_ref); return linux_errno(); @@ -3671,6 +3707,11 @@ int64_t sys_truncate(guest_t *g, uint64_t path_gva, int64_t length) return -LINUX_EINVAL; return linux_errno(); } + int64_t fsz = truncate_fsize_check(fd, length); + if (fsz < 0) { + close(fd); + return fsz; + } if (ftruncate(fd, length) < 0) { close_keep_errno(fd); return linux_errno(); @@ -3679,6 +3720,32 @@ int64_t sys_truncate(guest_t *g, uint64_t path_gva, int64_t length) return 0; } + /* A request that could pass RLIMIT_FSIZE is checked and carried out on one + * descriptor: checked by path, a rename between the two calls would have + * the limit looked up on one file and another one grown. Opening for + * writing is also the permission check Linux makes first (vfs_truncate). A + * path that does not open is left to truncate, which fails the same way. + * The exception is a host out of descriptors, where truncate would succeed: + * the size from the stat then decides, as the best answer left. + */ + uint64_t limit = sys_rlimit_cur(LINUX_RLIMIT_FSIZE); + struct stat st; + if (length >= 0 && (uint64_t) length > limit && + stat(tx.host_path, &st) == 0 && S_ISREG(st.st_mode)) { + int fd = open(tx.host_path, O_WRONLY | O_NONBLOCK | O_CLOEXEC); + if (fd < 0 && (errno == EMFILE || errno == ENFILE) && + st.st_size < length) { + sys_rlimit_send_xfsz(); + return -LINUX_EFBIG; + } + if (fd >= 0) { + int64_t fsz = truncate_fsize_check(fd, length); + if (fsz == 0 && ftruncate(fd, length) < 0) + fsz = linux_errno(); + close(fd); + return fsz; + } + } if (truncate(tx.host_path, length) < 0) return linux_errno(); return 0; diff --git a/src/syscall/fuse.c b/src/syscall/fuse.c index 3622b133..626b6852 100644 --- a/src/syscall/fuse.c +++ b/src/syscall/fuse.c @@ -66,6 +66,7 @@ #include "proved/fuse.h" #include "syscall/fuse.h" #include "syscall/internal.h" +#include "syscall/mem.h" #include "syscall/path.h" #include "runtime/thread.h" /* thread_stop_requested */ #include "syscall/proc.h" @@ -2544,7 +2545,10 @@ int64_t fuse_dev_read(int guest_fd, host_fd_ref_close(¬ify_ref); return -LINUX_EINVAL; } - if (guest_write(g, buf_gva, req->frame, frame_len) < 0) { + mmap_lock_nowait_begin(); + int frame_rc = guest_write(g, buf_gva, req->frame, frame_len); + mmap_lock_nowait_end(); + if (frame_rc < 0) { pthread_mutex_unlock(&session->lock); pthread_mutex_lock(&fuse_lock); fuse_session_put_locked(session); diff --git a/src/syscall/internal.h b/src/syscall/internal.h index 0de01ebd..ee739314 100644 --- a/src/syscall/internal.h +++ b/src/syscall/internal.h @@ -61,7 +61,23 @@ * mmap_lock's "1" means first among the * locks a syscall path takes, not that * nothing precedes it - * mmap_lock (syscall/mem.c): mmap/brk allocators + page tables + * mmap_lock (syscall/mem.c): mmap/brk allocators + page tables. A + * host read or write of guest memory ranks + * here too: it takes this lock to grow the + * stack when the address lies under it + * (mem_stack_grow_for_host). So guest + * memory is touched before any lock below + * is taken, or inside a + * mmap_lock_nowait_begin scope (mem.h), + * where the growth only tries for the lock. + * The caller picks, by what it holds, and + * mem_stack_grow_for_host follows the scope + * it declared; nothing at run time works + * out what the caller holds. make + * check-locks asserts both halves: no lock + * below is held where a thread waits on + * mmap_lock, and none is held at a guest + * access outside such a scope * pt_lock (core/guest.c): page table pool allocator * pty_keepalive_lock (runtime/procemu-pty.c): pty master keepalive table; * holds fd_lock beneath it in @@ -953,13 +969,6 @@ int linux_to_mac_status_flags(int linux_flags); */ int64_t sys_mmap_anon(guest_t *g, uint64_t addr, uint64_t length, int prot); -/* RLIMIT_NOFILE tracking. */ - -/* Update the guest RLIMIT_NOFILE soft limit. Called from prlimit64 when - * resource == RLIMIT_NOFILE. fd_alloc checks this. - */ -void fd_set_rlimit_nofile(int cur); - /* Borrowed-or-pinned host fd reference. * * Single-threaded guests borrow the raw host fd directly (no dup, no close). diff --git a/src/syscall/io.c b/src/syscall/io.c index 76fcbbbf..b35dfc79 100644 --- a/src/syscall/io.c +++ b/src/syscall/io.c @@ -55,6 +55,7 @@ #include "syscall/usbdev.h" #include "syscall/proc.h" #include "syscall/signal.h" +#include "syscall/sys.h" #include "syscall/wakeup-pipe.h" #define URANDOM_CACHE_SIZE 4096 @@ -1522,6 +1523,96 @@ static int64_t io_write_result(ssize_t ret) return linux_errno(); } +/* What RLIMIT_FSIZE leaves a write to @host_fd that starts at @pos; with + * @append_aware a descriptor opened O_APPEND starts at end of file instead. The + * limit is on regular files only. + * + * The kernel refuses a descriptor that is not open for writing before it looks + * at any size (vfs_write, Linux fs/read_write.c), so no limit applies to one + * and the host call reports it, as in truncate_fsize_check. + * + * Returns false when no limit applies, true with @room set otherwise, 0 at or + * past the limit. + */ +static bool fsize_room(int host_fd, + off_t pos, + bool append_aware, + uint64_t *room) +{ + uint64_t limit = sys_rlimit_cur(LINUX_RLIMIT_FSIZE); + if (limit == LINUX_RLIM_INFINITY) + return false; + + struct stat st; + if (fstat(host_fd, &st) < 0 || !S_ISREG(st.st_mode)) + return false; + int fl = fcntl(host_fd, F_GETFL); + if (fl < 0 || (fl & O_ACCMODE) == O_RDONLY) + return false; + if (append_aware && (fl & O_APPEND)) + pos = st.st_size; + if (pos < 0) + return false; + *room = (uint64_t) pos < limit ? limit - (uint64_t) pos : 0; + return true; +} + +/* RLIMIT_FSIZE on a write, after generic_write_check_limits (Linux + * mm/filemap.c): a regular-file write that starts at or past the soft limit + * fails with EFBIG and SIGXFSZ, and one that crosses it is cut at the limit. A + * write of no bytes is done before the limit is looked at (generic_write_checks + * returns on a zero count first). + * + * Returns 0 with @count clamped, or -LINUX_EFBIG. + */ +static int64_t fsize_limit_write(int host_fd, + off_t pos, + bool append_aware, + uint64_t *count) +{ + uint64_t room; + if (*count == 0 || !fsize_room(host_fd, pos, append_aware, &room)) + return 0; + if (room == 0) { + sys_rlimit_send_xfsz(); + return -LINUX_EFBIG; + } + if (*count > room) + *count = room; + return 0; +} + +/* fsize_limit_write for a vector: @iov and @iovcnt are cut to the bytes the + * limit leaves. + */ +static int64_t fsize_limit_writev(int host_fd, + off_t pos, + bool append_aware, + struct iovec *iov, + int *iovcnt) +{ + if (sys_rlimit_cur(LINUX_RLIMIT_FSIZE) == LINUX_RLIM_INFINITY) + return 0; + + uint64_t total = 0; + for (int i = 0; i < *iovcnt; i++) + total += iov[i].iov_len; + uint64_t allowed = total; + int64_t err = fsize_limit_write(host_fd, pos, append_aware, &allowed); + if (err < 0 || allowed == total) + return err; + + for (int i = 0; i < *iovcnt; i++) { + if (iov[i].iov_len >= allowed) { + iov[i].iov_len = allowed; + *iovcnt = i + 1; + break; + } + allowed -= iov[i].iov_len; + } + return 0; +} + int64_t sys_write(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) { int type = fd_get_type(fd); @@ -1582,6 +1673,12 @@ int64_t sys_write(guest_t *g, int fd, uint64_t buf_gva, uint64_t count) host_fd_ref_close(&host_ref); return intercepted; } + + int64_t fsz = fsize_limit_write(host_ref.fd, offset, true, &count); + if (fsz < 0) { + host_fd_ref_close(&host_ref); + return fsz; + } } /* Wait for POLLOUT (or a guest signal) and transfer without ever parking @@ -1784,6 +1881,15 @@ int64_t sys_pwrite64(guest_t *g, return intercepted; } + /* An O_APPEND descriptor is judged at end of file here too: Linux moves the + * position there in generic_write_checks whatever offset pwrite gave. + */ + int64_t fsz = fsize_limit_write(host_ref.fd, offset, true, &count); + if (fsz < 0) { + host_fd_ref_close(&host_ref); + return fsz; + } + ssize_t ret = pwrite(host_ref.fd, buf, count, offset); host_fd_ref_close(&host_ref); return io_write_result(ret); @@ -2259,6 +2365,14 @@ int64_t sys_writev(guest_t *g, int fd, uint64_t iov_gva, int iovcnt) host_fd_ref_close(&host_ref); return intercepted; } + + int64_t fsz = fsize_limit_writev(host_ref.fd, offset, true, + host_iov.iov, &iovcnt); + if (fsz < 0) { + host_iov_free(&host_iov); + host_fd_ref_close(&host_ref); + return fsz; + } } ssize_t ret; @@ -2374,6 +2488,14 @@ int64_t sys_pwritev(guest_t *g, return intercepted; } + int64_t fsz = + fsize_limit_writev(host_ref.fd, offset, true, host_iov.iov, &iovcnt); + if (fsz < 0) { + host_iov_free(&host_iov); + host_fd_ref_close(&host_ref); + return fsz; + } + ssize_t ret = pwritev(host_ref.fd, host_iov.iov, iovcnt, offset); int64_t result = io_write_result(ret); host_iov_free(&host_iov); @@ -2400,19 +2522,23 @@ static int64_t sys_pwritev_append(guest_t *g, } ssize_t ret; - if (update_file_offset) { - if (lseek(host_ref.fd, 0, SEEK_END) < 0) { - ret = -1; - } else { - ret = writev(host_ref.fd, host_iov.iov, iovcnt); - } + struct stat st; + if (fstat(host_ref.fd, &st) < 0) { + ret = -1; } else { - struct stat st; - if (fstat(host_ref.fd, &st) < 0) { - ret = -1; - } else { - ret = pwritev(host_ref.fd, host_iov.iov, iovcnt, st.st_size); + err = fsize_limit_writev(host_ref.fd, st.st_size, false, host_iov.iov, + &iovcnt); + if (err < 0) { + host_iov_free(&host_iov); + host_fd_ref_close(&host_ref); + return err; } + if (!update_file_offset) + ret = pwritev(host_ref.fd, host_iov.iov, iovcnt, st.st_size); + else if (lseek(host_ref.fd, 0, SEEK_END) < 0) + ret = -1; + else + ret = writev(host_ref.fd, host_iov.iov, iovcnt); } int64_t result = io_write_result(ret); @@ -3546,6 +3672,15 @@ int64_t sys_fallocate(int fd, int mode, int64_t offset, int64_t len) return -LINUX_EOPNOTSUPP; } + /* vfs_fallocate (Linux fs/open.c) refuses a descriptor that is not open for + * writing before it looks at any size. + */ + int fl = fcntl(host_ref.fd, F_GETFL); + if (fl >= 0 && (fl & O_ACCMODE) == O_RDONLY) { + host_fd_ref_close(&host_ref); + return -LINUX_EBADF; + } + struct stat st; if (fstat(host_ref.fd, &st) < 0) { host_fd_ref_close(&host_ref); @@ -3559,6 +3694,13 @@ int64_t sys_fallocate(int fd, int mode, int64_t offset, int64_t len) return -LINUX_EFBIG; /* Overflow check */ } if (new_size > st.st_size) { + /* inode_newsize_ok (Linux fs/attr.c), as a growing truncate. */ + if (S_ISREG(st.st_mode) && + (uint64_t) new_size > sys_rlimit_cur(LINUX_RLIMIT_FSIZE)) { + host_fd_ref_close(&host_ref); + sys_rlimit_send_xfsz(); + return -LINUX_EFBIG; + } if (ftruncate(host_ref.fd, new_size) < 0) { host_fd_ref_close(&host_ref); return linux_errno(); @@ -3638,6 +3780,62 @@ static bool io_give_up_unsent(int64_t off_in, return rewound || thread_stop_requested(); } +/* fsize_room for a stream copy: @off_out is the output offset, or negative for + * the descriptor's own position. + */ +static bool fsize_copy_room(int out_hfd, int64_t off_out, uint64_t *room) +{ + if (sys_rlimit_cur(LINUX_RLIMIT_FSIZE) == LINUX_RLIM_INFINITY) + return false; + + off_t pos = off_out >= 0 ? (off_t) off_out : lseek(out_hfd, 0, SEEK_CUR); + return fsize_room(out_hfd, pos, off_out < 0, room); +} + +/* A stream copy that has filled its output to RLIMIT_FSIZE. Linux hands the + * next source bytes to the file's write path (iter_file_splice_write, fs/ + * splice.c), which fails with EFBIG and SIGXFSZ, so the signal goes out only + * when the input has a byte to give. Before that the kernel waits for one + * (splice_from_pipe_next): a pipe that is empty and still has a writer blocks, + * or answers EAGAIN when it does not block, and one whose writers are gone ends + * the copy. Nothing is waited for once bytes have moved. + * + * Returns 1 after sending SIGXFSZ, 0 when the input has nothing more, or a + * negative Linux errno from the wait. + */ +static int64_t copy_past_fsize(int in_hfd, + int64_t off_in, + const fd_block_state_t *in_st, + bool nothing_moved) +{ + char c; + off_t pos = off_in >= 0 ? (off_t) off_in : lseek(in_hfd, 0, SEEK_CUR); + + if (pos >= 0) { + if (pread(in_hfd, &c, 1, pos) != 1) + return 0; + sys_rlimit_send_xfsz(); + return 1; + } + + for (;;) { + int queued = 0; + if (ioctl(in_hfd, FIONREAD, &queued) == 0 && queued > 0) { + sys_rlimit_send_xfsz(); + return 1; + } + + struct pollfd pfd = {.fd = in_hfd, .events = POLLIN}; + if (!nothing_moved || (poll(&pfd, 1, 0) > 0 && (pfd.revents & POLLHUP))) + return 0; + if (!io_eagain_should_wait(in_st, in_hfd)) + return -LINUX_EAGAIN; + int64_t waited = io_wait_fd_or_interrupted(in_hfd, POLLIN); + if (waited < 0) + return waited; + } +} + typedef struct { int in_gfd, in_hfd; int out_gfd, out_hfd; @@ -3664,6 +3862,20 @@ static int64_t copy_fd_range(const copy_ends_t *ends, while (remaining > 0) { size_t chunk = remaining > IO_COPY_BUF_SIZE ? IO_COPY_BUF_SIZE : remaining; + uint64_t room; + if (fsize_copy_room(out_hfd, *off_out, &room)) { + if (room == 0) { + int64_t past = + copy_past_fsize(in_hfd, *off_in, &in_st, total == 0); + if (total == 0 && past != 0) { + ret = past > 0 ? -LINUX_EFBIG : past; + goto done; + } + break; + } + if (chunk > room) + chunk = room; + } ssize_t nr; if (*off_in >= 0) { int64_t intercepted = proc_try_chunk_read_intercept( @@ -3870,6 +4082,21 @@ int64_t sys_copy_file_range(guest_t *g, } } + /* generic_copy_file_checks (Linux fs/read_write.c) applies RLIMIT_FSIZE to + * the whole request before anything is read: EFBIG and SIGXFSZ at or past + * the limit whatever the length, and a length cut at the limit otherwise. + */ + uint64_t room; + if (fsize_copy_room(out_ref.fd, off_out, &room)) { + if (room == 0) { + sys_rlimit_send_xfsz(); + err = -LINUX_EFBIG; + goto out_copy_file_range; + } + if (len > room) + len = room; + } + /* Emulate with a pread/pwrite loop. */ int64_t moved = copy_fd_range(&(copy_ends_t) {.in_gfd = fd_in, .in_hfd = in_ref.fd, @@ -4067,6 +4294,22 @@ int64_t sys_splice(guest_t *g, int64_t ret; while (total < len) { size_t n = (len - total) > chunk ? chunk : (len - total); + uint64_t room; + if (fsize_copy_room(st.out_hfd, st.off_out, &room)) { + if (room == 0) { + int64_t past = copy_past_fsize(st.in_hfd, st.off_in, &st.in_st, + total == 0); + if (past > 0) { + f.rw_error = true; + f.saved_errno = EFBIG; + } else if (past < 0) { + f.wait_err = past; + } + break; + } + if (n > room) + n = room; + } ssize_t r; if (st.off_in >= 0) { r = pread(st.in_hfd, buf, n, st.off_in); diff --git a/src/syscall/linux-wire.h b/src/syscall/linux-wire.h index 7c7e1410..39840fa6 100644 --- a/src/syscall/linux-wire.h +++ b/src/syscall/linux-wire.h @@ -438,6 +438,28 @@ typedef struct { uint64_t rlim_cur, rlim_max; } linux_rlimit64_t; +/* Resource numbers from include/uapi/asm-generic/resource.h, all 16 of them + * (RLIM_NLIMITS). + */ +#define LINUX_RLIMIT_CPU 0 +#define LINUX_RLIMIT_FSIZE 1 +#define LINUX_RLIMIT_DATA 2 +#define LINUX_RLIMIT_STACK 3 +#define LINUX_RLIMIT_CORE 4 +#define LINUX_RLIMIT_RSS 5 +#define LINUX_RLIMIT_NPROC 6 +#define LINUX_RLIMIT_NOFILE 7 +#define LINUX_RLIMIT_MEMLOCK 8 +#define LINUX_RLIMIT_AS 9 +#define LINUX_RLIMIT_LOCKS 10 +#define LINUX_RLIMIT_SIGPENDING 11 +#define LINUX_RLIMIT_MSGQUEUE 12 +#define LINUX_RLIMIT_NICE 13 +#define LINUX_RLIMIT_RTPRIO 14 +#define LINUX_RLIMIT_RTTIME 15 +#define LINUX_RLIMIT_COUNT 16 +#define LINUX_RLIM_INFINITY UINT64_MAX + /* Linux struct utmpx (aarch64 LP64). Matches musl's struct utmpx layout. Used * for /var/run/utmp synthesis. On LP64: short=2, int=4, long=8, sizeof(struct * timeval)=16. sizeof(linux_utmpx_t) == 400 (396 data + 4 trailing padding). diff --git a/src/syscall/mem.c b/src/syscall/mem.c index 43dda4f4..7763e966 100644 --- a/src/syscall/mem.c +++ b/src/syscall/mem.c @@ -32,6 +32,7 @@ #include "syscall/fuse.h" #include "syscall/internal.h" #include "syscall/mem.h" +#include "syscall/sys.h" /* Protects mmap/brk bump allocators and page table extension. Multiple threads * may call mmap/brk concurrently; without this lock they could get overlapping @@ -42,6 +43,29 @@ static pthread_mutex_t mmap_lock = static _Thread_local bool mmap_lock_owned; +/* The pthread calls on mmap_lock are parenthesized so that make check-locks, + * which counts the locks ranked below it, does not count mmap_lock itself + * (debug/lock-assert.h). + */ + +/* Depth of the scopes in which this thread holds a lock ranked below mmap_lock + * while it reads or writes guest memory (mmap_lock_nowait_begin). + */ +static _Thread_local unsigned mmap_lock_nowait_depth; + +#ifdef ELFUSE_LOCK_ASSERT +bool mmap_lock_may_block(void) +{ + return lock_assert_held == 0 && mmap_lock_nowait_depth == 0; +} + +void mmap_lock_assert_guest_access(void) +{ + assert(mmap_lock_owned || mmap_lock_nowait_depth != 0 || + lock_assert_held == 0); +} +#endif + bool mmap_lock_held(void) { return mmap_lock_owned; @@ -50,7 +74,10 @@ bool mmap_lock_held(void) void mmap_lock_acquire(void) { assert(!mmap_lock_held()); - pthread_mutex_lock(&mmap_lock); +#ifdef ELFUSE_LOCK_ASSERT + assert(mmap_lock_may_block()); +#endif + (pthread_mutex_lock)(&mmap_lock); mmap_lock_owned = true; } @@ -58,7 +85,42 @@ void mmap_lock_release(void) { assert(mmap_lock_held()); mmap_lock_owned = false; - pthread_mutex_unlock(&mmap_lock); + (pthread_mutex_unlock)(&mmap_lock); +} + +void mmap_lock_nowait_begin(void) +{ + mmap_lock_nowait_depth++; +} + +void mmap_lock_nowait_end(void) +{ + assert(mmap_lock_nowait_depth != 0); + mmap_lock_nowait_depth--; +} + +/* How long a caller inside a nowait scope keeps trying for mmap_lock. */ +#define MMAP_LOCK_TRY_MS 1000 + +/* The non-blocking form, for a caller that holds a lock ranked below mmap_lock: + * blocking there could close a cycle with a holder that wants that lock. Only + * trylock is used, so this cannot deadlock; it gives up after MMAP_LOCK_TRY_MS. + * + * Returns true with the lock held. + */ +static bool mmap_lock_try_acquire(void) +{ + assert(!mmap_lock_held()); + for (int waited_us = 0; waited_us < MMAP_LOCK_TRY_MS * 1000; + waited_us += 100) { + int rc = (pthread_mutex_trylock) (&mmap_lock); + if (rc == 0) { + mmap_lock_owned = true; + return true; + } + usleep(100); + } + return false; } /* Host kernel page size (16 KiB on Apple Silicon, typically 4 KiB on Intel @@ -690,6 +752,23 @@ static int split_regions_at_boundary(guest_t *g, uint64_t boundary) return 0; } +/* stack_guard_gap (Linux mm/mmap.c): what a growing stack keeps clear of an + * accessible mapping below it, and what the break and a mapping placed by the + * gap finder keep clear of the stack. + */ +#define STACK_GUARD_GAP (256 * GUEST_PAGE_SIZE) + +/* vm_start_gap (Linux include/linux/mm.h): where the space a placed mapping may + * use ends in front of @r. A stack claims STACK_GUARD_GAP below its start, so + * an address hint cannot put a mapping where the next stack page goes. + */ +static uint64_t region_start_gap(const guest_region_t *r) +{ + if (strcmp(r->name, "[stack]")) + return r->start; + return r->start > STACK_GUARD_GAP ? r->start - STACK_GUARD_GAP : 0; +} + static uint64_t find_free_gap_inner(const guest_t *g, uint64_t length, uint64_t min_addr, @@ -728,7 +807,8 @@ static uint64_t find_free_gap_inner(const guest_t *g, * stream reaches max_addr, later regions cannot affect any candidate * gap inside the window. */ - if (g->regions[i].start >= max_addr) + uint64_t start = region_start_gap(&g->regions[i]); + if (start >= max_addr) break; /* If this region starts far enough after gap_start, the allocator found @@ -737,7 +817,7 @@ static uint64_t find_free_gap_inner(const guest_t *g, * valid range. */ if (window_fits(gap_start, length, max_addr) && - g->regions[i].start >= gap_start + length) + start >= gap_start + length) return gap_start; /* Region overlaps; advance past it and round to the next aligned @@ -2597,16 +2677,310 @@ static bool heap_piece_is_plain(const guest_region_t *r) } +/* Whether a region is what Linux counts toward RLIMIT_DATA: private, writable, + * and not the stack (is_data_mapping, mm/internal.h). + */ +static bool region_is_data(const guest_region_t *r) +{ + return (r->prot & LINUX_PROT_WRITE) && !r->shared && + strcmp(r->name, "[stack]"); +} + +/* The bound may_expand_vm (Linux mm/mmap.c) holds data mappings to: the soft + * RLIMIT_DATA, or the hard one when the soft limit is 0, which is the kernel's + * allowance for Valgrind. + */ +static uint64_t data_rlimit(void) +{ + uint64_t cur = sys_rlimit_cur(LINUX_RLIMIT_DATA); + return cur ? cur : sys_rlimit_max(LINUX_RLIMIT_DATA); +} + +/* RLIMIT_AS and RLIMIT_DATA at the point a request would extend the region + * table, after may_expand_vm. Both totals are summed from the sorted region + * array on each call, so they cannot drift from the region table. Only a + * request for a data mapping is held to RLIMIT_DATA. + * + * Once regions_tracker_stale is set the table has lost records and the totals + * are short by them. The limits are still judged on what is left: refusing + * instead would fail every later request of a guest that has a finite limit. + * + * @grow is the size the request adds. [@replace_start, @replace_end) is the + * range a MAP_FIXED request maps over, in region coordinates, and what is + * already mapped there is not charged twice; pass an empty range otherwise. + * + * Returns true when the request fits both limits. Caller holds mmap_lock. + */ +static bool mem_rlimit_allows(const guest_t *g, + uint64_t grow, + int prot, + int flags, + uint64_t replace_start, + uint64_t replace_end) +{ + uint64_t as_limit = sys_rlimit_cur(LINUX_RLIMIT_AS); + uint64_t data_limit = data_rlimit(); + if (as_limit == LINUX_RLIM_INFINITY && data_limit == LINUX_RLIM_INFINITY) + return true; + + bool grow_is_data = + (prot & LINUX_PROT_WRITE) && !(flags & LINUX_MAP_SHARED); + uint64_t total = 0, data = 0; + for (int i = 0; i < g->nregions; i++) { + const guest_region_t *r = &g->regions[i]; + if (!guest_region_in_process_vm(g, r)) + continue; + uint64_t lo = r->start > replace_start ? r->start : replace_start; + uint64_t hi = r->end < replace_end ? r->end : replace_end; + uint64_t kept = (r->end - r->start) - (lo < hi ? hi - lo : 0); + + total += kept; + if (region_is_data(r)) + data += kept; + } + + /* Each limit is compared with what it has left, so a request near + * UINT64_MAX cannot wrap a sum. + */ + if (total > as_limit || grow > as_limit - total) + return false; + return !grow_is_data || (data <= data_limit && grow <= data_limit - data); +} + +/* RLIMIT_DATA on an mprotect that adds PROT_WRITE, after mprotect_fixup (Linux + * mm/mprotect.c). The kernel takes the mappings of the range in address order. + * One that turns into data, a private mapping that was not writable, fails with + * ENOMEM when may_expand_vm refuses it as data and would have allowed it as + * what it was, and the mappings below it have been changed by then. The second + * half is why a range that RLIMIT_AS already refuses goes through. + * + * [@start, @end) is in region coordinates. + * + * Returns true when a mapping fails, with @stop set to where the range reaches + * it. Caller holds mmap_lock. + */ +static bool mprotect_exceeds_data(const guest_t *g, + uint64_t start, + uint64_t end, + uint64_t *stop) +{ + uint64_t data_limit = data_rlimit(); + if (data_limit == LINUX_RLIM_INFINITY) + return false; + + uint64_t as_limit = sys_rlimit_cur(LINUX_RLIMIT_AS); + uint64_t total = 0, data = 0; + for (int i = 0; i < g->nregions; i++) { + const guest_region_t *r = &g->regions[i]; + if (!guest_region_in_process_vm(g, r)) + continue; + total += r->end - r->start; + if (region_is_data(r)) + data += r->end - r->start; + } + + for (int i = guest_region_first_end_above(g, start); + i < g->nregions && g->regions[i].start < end; i++) { + const guest_region_t *r = &g->regions[i]; + if (r->shared || (r->prot & LINUX_PROT_WRITE) || + !strcmp(r->name, "[stack]")) + continue; + + uint64_t lo = r->start > start ? r->start : start; + uint64_t hi = r->end < end ? r->end : end; + uint64_t pages = hi - lo; + bool fits_as = total <= as_limit && pages <= as_limit - total; + bool fits_data = data <= data_limit && pages <= data_limit - data; + if (fits_as && !fits_data) { + *stop = lo; + return true; + } + data += pages; + } + return false; +} + +/* Stack growth. + * + * The stack is the region named "[stack]"; the name is what a region has in + * place of VM_GROWSDOWN, and it survives a split. The region starts at execve + * as the argument block plus STACK_INITIAL_EXPAND and everything below it has + * no page-table entry, so the first access to a page under it faults, from the + * guest or from a syscall acting on a guest pointer, and the stack grows to + * that page if Linux would let it. + */ + +/* What setup_arg_pages (Linux fs/exec.c) maps below the argument strings. */ +#define STACK_INITIAL_EXPAND (128 * 1024ULL) + +/* mmap_min_addr: no stack grows into the first 64 KiB. */ +#define STACK_MIN_ADDR 0x10000ULL + +/* Take the page-table entries off [@from, @to), a range that belongs to no + * region and ends where the stack starts. The window between the break and the + * stack is premapped at boot, and a break maps the whole 2 MiB block it ends + * in; an address mapped that way is one the stack could move onto with no fault + * to grow it or to refuse it. + * + * Whole blocks go without being split, so a break far from the stack keeps its + * block mapping. Within two blocks of the stack that mapping is itself in the + * way, and the range is cut back to the page. + */ +static void stack_unmap_approach(guest_t *g, uint64_t from, uint64_t to) +{ + uint64_t start = ALIGN_UP(from, BLOCK_2MIB); + if (to > from && to - from < 2 * BLOCK_2MIB) + start = PAGE_ALIGN_UP(from); + if (start < to) + (void) guest_invalidate_ptes(g, start, to); +} + +/* Grow the stack down to the page holding @off, after expand_downwards and + * acct_stack_growth (Linux mm/mmap.c): the address has to lie under a stack + * region with nothing between them, STACK_GUARD_GAP above an accessible mapping + * below, and the grown region has to fit RLIMIT_STACK and RLIMIT_AS. A stack is + * not data, so RLIMIT_DATA does not count it. + * + * @off is in region coordinates. + * + * Returns true when the page is now mapped. Caller holds mmap_lock. + */ +bool mem_stack_grow(guest_t *g, uint64_t off) +{ + if (off < STACK_MIN_ADDR) + return false; + + /* A table that has lost records cannot show every mapping under the stack. + * The window execve set aside is the one place the break never reaches + * (brk_grow_limit), so the stack keeps growing there and stops at its lower + * end. + */ + if (g->regions_tracker_stale && off < g->stack_base) + return false; + + /* find_vma: the first region that ends above the address takes the fault, + * and only a stack that starts above it grows. + */ + int i = guest_region_first_end_above(g, off); + if (i >= g->nregions) + return false; + guest_region_t *r = &g->regions[i]; + if (off >= r->start || strcmp(r->name, "[stack]")) + return false; + + uint64_t page = ALIGN_DOWN(off, GUEST_PAGE_SIZE); + uint64_t below_end = 0; + if (i > 0) { + const guest_region_t *below = &g->regions[i - 1]; + below_end = below->end; + if (page < below_end) + return false; + if (below->prot != LINUX_PROT_NONE && strcmp(below->name, "[stack]") && + page - below_end < STACK_GUARD_GAP) + return false; + } + if (guest_range_hits_infra(g, page, r->start)) + return false; + + /* The scan above saw only the records the table still holds. A mapping that + * lost its record keeps its pages, so with a stale table the page tables + * are asked too, and the stack does not grow over one. + */ + if (g->regions_tracker_stale && guest_range_any_mapped(g, page, r->start)) + return false; + + if (r->end - page > sys_rlimit_cur(LINUX_RLIMIT_STACK) || + !mem_rlimit_allows(g, r->start - page, LINUX_PROT_READ, + LINUX_MAP_PRIVATE, 0, 0)) + return false; + + /* Whatever used these pages before, a new stack page reads as zero. */ + memset((uint8_t *) g->host_base + page, 0, r->start - page); + if (r->prot != LINUX_PROT_NONE && + guest_map_pages(g, page, r->start, prot_to_perms(r->prot)) < 0) + return false; + /* The stack is identity-mapped, so its backing address moves with it. */ + r->gpa_base -= r->start - page; + r->start = page; + if (i > 0 && page - below_end < 2 * BLOCK_2MIB) + stack_unmap_approach(g, below_end, page); + return true; +} + +bool mem_stack_grow_for_host(guest_t *g, uint64_t gva) +{ + /* Cheap refusal first: this runs for every guest pointer that does not + * translate, and almost none of those are under the stack. + */ + uint64_t off = gva - g->ipa_base; + if (gva < g->ipa_base || off >= g->stack_top || off < STACK_MIN_ADDR) + return false; + + /* An SC_LOCKED caller already has mmap_lock. Any other caller waits for it, + * unless it declared a lock ranked below mmap_lock with + * mmap_lock_nowait_begin: that one may only try, and gives up on a holder + * that keeps the lock. + * + * The answer does not depend on who did the growing: a sibling thread may + * have grown the stack over @gva between the caller's failed translation + * and the lock, or while this thread tried for the lock in vain, and then + * the address translates with nothing left to grow here. + */ + if (mmap_lock_held()) { + (void) mem_stack_grow(g, off); + } else if (mmap_lock_nowait_depth == 0) { + mmap_lock_acquire(); + (void) mem_stack_grow(g, off); + mmap_lock_release(); + } else if (mmap_lock_try_acquire()) { + (void) mem_stack_grow(g, off); + mmap_lock_release(); + } + return true; +} + +void mem_stack_init_extent(guest_t *g, uint64_t sp) +{ + /* setup_arg_pages: the pages the argument block took, plus + * STACK_INITIAL_EXPAND, held to RLIMIT_STACK, and never less than what is + * already written so the image can start. + */ + uint64_t used = g->stack_top - ALIGN_DOWN(sp, GUEST_PAGE_SIZE); + uint64_t limit = + ALIGN_DOWN(sys_rlimit_cur(LINUX_RLIMIT_STACK), GUEST_PAGE_SIZE); + uint64_t size = used + STACK_INITIAL_EXPAND; + if (size > limit) + size = limit; + if (size < used) + size = used; + if (size > g->stack_top - g->stack_base) + size = g->stack_top - g->stack_base; + + uint64_t lo = g->stack_top - size; + if (lo > g->stack_base) + (void) guest_region_remove(g, g->stack_base, lo); + + /* The trim advanced the record's file offset with its start, which is right + * for a file mapping and shows as a nonzero offset on the [stack] line of + * /proc/self/maps. + */ + int kept = guest_region_first_end_above(g, lo); + if (kept < g->nregions && g->regions[kept].start == lo) + g->regions[kept].offset = 0; + (void) guest_invalidate_ptes(g, g->stack_base, lo); + stack_unmap_approach(g, g->brk_current, g->stack_base); +} + /* How far the break may grow from its current position. * * The first tracked mapping above the break is where Linux stops a grow. An * unbounded grow can zero the stack or a mapping in the gap above the heap. * * The answer starts at g->stack_base and only comes down from there. That is - * not a backstop under the table but the operative bound at the stack: it is - * the same address the "[stack-guard]" record starts at, so the scan below - * stops before ever reading that record, and the stack stays bounded whether or - * not the record is there to say so. + * not a backstop under the table but the operative bound at the stack: the + * stack region starts small near the top of its 8 MiB window and grows on + * demand, so most of the window has no record, and a break allowed up to the + * record would leave a default stack nowhere to grow. * * Below the stack the table is the only account of what is in the way, and a * full one loses entries: a munmap split it had no room to record leaves its @@ -2657,9 +3031,15 @@ static uint64_t brk_grow_limit(const guest_t *g, uint64_t from, int *adjoining) heap_piece_is_plain(&g->regions[i - 1])) *adjoining = i - 1; - if (i < g->nregions) - limit = brk_limit_region(from, limit, g->regions[i].start, - g->regions[i].end); + if (i < g->nregions) { + /* The break stays STACK_GUARD_GAP under the stack, as the stack does + * above the break (vm_start_gap, Linux include/linux/mm.h). + */ + uint64_t start = g->regions[i].start; + if (!strcmp(g->regions[i].name, "[stack]")) + start = start > STACK_GUARD_GAP ? start - STACK_GUARD_GAP : 0; + limit = brk_limit_region(from, limit, start, g->regions[i].end); + } return limit; } @@ -2710,6 +3090,18 @@ int64_t sys_brk(guest_t *g, uint64_t addr) return (int64_t) ipa_brk; } + /* The heap record ends at the break itself, so the growth the table sees is + * the byte difference. check_data_rlimit (Linux include/linux/mm.h) holds + * the break to the soft limit itself, so the allowance for a soft limit of + * 0 is not for brk. + */ + if (new_off > old_brk && + (sys_rlimit_cur(LINUX_RLIMIT_DATA) == 0 || + !mem_rlimit_allows(g, new_off - old_brk, + LINUX_PROT_READ | LINUX_PROT_WRITE, + LINUX_MAP_PRIVATE, 0, 0))) + return (int64_t) ipa_brk; + /* Shrinking can split a file-backed semantic region when a fork child has * grown and reshaped its heap metadata. Reserve the right-hand region's * backing fd before publishing the new break, so EMFILE leaves both the @@ -2741,6 +3133,32 @@ int64_t sys_brk(guest_t *g, uint64_t addr) new_off - g->brk_current); } + /* Keep the stack's approach faulting (stack_unmap_approach): a break that + * has come close gives up the rest of the block it just mapped, and one + * that shrinks gives up the blocks it no longer reaches. + */ + if (new_off > old_brk) { + int above = guest_region_first_end_above(g, new_off); + if (above < g->nregions && g->regions[above].start >= new_off && + g->regions[above].start - new_off < 2 * BLOCK_2MIB && + !strcmp(g->regions[above].name, "[stack]")) + stack_unmap_approach(g, new_off, g->regions[above].start); + } else if (new_off < old_brk) { + /* Up to the end of the old break's block, or to the mapping that shares + * that block with it. A mapping that straddles the old break keeps the + * page the break was in, so the unmapping stops below that page. + */ + uint64_t keep = ALIGN_UP(new_off, BLOCK_2MIB); + uint64_t had = ALIGN_UP(old_brk, BLOCK_2MIB); + int above = guest_region_first_end_above(g, old_brk); + if (above < g->nregions && g->regions[above].start < had) + had = g->regions[above].start > old_brk + ? g->regions[above].start + : ALIGN_DOWN(old_brk, GUEST_PAGE_SIZE); + if (keep < had) + (void) guest_invalidate_ptes(g, keep, had); + } + g->brk_current = new_off; /* Update "[heap]" region tracking atomically. Find-and-update-in-place @@ -2954,6 +3372,13 @@ int64_t sys_mmap(guest_t *g, */ bool is_noreplace = (flags & LINUX_MAP_FIXED_NOREPLACE) != 0; + /* A region is keyed by guest offset below guest_size and by VA above it. */ + uint64_t replace_start = 0, replace_end = 0; + if (is_fixed && !is_noreplace && addr <= UINT64_MAX - length) { + replace_start = addr >= g->guest_size ? addr : addr - g->ipa_base; + replace_end = replace_start + length; + } + uint64_t result_off; /* Result as offset (0-based) */ if (is_fixed) { /* Addresses above TASK_SIZE (bit 63 set or beyond user VA range) are @@ -2962,9 +3387,23 @@ int64_t sys_mmap(guest_t *g, if (addr > 0x0000FFFFFFFFFFFFULL) return -LINUX_ENOMEM; - if (addr >= g->guest_size) + if (addr >= g->guest_size) { + /* The descriptor is judged before the limit, as on the paths below; + * sys_mmap_high_va opens it again for the mapping. + */ + if (!(flags & LINUX_MAP_ANONYMOUS)) { + host_fd_ref_t probe = HOST_FD_REF_INIT; + int64_t ref_err = host_fd_ref_open(fd, &probe); + if (ref_err < 0) + return ref_err; + host_fd_ref_close(&probe); + } + if (!mem_rlimit_allows(g, length, prot, flags, replace_start, + replace_end)) + return -LINUX_ENOMEM; return sys_mmap_high_va(g, addr, length, prot, flags, fd, offset, true, is_noreplace); + } /* High-VA MAP_FIXED (rosetta's JIT slabs at 240 TiB, code caches at 85 * TiB, etc.) is not safe to expose yet. The previous draft could @@ -3027,6 +3466,17 @@ int64_t sys_mmap(guest_t *g, host_backing_fd = fresh.backing_ref.fd; } + /* The limits come after the address and the descriptor have been + * checked, as may_expand_vm comes after get_unmapped_area and fget in + * the kernel, so a bad descriptor is EBADF under a limit the request + * exceeds. + */ + if (!mem_rlimit_allows(g, length, prot, flags, replace_start, + replace_end)) { + mmap_fresh_dispose(&fresh); + return -LINUX_ENOMEM; + } + remove_range_t replaced = {result_off, result_off + length}; if (!region_has_capacity_after_removes(g, &replaced, 1, 1)) { mmap_fresh_dispose(&fresh); @@ -3233,17 +3683,10 @@ int64_t sys_mmap(guest_t *g, * backing once the final guest range is known. */ if (!is_fixed) { - if (g->is_rosetta && addr >= g->guest_size && - addr <= 0x0000FFFFFFFFFFFFULL) { - int64_t high_hint = sys_mmap_high_va(g, addr, length, prot, flags, - fd, offset, false, false); - if (high_hint >= 0) - return high_hint; - } - /* Open the backing fd before the gap-finder so the alignment heuristic - * can read the host fd's access mode through overlay_fd_writable. - * Closes on every failure path within the non-fixed branch. + * can read the host fd's access mode through overlay_fd_writable, and + * before the limits so a bad descriptor is EBADF. Closes on every exit + * within the non-fixed branch. */ if (!is_anon) { int64_t ref_err = host_fd_ref_open(fd, &fresh.backing_ref); @@ -3253,6 +3696,21 @@ int64_t sys_mmap(guest_t *g, } host_backing_fd = fresh.backing_ref.fd; } + if (!mem_rlimit_allows(g, length, prot, flags, replace_start, + replace_end)) { + mmap_fresh_dispose(&fresh); + return -LINUX_ENOMEM; + } + + if (g->is_rosetta && addr >= g->guest_size && + addr <= 0x0000FFFFFFFFFFFFULL) { + int64_t high_hint = sys_mmap_high_va(g, addr, length, prot, flags, + fd, offset, false, false); + if (high_hint >= 0) { + mmap_fresh_dispose(&fresh); + return high_hint; + } + } /* Prefer stage-2 2 MiB block boundaries for non-fixed MAP_SHARED * file-backed allocations. Without this each shared file mmap whose @@ -3729,6 +4187,11 @@ int64_t sys_mremap(guest_t *g, uint64_t src_gpa_base = src_reg->gpa_base; uint64_t src_start = src_reg->start; + if (new_size > old_size && + !mem_rlimit_allows(g, new_size - old_size, src_reg->prot, + src_reg->flags, 0, 0)) + return finish_mremap(&source, -LINUX_ENOMEM); + /* Same size: nothing to do */ if (old_size == new_size && !(flags & LINUX_MREMAP_FIXED)) return finish_mremap(&source, (int64_t) old_addr); @@ -4611,6 +5074,18 @@ int64_t sys_mprotect(guest_t *g, uint64_t addr, uint64_t length, int prot) if (addr > UINT64_MAX - length) return -LINUX_EINVAL; + if (prot & LINUX_PROT_WRITE) { + /* Region coordinates, as the two branches below take them. */ + uint64_t off = addr >= g->guest_size ? addr : addr - g->ipa_base; + uint64_t stop; + if (off <= UINT64_MAX - length && + mprotect_exceeds_data(g, off, off + length, &stop)) { + if (stop > off) + (void) sys_mprotect(g, addr, stop - off, prot); + return -LINUX_ENOMEM; + } + } + if (addr <= 0x0000FFFFFFFFFFFFULL) { if (addr >= g->guest_size) { uint64_t mprot_end = addr + length; diff --git a/src/syscall/mem.h b/src/syscall/mem.h index e90312cb..f36bc5f4 100644 --- a/src/syscall/mem.h +++ b/src/syscall/mem.h @@ -21,6 +21,41 @@ typedef struct mmap_fork_anon_shared_txn mmap_fork_anon_shared_txn_t; /* brk: set/query program break */ int64_t sys_brk(guest_t *g, uint64_t addr); +/* Stack growth (see the block comment in mem.c). + * + * mem_stack_grow serves a guest fault at @off, in region coordinates, with + * mmap_lock held, and returns true when the address is now mapped. + * mem_stack_grow_for_host serves a host-side access to @gva that found no + * translation; it takes mmap_lock itself unless the caller holds it, and + * returns true when the caller should translate @gva again. + * + * mem_stack_init_extent cuts the stack region a new image was built in down to + * what Linux maps at execve. @sp is the initial stack pointer. The caller holds + * mmap_lock or runs before the guest does. + */ +bool mem_stack_grow(guest_t *g, uint64_t off); +bool mem_stack_grow_for_host(guest_t *g, uint64_t gva); +void mem_stack_init_extent(guest_t *g, uint64_t sp); + +/* A host read or write of guest memory can grow the stack, which takes + * mmap_lock, so it ranks where mmap_lock does in the order in internal.h. A + * caller that touches guest memory while it holds a lock ranked below mmap_lock + * says so by bracketing the access with these: inside the scope the growth only + * tries for mmap_lock, and an address it could not grow to reads as unmapped. + * Every other access waits for the lock. Scopes nest. + */ +void mmap_lock_nowait_begin(void); +void mmap_lock_nowait_end(void); + +#ifdef ELFUSE_LOCK_ASSERT +/* make check-locks: the thread holds no counted lock and is in no nowait scope, + * so it may block on mmap_lock; and a guest access is either under mmap_lock, + * inside a nowait scope, or made with no counted lock held. + */ +bool mmap_lock_may_block(void); +void mmap_lock_assert_guest_access(void); +#endif + /* mmap: map pages into guest address space */ int64_t sys_mmap(guest_t *g, uint64_t addr, diff --git a/src/syscall/proc.c b/src/syscall/proc.c index c127a543..1cb33ec2 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -53,6 +53,8 @@ #include "syscall/proc-pidfd.h" #include "syscall/proc-state.h" #include "syscall/signal.h" +#include "syscall/mem.h" +#include "syscall/sys.h" #include "syscall/wakeup-pipe.h" #include "debug/crashreport.h" @@ -587,6 +589,20 @@ pid_t proc_guest_to_host_pid(int64_t gpid) return result; } +bool proc_child_exited(int64_t gpid) +{ + pthread_mutex_lock(&pid_lock); + const proc_entry_t *entry = proc_find_guest_entry(gpid); + bool known = entry != NULL, exited = entry && entry->exited; + pthread_mutex_unlock(&pid_lock); + + /* The entry is marked when the host process has been collected; the child + * records its own exit in the lifecycle registry before that. + */ + return exited || + (known && lifecycle_query_exit(gpid, NULL, NULL, NULL, NULL, NULL)); +} + /* Build the path to the cross-process signal-transport file for @host_pid. * * Files live in the per-user private temp directory macOS provisions (mode @@ -1224,7 +1240,7 @@ typedef struct { * * Returns false once @pid has exited. */ -static bool host_start_us(pid_t pid, uint64_t *out) +bool proc_host_start_us(pid_t pid, uint64_t *out) { struct proc_bsdinfo info; if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != @@ -1250,7 +1266,7 @@ static void registry_parse_cb(char *rec, void *vctx) if (hp <= 0 || hp > INT_MAX || pg < 0 || pg > INT_MAX) return; uint64_t live_us; - if (!host_start_us((pid_t) hp, &live_us) || live_us != (uint64_t) st) + if (!proc_host_start_us((pid_t) hp, &live_us) || live_us != (uint64_t) st) return; int idx = -1; for (int k = 0; k < c->n; k++) @@ -1296,7 +1312,7 @@ static void registry_find_by_host_cb(char *rec, void *vctx) if ((pid_t) hp != c->target) return; uint64_t live_us; - if (!host_start_us((pid_t) hp, &live_us) || live_us != (uint64_t) st) + if (!proc_host_start_us((pid_t) hp, &live_us) || live_us != (uint64_t) st) return; c->guest_pid = (int64_t) gp; c->found = true; @@ -1353,7 +1369,7 @@ static void proc_registry_publish(pid_t host_pid, break; } uint64_t start_us; - if (idx < 0 && host_start_us(host_pid, &start_us)) { + if (idx < 0 && proc_host_start_us(host_pid, &start_us)) { if (n == REGISTRY_MAX_ENTRIES) /* No slot for a new live member: group signals (kill(-1), @@ -2941,7 +2957,10 @@ int64_t sys_waitid(guest_t *g, memcpy(si + SIGINFO_OFF_UID, &uid, 4); memcpy(si + SIGINFO_OFF_STATUS, &si_status, 4); - if (guest_write_small(g, infop_gva, si, SIGINFO_SIZE) < 0) { + mmap_lock_nowait_begin(); + int si_rc = guest_write_small(g, infop_gva, si, SIGINFO_SIZE); + mmap_lock_nowait_end(); + if (si_rc < 0) { pthread_mutex_unlock(&pid_lock); return -LINUX_EFAULT; } @@ -3132,6 +3151,11 @@ static void *preempt_thread_main(void *arg) pthread_cond_broadcast(&pid_cond); pthread_mutex_unlock(&pid_lock); wakeup_pipe_signal(); + + /* And how prlimit64 in another process reports that it changed this + * one's RLIMIT_CPU. + */ + sys_rlimit_cpu_check(); } else if (sig == SIGALRM) { /* sys_wait4's entry is the only other caller, so a guest that takes * its last child's status and never waits again strands that @@ -3862,6 +3886,24 @@ static bool vcpu_handle_el0_fault(guest_t *g, uint32_t fault_ec = (uint32_t) ((esr >> 26) & 0x3F); + /* A DC ZVA the shim would not emulate because EL0 cannot write the block + * (handle_sysreg_trap, src/core/shim.S). It arrives with the ESR of the + * trap, EC 0x18 and the DC ZVA encoding in ISS[21:10] and ISS[4:0], and is + * the write data abort the instruction would have taken untrapped: the + * address is the block in Xt, and the fault status is what the shim's AT + * S1E0W probe left in PAR_EL1.FST. + */ + if (fault_ec == 0x18 && ((esr >> 10) & 0xFFF) == 0x4B7 && + (esr & 0x1F) == 0x08) { + uint64_t xt = 0, par = 0; + hv_vcpu_get_reg(vcpu, HV_REG_X0 + (uint32_t) ((esr >> 5) & 0x1F), &xt); + hv_vcpu_get_sys_reg(vcpu, HV_SYS_REG_PAR_EL1, &par); + far_addr = xt & ~63ULL; + fault_ec = 0x24; + esr = + (0x24ULL << 26) | (1ULL << 25) | (1ULL << 6) | ((par >> 1) & 0x3F); + } + /* Non-abort EC -> SIGILL. Branch out early so the abort / SIGSEGV path * below stays at the case-body indent rather than nested inside an else * branch. FAR_EL1 is UNKNOWN for non-abort exceptions, so use ELR_EL1 for @@ -3920,6 +3962,13 @@ static bool vcpu_handle_el0_fault(guest_t *g, uint64_t fault_off = far_addr - g->ipa_base; mmap_lock_acquire(); int mat = guest_materialize_lazy(g, fault_off); + + /* A page under the stack is the other fault that is not an error: the + * stack grows to it when RLIMIT_STACK and its neighbors allow, and the + * fault stays a SIGSEGV when they do not. + */ + if (mat != 0 && mem_stack_grow(g, fault_off)) + mat = 0; mmap_lock_release(); if (mat == 0) { /* Page materialized; the helpers inside guest_materialize_lazy diff --git a/src/syscall/proc.h b/src/syscall/proc.h index ad4303da..ee03c0a8 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -549,6 +549,16 @@ int proc_set_child_pgid(int64_t guest_pid, int64_t pgid); */ pid_t proc_guest_to_host_pid(int64_t gpid); +/* Start time of host process @pid in microseconds, which with the pid names one + * process: a later process that reuses the pid starts later. + * + * Returns false once @pid has exited. + */ +bool proc_host_start_us(pid_t pid, uint64_t *out); + +/* Whether @gpid is a child in the table that has exited and not been reaped. */ +bool proc_child_exited(int64_t gpid); + /* Look up a host PID in the child process table, falling back to the * cross-process registry so the rest of the fork family (grandchildren, * siblings' descendants) resolves too. diff --git a/src/syscall/signal.c b/src/syscall/signal.c index ccf62ad3..cfb4c733 100644 --- a/src/syscall/signal.c +++ b/src/syscall/signal.c @@ -1549,26 +1549,20 @@ int64_t signal_rt_sigaction(guest_t *g, int idx = signum - 1; + /* Linux order (kernel/signal.c): the new action is read before sig_lock and + * the old one written after it, so neither copy runs under a lock ranked + * below mmap_lock, and an oldact fault leaves the new action installed. + */ + linux_sigaction_t act, oldact; + if (act_gva && guest_read_small(g, act_gva, &act, sizeof(act)) < 0) + return -LINUX_EFAULT; + bool reap_exited_sigchld = false; pthread_mutex_lock(&sig_lock); - - /* Return old action if requested */ - if (oldact_gva) { - if (guest_write_small(g, oldact_gva, &sig_state.actions[idx], - sizeof(linux_sigaction_t)) < 0) { - pthread_mutex_unlock(&sig_lock); - return -LINUX_EFAULT; - } - } + oldact = sig_state.actions[idx]; /* Install new action if provided */ if (act_gva) { - linux_sigaction_t act; - if (guest_read_small(g, act_gva, &act, sizeof(act)) < 0) { - pthread_mutex_unlock(&sig_lock); - return -LINUX_EFAULT; - } - log_debug( "rt_sigaction(%d): handler=0x%llx flags=0x%llx " "restorer=0x%llx mask=0x%llx%s%s%s%s", @@ -1596,6 +1590,9 @@ int64_t signal_rt_sigaction(guest_t *g, pthread_mutex_unlock(&sig_lock); if (reap_exited_sigchld) proc_autoreap_exited_children(); + if (oldact_gva && + guest_write_small(g, oldact_gva, &oldact, sizeof(oldact)) < 0) + return -LINUX_EFAULT; return 0; } @@ -1610,29 +1607,20 @@ int64_t signal_rt_sigprocmask(guest_t *g, if (sigsetsize != 8) return -LINUX_EINVAL; + /* Linux order (kernel/signal.c): the new set is read before sig_lock and + * the old one written after it, so neither copy runs under a lock ranked + * below mmap_lock, and an oldset fault leaves the new mask applied. + */ + uint64_t set = 0; + if (set_gva && guest_read_small(g, set_gva, &set, sizeof(set)) < 0) + return -LINUX_EFAULT; + pthread_mutex_lock(&sig_lock); _Atomic uint64_t *blocked = thread_blocked_ptr(); - - /* Return old mask if requested. Load the value out first: handing - * guest_write_small the _Atomic object copies its representation rather - * than performing an atomic read of it. - */ - if (oldset_gva) { - uint64_t old_mask = atomic_load_explicit(blocked, memory_order_relaxed); - if (guest_write_small(g, oldset_gva, &old_mask, sizeof(old_mask)) < 0) { - pthread_mutex_unlock(&sig_lock); - return -LINUX_EFAULT; - } - } + uint64_t old_mask = atomic_load_explicit(blocked, memory_order_relaxed); /* Apply new mask if provided */ if (set_gva) { - uint64_t set; - if (guest_read_small(g, set_gva, &set, sizeof(set)) < 0) { - pthread_mutex_unlock(&sig_lock); - return -LINUX_EFAULT; - } - /* Never allow blocking SIGKILL or SIGSTOP */ uint64_t unmaskable = sig_bit(LINUX_SIGKILL) | sig_bit(LINUX_SIGSTOP); @@ -1678,6 +1666,9 @@ int64_t signal_rt_sigprocmask(guest_t *g, } pthread_mutex_unlock(&sig_lock); + if (oldset_gva && + guest_write_small(g, oldset_gva, &old_mask, sizeof(old_mask)) < 0) + return -LINUX_EFAULT; return 0; } @@ -2403,6 +2394,31 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, */ syscall_restart_cancel(vcpu); + /* The frame is built and written with sig_lock dropped, as Linux get_signal + * hands setup_rt_frame a copy of the action and releases siglock: the frame + * write and the rseq abort touch guest memory, which may grow the stack and + * so wait for mmap_lock, and sig_lock ranks below it. SA_RESETHAND takes + * effect before the lock is let go, also as in get_signal, so a sibling + * taking the same signal meanwhile sees SIG_DFL. The sigsuspend saved mask + * is consumed here because it is only touched under sig_lock (thread.h). + * + * If delivering from sigsuspend, the frame carries the ORIGINAL blocked + * mask so rt_sigreturn restores it (not the temporary sigsuspend mask). + */ + linux_sigaction_t ka = *act; + if (act->sa_flags & LINUX_SA_RESETHAND) { + act->sa_handler = LINUX_SIG_DFL; + act->sa_flags &= ~LINUX_SA_SIGINFO; + } + uint64_t frame_mask; + if (*valid_ptr) { + frame_mask = *saved_ptr; + *valid_ptr = false; + } else { + frame_mask = atomic_load_explicit(blocked, memory_order_relaxed); + } + pthread_mutex_unlock(&sig_lock); + /* 1. Save current vCPU state. * * ELR_EL1/SPSR_EL1 hold the interrupted EL0 return state only while the @@ -2457,7 +2473,6 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_ELR_EL1, saved_pc); if (rseq_rc == -1) { *exit_code = 128 + 11; /* SIGSEGV */ - pthread_mutex_unlock(&sig_lock); return -1; } } @@ -2497,16 +2512,7 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, frame.uc.uc_flags = cookie; frame.uc.uc_link = 0; - /* If delivering from sigsuspend, store the ORIGINAL blocked mask so - * rt_sigreturn restores it (not the temporary sigsuspend mask). - */ - if (*valid_ptr) { - frame.uc.uc_sigmask = *saved_ptr; - *valid_ptr = false; - } else { - frame.uc.uc_sigmask = - atomic_load_explicit(blocked, memory_order_relaxed); - } + frame.uc.uc_sigmask = frame_mask; /* sigcontext: save all registers. fault_address: for synchronous faults * (BRK, segfault), set to the faulting address; for asynchronous signals, @@ -2548,7 +2554,7 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, */ uint64_t signal_sp = saved_sp; bool use_altstack = false; - if (thr && (act->sa_flags & LINUX_SA_ONSTACK) && + if (thr && (ka.sa_flags & LINUX_SA_ONSTACK) && !(thr->altstack_flags & LINUX_SS_DISABLE) && !thr->on_altstack) { /* Place frame at top of altstack (stack grows down) */ signal_sp = thr->altstack_sp + thr->altstack_size; @@ -2571,7 +2577,6 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, (unsigned long long) (use_altstack && thr ? thr->altstack_sp : 0), signum); *exit_code = 128 + signum; - pthread_mutex_unlock(&sig_lock); return -1; } @@ -2597,13 +2602,13 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, if (pushed_cookie) sigreturn_cookie_depth--; *exit_code = 128 + signum; - pthread_mutex_unlock(&sig_lock); return -1; } + pthread_mutex_lock(&sig_lock); /* 4. Redirect vCPU to signal handler */ hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_SP_EL0, frame_sp); - hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_ELR_EL1, act->sa_handler); + hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_ELR_EL1, ka.sa_handler); /* SPSR_EL1: EL0t (user mode) */ hv_vcpu_set_sys_reg(vcpu, HV_SYS_REG_SPSR_EL1, 0); @@ -2613,7 +2618,7 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, * unwinds back to EL0 through the shim ERET. */ if (el0_preempt) { - hv_vcpu_set_reg(vcpu, HV_REG_PC, act->sa_handler); + hv_vcpu_set_reg(vcpu, HV_REG_PC, ka.sa_handler); hv_vcpu_set_reg(vcpu, HV_REG_CPSR, 0); /* EL0t */ } @@ -2629,7 +2634,7 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, */ hv_vcpu_set_reg(vcpu, HV_REG_X30, VDSO_BASE + VDSO_OFF_SIGRET); - if (act->sa_flags & LINUX_SA_SIGINFO) { + if (ka.sa_flags & LINUX_SA_SIGINFO) { /* X1 = pointer to siginfo, X2 = pointer to ucontext */ uint64_t siginfo_addr = frame_sp; uint64_t ucontext_addr = frame_sp + sizeof(linux_siginfo_t); @@ -2645,9 +2650,9 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, * two can never overlap. */ uint64_t new_blocked = atomic_load_explicit(blocked, memory_order_relaxed); - if (!(act->sa_flags & LINUX_SA_NODEFER)) + if (!(ka.sa_flags & LINUX_SA_NODEFER)) new_blocked |= sig_bit(signum); - new_blocked |= act->sa_mask; + new_blocked |= ka.sa_mask; /* Never block SIGKILL/SIGSTOP */ new_blocked &= ~(sig_bit(LINUX_SIGKILL) | sig_bit(LINUX_SIGSTOP)); atomic_store_explicit(blocked, new_blocked, memory_order_release); @@ -2656,12 +2661,6 @@ static int deliver_signal_locked(hv_vcpu_t vcpu, if (use_altstack && thr) thr->on_altstack = true; - /* 7. Reset to SIG_DFL if SA_RESETHAND is set */ - if (act->sa_flags & LINUX_SA_RESETHAND) { - act->sa_handler = LINUX_SIG_DFL; - act->sa_flags &= ~LINUX_SA_SIGINFO; - } - /* If delivery happens while returning from the syscall HVC path, the shim * still has the interrupted syscall frame on its EL1 stack. Tell it to drop * that frame so the handler PC/SP/LR/args installed above are not diff --git a/src/syscall/sys.c b/src/syscall/sys.c index 1b5755bc..b7f5310f 100644 --- a/src/syscall/sys.c +++ b/src/syscall/sys.c @@ -10,11 +10,21 @@ * syscall/syscall.c. */ +#include +#include #include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -22,14 +32,17 @@ #include #include +#include "debug/log.h" #include "utils.h" #include "proved/slice.h" #include "core/shim-globals.h" +#include "runtime/thread.h" #include "syscall/linux-wire.h" #include "syscall/internal.h" #include "syscall/proc.h" +#include "syscall/signal.h" #include "syscall/sys.h" /* System info syscall handlers. */ @@ -71,14 +84,6 @@ static uint64_t cached_page_size = 0; static mach_port_t cached_host_port = MACH_PORT_NULL; static time_t cached_sysinfo_sec = -1; static linux_sysinfo_t cached_sysinfo; -static pthread_mutex_t rlimit_lock = PTHREAD_MUTEX_INITIALIZER; -static linux_rlimit64_t cached_linux_rlimits[10]; -static uint8_t cached_linux_rlimit_valid[10]; -static linux_rlimit64_t guest_nofile_limit = { - .rlim_cur = FD_TABLE_SIZE, - .rlim_max = FD_TABLE_SIZE, -}; -#define RLIMIT_CACHE_SIZE ((int) (ARRAY_SIZE(cached_linux_rlimits))) /* The full-size assertion lives with write_rusage_to_guest() in proc.c; this * offset check guards the fast memcpy translation done there. @@ -594,207 +599,715 @@ int64_t sys_sysinfo(guest_t *g, uint64_t info_gva) /* Resource limits. */ -/* Translate Linux RLIMIT_* resource numbers to macOS equivalents. The numbering - * differs: Linux RLIMIT_NPROC=6 vs macOS RLIMIT_NPROC=7. +/* Linux RLIMIT_* numbers to the macOS ones, for seeding the guest table from + * the host. The numbering differs: Linux RLIMIT_NPROC=6 vs macOS + * RLIMIT_NPROC=7. */ static int translate_rlimit_resource(int linux_res) { switch (linux_res) { - case 0: + case LINUX_RLIMIT_CPU: return RLIMIT_CPU; - case 1: + case LINUX_RLIMIT_FSIZE: return RLIMIT_FSIZE; - case 2: + case LINUX_RLIMIT_DATA: return RLIMIT_DATA; - case 3: + case LINUX_RLIMIT_STACK: return RLIMIT_STACK; - case 4: + case LINUX_RLIMIT_CORE: return RLIMIT_CORE; - case 5: + case LINUX_RLIMIT_RSS: return RLIMIT_RSS; - case 6: + case LINUX_RLIMIT_NPROC: return RLIMIT_NPROC; /* Linux 6 -> macOS 7 */ - case 7: + case LINUX_RLIMIT_NOFILE: return RLIMIT_NOFILE; /* Linux 7 -> macOS 8 */ - case 8: + case LINUX_RLIMIT_MEMLOCK: return RLIMIT_MEMLOCK; /* Linux 8 -> macOS 6 */ - case 9: + case LINUX_RLIMIT_AS: return RLIMIT_AS; default: return -1; } } +/* MAXSSIZ on arm64 macOS: 64 MiB less one 16 KiB page. */ +#define HOST_STACK_HARD_CAP (64ULL * 1024 * 1024 - 16 * 1024) + static linux_rlimit64_t translate_host_rlimit(int resource, struct rlimit rl) { linux_rlimit64_t lim = {0}; - lim.rlim_cur = (rl.rlim_cur == RLIM_INFINITY) ? UINT64_MAX : rl.rlim_cur; - lim.rlim_max = (rl.rlim_max == RLIM_INFINITY) ? UINT64_MAX : rl.rlim_max; + lim.rlim_cur = + (rl.rlim_cur == RLIM_INFINITY) ? LINUX_RLIM_INFINITY : rl.rlim_cur; + lim.rlim_max = + (rl.rlim_max == RLIM_INFINITY) ? LINUX_RLIM_INFINITY : rl.rlim_max; /* macOS returns ~8MiB-16KiB for the default stack; round to Linux's * conventional 8MiB to keep guest userspace behavior stable. */ - if (resource == 3 /* RLIMIT_STACK */ && lim.rlim_cur > 0 && + if (resource == LINUX_RLIMIT_STACK && lim.rlim_cur > 0 && lim.rlim_cur < 8388608) { lim.rlim_cur = 8388608; + if (lim.rlim_max < lim.rlim_cur) + lim.rlim_max = lim.rlim_cur; } + /* macOS also caps the hard stack limit, at MAXSSIZ, where Linux starts a + * process with it unlimited. The cap is about host thread stacks and bounds + * nothing in the guest, so a host that sits at it reads as unlimited; one + * whose user lowered it keeps the lower value. + */ + if (resource == LINUX_RLIMIT_STACK && lim.rlim_max >= HOST_STACK_HARD_CAP) + lim.rlim_max = LINUX_RLIM_INFINITY; + return lim; } -static bool rlimit_cache_get(int resource, linux_rlimit64_t *lim) +/* Guest rlimit table. + * + * prlimit64 reads and writes these slots and never calls host setrlimit, so a + * guest limit cannot constrain elfuse itself, and RSS keeps a slot of its own + * although macOS defines RLIMIT_RSS as RLIMIT_AS. + * + * A process publishes its table as a file under the per-user temp directory, + * named by host pid and mapped shared, so prlimit64 on another fork-family + * member maps that member's file and changes the limits it enforces. + * rlimit_lock serializes the threads of one process, which share one open file + * description and so do not exclude each other through flock; flock serializes + * processes. A site that enforces a limit loads one soft value through + * sys_rlimit_cur and takes neither. + * + * When the file cannot be created the table stays in rlimit_private, and other + * processes get ESRCH for this pid. + */ +#define RLIMIT_TABLE_MAGIC 0x454C524CU /* "ELRL" */ + +/* How long prlimit64 waits for a live fork-family member to publish. */ +#define RLIMIT_PUBLISH_WAIT_MS 1000 + +/* Bounds on one sleep of the RLIMIT_CPU sampling thread. */ +#define RLIMIT_CPU_SLEEP_MAX_S 86400 +#define RLIMIT_CPU_SLEEP_MIN_NS 10000000ULL + +/* Order of rlimit_table_t.ids: the three uids, then the three gids. */ +enum { RLIMIT_ID_UID, RLIMIT_ID_GID = 3, RLIMIT_ID_COUNT = 6 }; + +typedef struct { + uint32_t magic; + uint32_t count; + struct { + _Atomic uint64_t cur, max; + } slot[LINUX_RLIMIT_COUNT]; + + /* Real, effective and saved guest ids of the owner, which prlimit64 in + * another process compares its own with. + */ + _Atomic uint32_t ids[RLIMIT_ID_COUNT]; + + /* proc_host_start_us of the owner, written once before the file gets its + * name. A file left by a dead process does not match whatever runs under + * its host pid afterwards. + */ + uint64_t owner_start_us; +} rlimit_table_t; + +static pthread_mutex_t rlimit_lock = PTHREAD_MUTEX_INITIALIZER; + +/* What a slot reads before sys_rlimit_init or sys_rlimit_restore has run: + * nothing is limited, and NOFILE is the table size because a fork child + * installs its inherited descriptors before sys_rlimit_restore. + */ +#define RLIMIT_SLOT_UNLIMITED {LINUX_RLIM_INFINITY, LINUX_RLIM_INFINITY} +static rlimit_table_t rlimit_private = { + .slot = + { + [LINUX_RLIMIT_CPU] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_FSIZE] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_DATA] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_STACK] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_CORE] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_RSS] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_NPROC] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_NOFILE] = {FD_TABLE_SIZE, FD_TABLE_SIZE}, + [LINUX_RLIMIT_MEMLOCK] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_AS] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_LOCKS] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_SIGPENDING] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_MSGQUEUE] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_NICE] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_RTPRIO] = RLIMIT_SLOT_UNLIMITED, + [LINUX_RLIMIT_RTTIME] = RLIMIT_SLOT_UNLIMITED, + }, +}; + +/* Both are written once per process, by rlimit_publish under rlimit_lock. The + * pointer is released there and acquired in rlimit_table_load because + * sys_rlimit_cur reads it with no lock. + */ +static rlimit_table_t *_Atomic rlimit_table = &rlimit_private; +static int rlimit_fd = -1; + +static rlimit_table_t *rlimit_table_load(void) +{ + return atomic_load_explicit(&rlimit_table, memory_order_acquire); +} + +/* Relaxed on both sides: writers hold the table's locks, and the lock-free + * reader in sys_rlimit_cur wants one word with nothing published behind it. + */ +static linux_rlimit64_t rlimit_slot_load(const rlimit_table_t *t, int resource) { - if (!RANGE_CHECK(resource, 0, RLIMIT_CACHE_SIZE) || !lim) - return false; + return (linux_rlimit64_t) { + .rlim_cur = + atomic_load_explicit(&t->slot[resource].cur, memory_order_relaxed), + .rlim_max = + atomic_load_explicit(&t->slot[resource].max, memory_order_relaxed), + }; +} - if (thread_is_single_active()) { - if (cached_linux_rlimit_valid[resource]) { - *lim = cached_linux_rlimits[resource]; - return true; - } +static void rlimit_slot_store(rlimit_table_t *t, + int resource, + linux_rlimit64_t lim) +{ + atomic_store_explicit(&t->slot[resource].cur, lim.rlim_cur, + memory_order_relaxed); + atomic_store_explicit(&t->slot[resource].max, lim.rlim_max, + memory_order_relaxed); +} + +/* Copy this process's guest ids into @t. The caller holds the table's locks. */ +static void rlimit_ids_store(rlimit_table_t *t) +{ + const uint32_t ids[RLIMIT_ID_COUNT] = { + proc_get_uid(), proc_get_euid(), proc_get_suid(), + proc_get_gid(), proc_get_egid(), proc_get_sgid(), + }; + for (int i = 0; i < RLIMIT_ID_COUNT; i++) + atomic_store_explicit(&t->ids[i], ids[i], memory_order_relaxed); +} + +static void rlimit_flock(int fd, int op) +{ + if (fd < 0) + return; + while (flock(fd, op) < 0 && errno == EINTR) + ; +} + +/* Same directory and the same reasoning as signal_transport_path in proc.c. */ +static bool rlimit_file_path(char *out, size_t out_size, pid_t host_pid) +{ + char dir[PATH_MAX]; + size_t n = confstr(_CS_DARWIN_USER_TEMP_DIR, dir, sizeof(dir)); + if (n == 0 || n > sizeof(dir)) return false; + int len = + snprintf(out, out_size, "%selfuse-rlim-%ld", dir, (long) host_pid); + return len > 0 && (size_t) len < out_size; +} + +/* Map the published table of @host_pid. + * + * Returns the mapping with its descriptor in @fd_out, or NULL. The size and the + * header are checked first because the file sits where any process of this user + * can write it, and a short file would fault on first touch. + */ +static rlimit_table_t *rlimit_file_map(pid_t host_pid, int *fd_out) +{ + char path[PATH_MAX]; + if (!rlimit_file_path(path, sizeof(path), host_pid)) + return NULL; + int fd = open(path, O_RDWR | O_NOFOLLOW | O_CLOEXEC); + if (fd < 0) + return NULL; + + struct stat st; + rlimit_table_t *t = MAP_FAILED; + if (fstat(fd, &st) == 0 && st.st_size == (off_t) sizeof(*t)) + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (t != MAP_FAILED && + (t->magic != RLIMIT_TABLE_MAGIC || t->count != LINUX_RLIMIT_COUNT)) { + munmap(t, sizeof(*t)); + t = MAP_FAILED; + } + if (t == MAP_FAILED) { + close(fd); + return NULL; } + *fd_out = fd; + return t; +} - pthread_mutex_lock(&rlimit_lock); - if (cached_linux_rlimit_valid[resource]) { - *lim = cached_linux_rlimits[resource]; - pthread_mutex_unlock(&rlimit_lock); - return true; +static void rlimit_file_unlink(void) +{ + char path[PATH_MAX]; + if (rlimit_file_path(path, sizeof(path), getpid())) + unlink(path); +} + +/* Move the table from rlimit_private into a published file. The file is filled + * under a temporary name and renamed, so a reader never maps a partial table, + * and a file left by a dead process with this host pid is replaced rather than + * truncated under a reader that still maps it. Caller holds rlimit_lock. + */ +static void rlimit_publish(void) +{ + char path[PATH_MAX], tmp[PATH_MAX]; + if (rlimit_fd >= 0 || !rlimit_file_path(path, sizeof(path), getpid())) + return; + int len = snprintf(tmp, sizeof(tmp), "%s.new", path); + if (len < 0 || (size_t) len >= sizeof(tmp)) + return; + + unlink(tmp); + int fd = + open(tmp, O_CREAT | O_EXCL | O_RDWR | O_NOFOLLOW | O_CLOEXEC, 0600); + if (fd < 0) + return; + + rlimit_table_t *t = MAP_FAILED; + if (ftruncate(fd, sizeof(*t)) == 0) + t = mmap(NULL, sizeof(*t), PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (t != MAP_FAILED) { + t->magic = RLIMIT_TABLE_MAGIC; + t->count = LINUX_RLIMIT_COUNT; + for (int r = 0; r < LINUX_RLIMIT_COUNT; r++) + rlimit_slot_store(t, r, rlimit_slot_load(&rlimit_private, r)); + rlimit_ids_store(t); + if (!proc_host_start_us(getpid(), &t->owner_start_us)) + t->owner_start_us = 0; + } + if (t == MAP_FAILED || rename(tmp, path) < 0) { + if (t != MAP_FAILED) + munmap(t, sizeof(*t)); + close(fd); + unlink(tmp); + return; } + + atomic_store_explicit(&rlimit_table, t, memory_order_release); + rlimit_fd = fd; + atexit(rlimit_file_unlink); +} + +void sys_rlimit_ids_changed(void) +{ + pthread_mutex_lock(&rlimit_lock); + rlimit_flock(rlimit_fd, LOCK_EX); + rlimit_ids_store(rlimit_table_load()); + rlimit_flock(rlimit_fd, LOCK_UN); pthread_mutex_unlock(&rlimit_lock); - return false; } -static void rlimit_cache_set(int resource, linux_rlimit64_t lim) +void sys_rlimit_forget(pid_t host_pid) { - if (!RANGE_CHECK(resource, 0, RLIMIT_CACHE_SIZE)) - return; + char path[PATH_MAX]; + if (rlimit_file_path(path, sizeof(path), host_pid)) + unlink(path); +} - if (thread_is_single_active()) { - cached_linux_rlimits[resource] = lim; - cached_linux_rlimit_valid[resource] = 1; +/* Test hook, off unless ELFUSE_HOST_RLIMIT_CHECK is set: record every host + * limit of this process and fail its exit when one has moved. Only elfuse can + * read its own limits, and a guest limit that reached host setrlimit shows + * nowhere else. tests/test-rlimit-unchanged-host drives it. + */ +#define HOST_RLIMIT_CHECK_EXIT 86 + +static struct rlimit host_rlimit_start[RLIM_NLIMITS]; + +static void host_rlimit_check(void) +{ + for (int r = 0; r < RLIM_NLIMITS; r++) { + struct rlimit now; + if (getrlimit(r, &now) < 0 || + (now.rlim_cur == host_rlimit_start[r].rlim_cur && + now.rlim_max == host_rlimit_start[r].rlim_max)) + continue; + fprintf(stderr, + "elfuse: host rlimit %d moved during the guest run: " + "%llu/%llu -> %llu/%llu\n", + r, (unsigned long long) host_rlimit_start[r].rlim_cur, + (unsigned long long) host_rlimit_start[r].rlim_max, + (unsigned long long) now.rlim_cur, + (unsigned long long) now.rlim_max); + _exit(HOST_RLIMIT_CHECK_EXIT); + } +} + +static void host_rlimit_check_arm(void) +{ + if (!getenv("ELFUSE_HOST_RLIMIT_CHECK")) return; + for (int r = 0; r < RLIM_NLIMITS; r++) + getrlimit(r, &host_rlimit_start[r]); + atexit(host_rlimit_check); +} + +static bool rlimit_pair_valid(int resource, linux_rlimit64_t lim) +{ + if (lim.rlim_cur > lim.rlim_max) + return false; + return resource != LINUX_RLIMIT_NOFILE || lim.rlim_max <= FD_TABLE_SIZE; +} + +/* What Linux gives the resources macOS has no counterpart for: INIT_RLIMITS + * (include/asm-generic/resource.h) for all but SIGPENDING, which fork_init + * (kernel/fork.c) sets to the same value as NPROC. + */ +static linux_rlimit64_t rlimit_linux_default(int resource, + linux_rlimit64_t nproc) +{ + switch (resource) { + case LINUX_RLIMIT_SIGPENDING: + return nproc; + case LINUX_RLIMIT_MSGQUEUE: + return (linux_rlimit64_t) {819200, 819200}; + case LINUX_RLIMIT_NICE: + case LINUX_RLIMIT_RTPRIO: + return (linux_rlimit64_t) {0, 0}; + default: + return (linux_rlimit64_t) {LINUX_RLIM_INFINITY, LINUX_RLIM_INFINITY}; } +} + +void sys_rlimit_init(void) +{ + linux_rlimit64_t nproc = {LINUX_RLIM_INFINITY, LINUX_RLIM_INFINITY}; pthread_mutex_lock(&rlimit_lock); - cached_linux_rlimits[resource] = lim; - cached_linux_rlimit_valid[resource] = 1; + for (int r = 0; r < LINUX_RLIMIT_COUNT; r++) { + linux_rlimit64_t lim; + struct rlimit rl; + int host_res = translate_rlimit_resource(r); + + /* The host NOFILE limit is elfuse's own capacity (main.c); the guest + * gets the size of its fd table. + */ + if (r == LINUX_RLIMIT_NOFILE) + lim = (linux_rlimit64_t) {FD_TABLE_SIZE, FD_TABLE_SIZE}; + else if (host_res >= 0 && getrlimit(host_res, &rl) == 0) + lim = translate_host_rlimit(r, rl); + else + lim = rlimit_linux_default(r, nproc); + if (r == LINUX_RLIMIT_NPROC) + nproc = lim; + rlimit_slot_store(&rlimit_private, r, lim); + } + rlimit_publish(); pthread_mutex_unlock(&rlimit_lock); + host_rlimit_check_arm(); + sys_rlimit_cpu_check(); +} + +uint64_t sys_rlimit_cur(int resource) +{ + return atomic_load_explicit(&rlimit_table_load()->slot[resource].cur, + memory_order_relaxed); } -void sys_nofile_snapshot(uint64_t *cur, uint64_t *max) +uint64_t sys_rlimit_max(int resource) +{ + return atomic_load_explicit(&rlimit_table_load()->slot[resource].max, + memory_order_relaxed); +} + +void sys_rlimit_send_xfsz(void) +{ + if (!current_thread || + !signal_queue_thread(thread_tid(current_thread), LINUX_SIGXFSZ)) + signal_queue(LINUX_SIGXFSZ); +} + +void sys_rlimit_snapshot(linux_rlimit64_t out[LINUX_RLIMIT_COUNT]) { pthread_mutex_lock(&rlimit_lock); - if (cur) - *cur = guest_nofile_limit.rlim_cur; - if (max) - *max = guest_nofile_limit.rlim_max; + rlimit_flock(rlimit_fd, LOCK_SH); + for (int r = 0; r < LINUX_RLIMIT_COUNT; r++) + out[r] = rlimit_slot_load(rlimit_table_load(), r); + rlimit_flock(rlimit_fd, LOCK_UN); pthread_mutex_unlock(&rlimit_lock); } -int sys_nofile_restore(uint64_t cur, uint64_t max) +bool sys_rlimit_table_valid(const linux_rlimit64_t in[LINUX_RLIMIT_COUNT]) { - if (cur > max || max > FD_TABLE_SIZE) - return -1; + for (int r = 0; r < LINUX_RLIMIT_COUNT; r++) + if (!rlimit_pair_valid(r, in[r])) + return false; + return true; +} +void sys_rlimit_restore(const linux_rlimit64_t in[LINUX_RLIMIT_COUNT]) +{ pthread_mutex_lock(&rlimit_lock); - guest_nofile_limit.rlim_cur = cur; - guest_nofile_limit.rlim_max = max; - fd_set_rlimit_nofile((int) cur); + for (int r = 0; r < LINUX_RLIMIT_COUNT; r++) + rlimit_slot_store(&rlimit_private, r, in[r]); + rlimit_publish(); pthread_mutex_unlock(&rlimit_lock); - return 0; + host_rlimit_check_arm(); + sys_rlimit_cpu_check(); } -static int64_t sys_prlimit64_nofile(guest_t *g, - uint64_t new_gva, - uint64_t old_gva) +/* RLIMIT_CPU, after check_process_timers (Linux + * kernel/time/posix-cpu-timers.c): SIGKILL at the hard limit, SIGXCPU at the + * soft limit, and the soft limit then moves up a second so the signal repeats + * until the hard limit. Process CPU time is the host's for this process, which + * charges a vCPU thread's guest time to it as user time. + * + * Returns how long the caller may sleep before the next expiry can be due, or 0 + * when there is nothing to wait for. The caller holds the table's locks. + */ +static uint64_t rlimit_cpu_expire(int *sig_out) { - linux_rlimit64_t new_lim = {0}; - if (new_gva != 0 && - guest_read_small(g, new_gva, &new_lim, sizeof(new_lim)) < 0) - return -LINUX_EFAULT; + rlimit_table_t *t = rlimit_table_load(); + linux_rlimit64_t lim = rlimit_slot_load(t, LINUX_RLIMIT_CPU); + uint64_t used_ns = clock_gettime_nsec_np(CLOCK_PROCESS_CPUTIME_ID); + uint64_t used_s = used_ns / NSEC_PER_SEC; + + *sig_out = 0; + if (lim.rlim_cur == LINUX_RLIM_INFINITY) + return 0; + if (used_s >= lim.rlim_max) { + *sig_out = LINUX_SIGKILL; + return 0; + } + if (used_s >= lim.rlim_cur) { + *sig_out = LINUX_SIGXCPU; + lim.rlim_cur = used_s + 1; + rlimit_slot_store(t, LINUX_RLIMIT_CPU, lim); + } + + /* CPU time advances at most one second per second on each host CPU, so the + * limit cannot be reached sooner than this, and the floor bounds how often + * a process close to its limit is sampled. + */ + uint64_t left_s = lim.rlim_cur - used_s; + if (left_s > RLIMIT_CPU_SLEEP_MAX_S) + left_s = RLIMIT_CPU_SLEEP_MAX_S; + uint64_t left_ns = left_s * NSEC_PER_SEC - used_ns % NSEC_PER_SEC; + long ncpu = sysconf(_SC_NPROCESSORS_ONLN); + uint64_t sleep_ns = left_ns / (uint64_t) (ncpu > 0 ? ncpu : 1); + return sleep_ns < RLIMIT_CPU_SLEEP_MIN_NS ? RLIMIT_CPU_SLEEP_MIN_NS + : sleep_ns; +} + +/* Signaled when the CPU limit may have changed. Waits under rlimit_lock. */ +static pthread_cond_t rlimit_cpu_cond = PTHREAD_COND_INITIALIZER; + +/* Samples process CPU time against RLIMIT_CPU. A host CPU-time timer would need + * no thread, but with setitimer(ITIMER_PROF) or host RLIMIT_CPU armed + * hv_vcpu_run stops making guest progress, so the wait is in wall time. + */ +static void *rlimit_cpu_thread_main(void *arg) +{ + (void) arg; pthread_mutex_lock(&rlimit_lock); - linux_rlimit64_t old_lim = guest_nofile_limit; - - int error = 0; - if (new_gva != 0) { - if (new_lim.rlim_cur > new_lim.rlim_max) { - error = LINUX_EINVAL; - } else if (new_lim.rlim_max > guest_nofile_limit.rlim_max) { - /* The guest has no capability path for raising its hard limit. */ - error = LINUX_EPERM; + for (;;) { + int sig; + rlimit_flock(rlimit_fd, LOCK_EX); + uint64_t sleep_ns = rlimit_cpu_expire(&sig); + rlimit_flock(rlimit_fd, LOCK_UN); + + /* rlimit_lock is a leaf (internal.h); signal_queue takes sig_lock. */ + if (sig) { + pthread_mutex_unlock(&rlimit_lock); + signal_queue(sig); + pthread_mutex_lock(&rlimit_lock); + } + + if (sleep_ns == 0) { + pthread_cond_wait(&rlimit_cpu_cond, &rlimit_lock); } else { - guest_nofile_limit = new_lim; - fd_set_rlimit_nofile((int) new_lim.rlim_cur); + struct timespec ts = { + .tv_sec = (time_t) (sleep_ns / NSEC_PER_SEC), + .tv_nsec = (long) (sleep_ns % NSEC_PER_SEC), + }; + pthread_cond_timedwait_relative_np(&rlimit_cpu_cond, &rlimit_lock, + &ts); + } + } + return NULL; +} + +void sys_rlimit_cpu_check(void) +{ + /* Relaxed: written under rlimit_lock, and a stale false only sends the + * caller through the lock to read it again. + */ + static _Atomic bool started; + if (sys_rlimit_cur(LINUX_RLIMIT_CPU) == LINUX_RLIM_INFINITY && + !atomic_load_explicit(&started, memory_order_relaxed)) + return; + + pthread_mutex_lock(&rlimit_lock); + if (atomic_load_explicit(&started, memory_order_relaxed)) { + pthread_cond_signal(&rlimit_cpu_cond); + } else { + pthread_t thr; + if (pthread_create(&thr, NULL, rlimit_cpu_thread_main, NULL) == 0) { + pthread_detach(thr); + atomic_store_explicit(&started, true, memory_order_relaxed); + } else { + log_error("elfuse: RLIMIT_CPU is not enforced: no sampling thread"); } } pthread_mutex_unlock(&rlimit_lock); +} - if (error) - return -error; - if (old_gva != 0 && - guest_write_small(g, old_gva, &old_lim, sizeof(old_lim)) < 0) - return -LINUX_EFAULT; +/* Read the slot into @old_lim and, when @new_lim is set, replace it under the + * rules of do_prlimit (Linux kernel/sys.c). The caller holds the table's locks. + * + * Returns 0 or a positive Linux errno. + */ +static int rlimit_exchange(rlimit_table_t *t, + int resource, + const linux_rlimit64_t *new_lim, + linux_rlimit64_t *old_lim) +{ + *old_lim = rlimit_slot_load(t, resource); + if (!new_lim) + return 0; + if (new_lim->rlim_cur > new_lim->rlim_max) + return LINUX_EINVAL; + + /* Raising a hard limit takes CAP_SYS_RESOURCE. NOFILE has no such path: its + * hard limit is the size of the guest fd table. + */ + if (new_lim->rlim_max > old_lim->rlim_max && + (resource == LINUX_RLIMIT_NOFILE || proc_get_euid() != 0)) + return LINUX_EPERM; + + rlimit_slot_store(t, resource, *new_lim); return 0; } +/* check_prlimit_permission (Linux kernel/sys.c) against the owner of @t: the + * caller's real uid and gid must equal all three of the owner's, or the caller + * holds CAP_SYS_RESOURCE, which here is an effective uid of 0. The caller holds + * the locks of @t. + */ +static bool rlimit_ids_permit(const rlimit_table_t *t) +{ + uint32_t uid = proc_get_uid(), gid = proc_get_gid(); + bool match = true; + + for (int i = 0; i < 3; i++) { + match &= atomic_load_explicit(&t->ids[RLIMIT_ID_UID + i], + memory_order_relaxed) == uid; + match &= atomic_load_explicit(&t->ids[RLIMIT_ID_GID + i], + memory_order_relaxed) == gid; + } + return match || proc_get_euid() == 0; +} + +/* rlimit_exchange on the table of another fork-family member. + * + * A fork child publishes its table once the fork header has been restored, + * which can be after the parent's clone returned, so a missing file is waited + * for while the registry still lists the target. + */ +static int rlimit_exchange_remote(int64_t guest_pid, + int resource, + const linux_rlimit64_t *new_lim, + linux_rlimit64_t *old_lim) +{ + rlimit_table_t *t = NULL; + int fd = -1; + pid_t host_pid; + + for (int tries = 0; !t; tries++) { + host_pid = proc_resolve_guest_pid(guest_pid); + if (host_pid < 0) + return LINUX_ESRCH; + t = rlimit_file_map(host_pid, &fd); + + /* A target that has exited or gone is ESRCH whether or not a file is + * there: a host process that was killed leaves its file behind, and the + * child table keeps naming its host pid after the child has been + * collected, by which time the pid can belong to something else. That + * something else passes kill, so the file must also carry the start + * time of the process the pid names now. + */ + uint64_t live_us; + if (proc_child_exited(guest_pid) || kill(host_pid, 0) < 0 || + (t && (!proc_host_start_us(host_pid, &live_us) || + live_us != t->owner_start_us))) { + if (t) { + munmap(t, sizeof(*t)); + close(fd); + } + return LINUX_ESRCH; + } + + /* Without a file of its own this process has no reason to expect one + * from the target, and a thread being stopped does not wait for it. + */ + if (!t && (rlimit_fd < 0 || tries == RLIMIT_PUBLISH_WAIT_MS || + thread_stop_requested())) + return LINUX_ESRCH; + if (!t) + usleep(1000); + } + + rlimit_flock(fd, new_lim ? LOCK_EX : LOCK_SH); + int err = rlimit_ids_permit(t) + ? rlimit_exchange(t, resource, new_lim, old_lim) + : LINUX_EPERM; + rlimit_flock(fd, LOCK_UN); + munmap(t, sizeof(*t)); + close(fd); + + /* The target arms its own CPU timer; SIGUSR2 has its preemption thread look + * at the limit again. + */ + if (!err && new_lim && resource == LINUX_RLIMIT_CPU) + kill(host_pid, SIGUSR2); + return err; +} + int64_t sys_prlimit64(guest_t *g, int pid, int resource, uint64_t new_gva, uint64_t old_gva) { - (void) pid; /* Ignore PID; single-process model */ - - int mac_res = translate_rlimit_resource(resource); - if (mac_res < 0) + if (!RANGE_CHECK(resource, 0, LINUX_RLIMIT_COUNT)) return -LINUX_EINVAL; - if (resource == 7 /* RLIMIT_NOFILE */) - return sys_prlimit64_nofile(g, new_gva, old_gva); - - /* Get old limits BEFORE setting new ones (Linux prlimit64 atomically - * returns old values and sets new ones; approximate by get-then-set). - */ - linux_rlimit64_t old_lim = {0}; - if (old_gva != 0) { - if (!rlimit_cache_get(resource, &old_lim)) { - struct rlimit rl; - if (getrlimit(mac_res, &rl) < 0) - return linux_errno(); - - old_lim = translate_host_rlimit(resource, rl); - rlimit_cache_set(resource, old_lim); - } - } - - /* Set new limits if requested */ - if (new_gva != 0) { - linux_rlimit64_t new_lim; - if (guest_read_small(g, new_gva, &new_lim, sizeof(new_lim)) < 0) - return -LINUX_EFAULT; - /* Translate Linux RLIM_INFINITY (UINT64_MAX) back to macOS - * RLIM_INFINITY (0x7FFFFFFFFFFFFFFF) for setrlimit. - */ - struct rlimit rl; - rl.rlim_cur = - (new_lim.rlim_cur == UINT64_MAX) ? RLIM_INFINITY : new_lim.rlim_cur; - rl.rlim_max = - (new_lim.rlim_max == UINT64_MAX) ? RLIM_INFINITY : new_lim.rlim_max; - if (setrlimit(mac_res, &rl) < 0) - return linux_errno(); - - rlimit_cache_set(resource, new_lim); - } + linux_rlimit64_t new_lim, old_lim; + if (new_gva != 0 && + guest_read_small(g, new_gva, &new_lim, sizeof(new_lim)) < 0) + return -LINUX_EFAULT; + const linux_rlimit64_t *set = new_gva ? &new_lim : NULL; - /* Write old limits to guest after set (so guest sees pre-set values) */ - if (old_gva != 0) { - if (guest_write_small(g, old_gva, &old_lim, sizeof(old_lim)) < 0) - return -LINUX_EFAULT; + /* find_task_by_vpid takes any thread id, and limits belong to the thread + * group, so a tid of this process names this process. A tid of another + * process does not resolve here, where Linux would reach that process: the + * registry and the table files are keyed by process, so it is ESRCH. + */ + int err; + if (pid == 0 || pid == proc_get_pid() || + (pid > 0 && thread_tid_alive(pid))) { + pthread_mutex_lock(&rlimit_lock); + rlimit_flock(rlimit_fd, set ? LOCK_EX : LOCK_SH); + err = rlimit_exchange(rlimit_table_load(), resource, set, &old_lim); + rlimit_flock(rlimit_fd, LOCK_UN); + pthread_mutex_unlock(&rlimit_lock); + if (!err && set && resource == LINUX_RLIMIT_CPU) + sys_rlimit_cpu_check(); + } else { + err = rlimit_exchange_remote(pid, resource, set, &old_lim); } + if (err) + return -err; + /* As in Linux, a new limit stays applied when the old one cannot be written + * back. + */ + if (old_gva != 0 && + guest_write_small(g, old_gva, &old_lim, sizeof(old_lim)) < 0) + return -LINUX_EFAULT; return 0; } @@ -803,29 +1316,34 @@ int64_t sys_prlimit64(guest_t *g, */ int sys_format_limits(char *buf, size_t bufsz) { - /* Linux resource names, units, and indices (matching kernel order). */ + /* Names, units and order of lnames in Linux fs/proc/base.c, indexed by + * resource number. + */ static const struct { const char *name, *units; - int linux_res; /* -1 = not tracked, emit unlimited */ - } rows[] = { - {"Max cpu time", "seconds", 0}, - {"Max file size", "bytes", 1}, - {"Max data size", "bytes", 2}, - {"Max stack size", "bytes", 3}, - {"Max core file size", "bytes", 4}, - {"Max resident set", "bytes", 5}, - {"Max processes", "processes", 6}, - {"Max open files", "files", 7}, - {"Max locked memory", "bytes", 8}, - {"Max address space", "bytes", 9}, - {"Max file locks", "locks", -1}, - {"Max pending signals", "signals", -1}, - {"Max msgqueue size", "bytes", -1}, - {"Max nice priority", "", -1}, - {"Max realtime priority", "", -1}, - {"Max realtime timeout", "us", -1}, + } rows[LINUX_RLIMIT_COUNT] = { + [LINUX_RLIMIT_CPU] = {"Max cpu time", "seconds"}, + [LINUX_RLIMIT_FSIZE] = {"Max file size", "bytes"}, + [LINUX_RLIMIT_DATA] = {"Max data size", "bytes"}, + [LINUX_RLIMIT_STACK] = {"Max stack size", "bytes"}, + [LINUX_RLIMIT_CORE] = {"Max core file size", "bytes"}, + [LINUX_RLIMIT_RSS] = {"Max resident set", "bytes"}, + [LINUX_RLIMIT_NPROC] = {"Max processes", "processes"}, + [LINUX_RLIMIT_NOFILE] = {"Max open files", "files"}, + [LINUX_RLIMIT_MEMLOCK] = {"Max locked memory", "bytes"}, + [LINUX_RLIMIT_AS] = {"Max address space", "bytes"}, + [LINUX_RLIMIT_LOCKS] = {"Max file locks", "locks"}, + [LINUX_RLIMIT_SIGPENDING] = {"Max pending signals", "signals"}, + [LINUX_RLIMIT_MSGQUEUE] = {"Max msgqueue size", "bytes"}, + [LINUX_RLIMIT_NICE] = {"Max nice priority", ""}, + [LINUX_RLIMIT_RTPRIO] = {"Max realtime priority", ""}, + [LINUX_RLIMIT_RTTIME] = {"Max realtime timeout", "us"}, }; + /* One snapshot, so every row is from the same instant of the table. */ + linux_rlimit64_t limits[LINUX_RLIMIT_COUNT]; + sys_rlimit_snapshot(limits); + int off = snprintf(buf, bufsz, "%-26s%-21s%-21s%-10s\n", "Limit", "Soft Limit", "Hard Limit", "Units"); if (off < 0) @@ -833,49 +1351,20 @@ int sys_format_limits(char *buf, size_t bufsz) if ((size_t) off >= bufsz) return (int) bufsz - 1; - for (int i = 0; i < (int) (ARRAY_SIZE(rows)); i++) { + for (int i = 0; i < LINUX_RLIMIT_COUNT; i++) { char soft[24], hard[24]; - int res = rows[i].linux_res; + linux_rlimit64_t lim = limits[i]; - if (res == 7 /* RLIMIT_NOFILE */) { - uint64_t cur, max; - sys_nofile_snapshot(&cur, &max); - linux_rlimit64_t lim = {.rlim_cur = cur, .rlim_max = max}; + if (lim.rlim_cur == LINUX_RLIM_INFINITY) + snprintf(soft, sizeof(soft), "unlimited"); + else snprintf(soft, sizeof(soft), "%llu", (unsigned long long) lim.rlim_cur); + if (lim.rlim_max == LINUX_RLIM_INFINITY) + snprintf(hard, sizeof(hard), "unlimited"); + else snprintf(hard, sizeof(hard), "%llu", (unsigned long long) lim.rlim_max); - } else if (RANGE_CHECK(res, 0, RLIMIT_CACHE_SIZE)) { - linux_rlimit64_t lim; - if (!rlimit_cache_get(res, &lim)) { - int mac_res = translate_rlimit_resource(res); - if (mac_res >= 0) { - struct rlimit rl; - if (getrlimit(mac_res, &rl) == 0) { - lim = translate_host_rlimit(res, rl); - } else { - lim.rlim_cur = UINT64_MAX; - lim.rlim_max = UINT64_MAX; - } - } else { - lim.rlim_cur = UINT64_MAX; - lim.rlim_max = UINT64_MAX; - } - } - if (lim.rlim_cur == UINT64_MAX) - snprintf(soft, sizeof(soft), "unlimited"); - else - snprintf(soft, sizeof(soft), "%llu", - (unsigned long long) lim.rlim_cur); - if (lim.rlim_max == UINT64_MAX) - snprintf(hard, sizeof(hard), "unlimited"); - else - snprintf(hard, sizeof(hard), "%llu", - (unsigned long long) lim.rlim_max); - } else { - snprintf(soft, sizeof(soft), "unlimited"); - snprintf(hard, sizeof(hard), "unlimited"); - } if ((size_t) off >= bufsz) break; diff --git a/src/syscall/sys.h b/src/syscall/sys.h index a3ac0afd..b68c1dca 100644 --- a/src/syscall/sys.h +++ b/src/syscall/sys.h @@ -12,8 +12,12 @@ #pragma once +#include #include +#include + #include "core/guest.h" +#include "syscall/linux-wire.h" /* Guest-visible kernel identity for uname(2) and /proc/version; * tests/test-proc.c pins the two surfaces to each other. The release names the @@ -77,12 +81,54 @@ int64_t sys_prlimit64(guest_t *g, uint64_t sys_guest_ram_bytes(void); uint64_t sys_guest_ram_free(void); -/* Snapshot/restore the guest-visible RLIMIT_NOFILE across the posix_spawn fork - * boundary. The host limit is internal capacity and is deliberately not exposed - * through these helpers. +/* Guest rlimit table, indexed by Linux resource number. It is guest state: + * seeded from the host once, then read and written by prlimit64 with no host + * setrlimit behind it. execve keeps the table because it keeps the process. + */ + +/* Seed the table from host getrlimit and publish it to the fork family. Called + * once by the process that boots the guest. + */ +void sys_rlimit_init(void); + +/* The fork boundary: the parent snapshots, the child checks the header copy + * before trusting it and restores it in place of sys_rlimit_init. + */ +void sys_rlimit_snapshot(linux_rlimit64_t out[LINUX_RLIMIT_COUNT]); +bool sys_rlimit_table_valid(const linux_rlimit64_t in[LINUX_RLIMIT_COUNT]); +void sys_rlimit_restore(const linux_rlimit64_t in[LINUX_RLIMIT_COUNT]); + +/* Remove a table file left under @host_pid by a process that died without + * unlinking it. The fork parent calls this for the pid posix_spawn returned, + * before any process can name the child. + */ +void sys_rlimit_forget(pid_t host_pid); + +/* Soft limit of @resource, LINUX_RLIM_INFINITY when unlimited. Lock-free, for + * the sites that enforce a limit. Another process of this user can write the + * slot, so a caller that uses the value as a bound clamps it. + */ +uint64_t sys_rlimit_cur(int resource); + +/* Hard limit of @resource, read the same way. */ +uint64_t sys_rlimit_max(int resource); + +/* Republish the guest ids after they changed, for the credential check + * prlimit64 makes from another process. + */ +void sys_rlimit_ids_changed(void); + +/* Send SIGXFSZ for a request RLIMIT_FSIZE refused. Linux sends it to the thread + * that made the request (send_sig to current), so it is queued for the calling + * thread alone and a sibling that has it unblocked does not take it. + */ +void sys_rlimit_send_xfsz(void); + +/* Have RLIMIT_CPU looked at again: start the thread that samples process CPU + * time against it, or wake it. Called when the limit changes, and from the + * preemption thread on SIGUSR2, which is how another process reports a change. */ -void sys_nofile_snapshot(uint64_t *cur, uint64_t *max); -int sys_nofile_restore(uint64_t cur, uint64_t max); +void sys_rlimit_cpu_check(void); /* Format /proc/self/limits content into buf. * diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 572d23a9..ced24fc7 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -212,9 +212,11 @@ typedef int64_t (*syscall_handler_t)(guest_t *g, guest_t *_g = (g_); \ shim_globals_attn_or(_g, ATTN_BIT_CRED); \ int64_t _rc = (mutator_); \ - if (_rc == 0) \ + if (_rc == 0) { \ shim_globals_publish_creds(_g, proc_get_uid(), proc_get_euid(),\ proc_get_gid(), proc_get_egid()); \ + sys_rlimit_ids_changed(); \ + } \ shim_globals_attn_and(_g, ~ATTN_BIT_CRED); \ _rc; \ }) @@ -2685,6 +2687,13 @@ int syscall_dispatch(hv_vcpu_t vcpu, guest_t *g, int *exit_code, bool verbose) host_fd_ref_close(&host_ref); goto slow_path; } + + /* sys_write is where RLIMIT_FSIZE is applied. */ + if (nr == SYS_write && + sys_rlimit_cur(LINUX_RLIMIT_FSIZE) != LINUX_RLIM_INFINITY) { + host_fd_ref_close(&host_ref); + goto slow_path; + } uint64_t count = x2; if (count == 0) { host_fd_ref_close(&host_ref); diff --git a/src/syscall/usbdev.c b/src/syscall/usbdev.c index 3e2e394c..3ca6fcb7 100644 --- a/src/syscall/usbdev.c +++ b/src/syscall/usbdev.c @@ -146,6 +146,7 @@ #include "debug/log.h" #include "runtime/usb-sysfs.h" #include "syscall/internal.h" +#include "syscall/mem.h" #include "syscall/io.h" #include "syscall/linux-wire.h" #include "syscall/proc.h" @@ -2724,6 +2725,11 @@ static usbdev_t *usbdev_acquire_snap(int fd, const fd_entry_t *snap) usbdev_unref(u); return NULL; } + + /* The entry lock is held until usbdev_release, across every guest copy the + * ioctl or read makes. + */ + mmap_lock_nowait_begin(); return u; } @@ -2745,6 +2751,7 @@ static usbdev_t *usbdev_acquire(int fd) */ static void usbdev_release(usbdev_t *u) { + mmap_lock_nowait_end(); pthread_mutex_unlock(&u->lock); usbdev_flush_disc_peers(u); usbdev_unref(u); diff --git a/tests/test-brk-stack.c b/tests/test-brk-stack.c index 860fbb42..89bb0dfe 100644 --- a/tests/test-brk-stack.c +++ b/tests/test-brk-stack.c @@ -15,11 +15,11 @@ * Four neighbor shapes are covered: a mapping in the gap above the heap, one * starting exactly on the break, a heap chunk the guest mremapped above it, and * the heap grown in place across it. The last two keep the "[heap]" name. Then - * the stack: g->stack_base is where a grow stops there, and it is the same - * address the "[stack-guard]" record starts at, so a last check unmaps that - * record to show the bound is the address and not the record. Linux refuses a - * break that would run into the next mapping, so every check below passes on a - * real kernel too. + * the stack, which starts small and grows on demand: the break has to stop + * short of the room a default 8 MiB stack may still grow into, though no + * mapping is there yet to stop it, and a last check grows the stack into that + * room. Linux refuses a break that would run into the next mapping and keeps + * the two far apart, so every check below passes on a real kernel too. * * Calls brk(2) directly rather than through sbrk(3): musl's sbrk answers ENOMEM * for any non-zero increment without issuing the syscall, which would make this @@ -72,11 +72,11 @@ static void give_up(const char *name, const char *why, int restored) } } -/* A check that could not build its shape. Only one of these is ever expected, - * and only on a real kernel: there is no "[stack-guard]" line to unmap there. - * Every other way a shape fails to build means something the check relies on - * stopped working, and the runner matches on "0 failed", so a skip would retire - * the check without saying so. Fail instead. +/* A check that could not build its shape. Only one skip is ever expected, and + * only on a real kernel: the full-table check has no region table to fill + * there. Every other way a shape fails to build means something the check + * relies on stopped working, and the runner matches on "0 failed", so a skip + * would retire the check without saying so. Fail instead. */ static void cannot_build(const char *name, const char *why, int restored) { @@ -117,33 +117,43 @@ static size_t grow_break(unsigned long *final_brk) return cur - start; } -/* Where the stack region begins, or 0 if /proc/self/maps names no stack. - * - * elfuse publishes a "[stack-guard]" page below "[stack]" and that guard page - * is the boundary the break must respect, so prefer it where it exists. Linux - * has only "[stack]", one page further up, which is why the fallback is not an - * error. Plain strstr would not tell them apart: "[stack]" is a prefix of - * "[stack-guard]". +/* Where the stack region begins, or 0 if /proc/self/maps names no stack. @end, + * when set, receives where it ends. */ -static unsigned long stack_map_start(void) +static unsigned long stack_map_start(unsigned long *end) { FILE *f = fopen("/proc/self/maps", "r"); if (!f) return 0; char line[512]; - unsigned long stack = 0, guard = 0; + unsigned long stack = 0; while (fgets(line, sizeof(line), f)) { - if (strstr(line, "[stack-guard]")) { - guard = strtoul(line, NULL, 16); - continue; - } const char *tag = strstr(line, "[stack]"); - if (tag && (tag[7] == '\n' || tag[7] == '\0')) - stack = strtoul(line, NULL, 16); + char *dash = NULL; + if (!tag || (tag[7] != '\n' && tag[7] != '\0')) + continue; + stack = strtoul(line, &dash, 16); + if (end) + *end = (*dash == '-') ? strtoul(dash + 1, NULL, 16) : 0; } fclose(f); - return guard ? guard : stack; + return stack; +} + +/* Whether this is elfuse: only it lists its EL1 shim in /proc/self/maps. */ +static int maps_name_the_shim(void) +{ + FILE *f = fopen("/proc/self/maps", "r"); + if (!f) + return 0; + + char line[512]; + int found = 0; + while (!found && fgets(line, sizeof(line), f)) + found = strstr(line, "[shim]") != NULL; + fclose(f); + return found; } /* A mapping above the heap is a neighbor the break has to stop at, the same way @@ -185,54 +195,36 @@ static void test_neighbor(const char *name, int adjacent) EXPECT_EQ(munmap(p, len), 0, "neighbor left mapped above the break"); } -/* Extent of the "[stack-guard]" line. - * - * Returns 0 where there is none, which is every real kernel, and doubles as the - * marker that tells elfuse from Linux. The length comes from the line rather - * than a constant, so a wider guard is unmapped whole instead of split, which - * would leave the check passing while testing nothing. +/* The stack is a small mapping near the top of an 8 MiB window it may grow + * into, so the mapping itself is not what has to stop the break: a break that + * ran up to it would leave a default stack nowhere to grow. Checked twice, by + * where the break ended and by growing the stack afterwards. */ -static unsigned long guard_map_start(unsigned long *len) +static void test_stack_room(unsigned long final_brk) { - FILE *f = fopen("/proc/self/maps", "r"); - if (!f) - return 0; - - char line[512]; - unsigned long start = 0; - while (fgets(line, sizeof(line), f)) { - if (!strstr(line, "[stack-guard]")) - continue; - char *dash = NULL; - start = strtoul(line, &dash, 16); - if (len) - *len = (*dash == '-') ? strtoul(dash + 1, NULL, 16) - start : 0; - break; - } - fclose(f); - return start; -} + const unsigned long default_stack = 8ul << 20; + unsigned long end = 0, start = stack_map_start(&end); -/* g->stack_base and the "[stack-guard]" record name the same address, so every - * check above passes whichever of the two is doing the work. Unmapping the - * record settles it: the grow must still be refused with nothing in the table - * to refuse it. - */ -static void test_stack_floor(void) -{ - const char *name = "floor holds without the record"; - unsigned long len = 0, guard = guard_map_start(&len); + TEST("break leaves the stack its room"); + EXPECT_TRUE( + start != 0 && end > default_stack && final_brk <= end - default_stack, + "the break ended inside the default stack window"); - if (!guard || !len) { - give_up(name, "no [stack-guard] line", 1); - return; + /* 6 MiB down, in a child so a refusal is a status and not this test's own + * death. Linux keeps a growing stack 1 MiB clear of the mapping below it, + * which here is the heap the break just grew. + */ + TEST("stack grows after the break stopped"); + int status = -1; + pid_t pid = fork(); + if (pid == 0) { + *(volatile unsigned char *) (end - (6ul << 20)) = CANARY_BYTE; + _exit(0); } - TEST("stack guard unmaps"); - EXPECT_EQ(munmap((void *) guard, len), 0, "guard would not unmap"); - - TEST(name); - EXPECT_TRUE(guest_brk(guard + len - 1) <= guard, - "brk grew into the stack guard once its record was gone"); + if (pid > 0) + waitpid(pid, &status, 0); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "the stack could not grow into its own window"); } /* The heap grown in place past the break, which leaves a record straddling it @@ -693,10 +685,9 @@ static void test_stale_tracker(char **argv) /* elfuse-internal: a real kernel has no region table to fill, so the child * below would split its mapping to no end and report that the break still - * grows, which on Linux is simply correct. The "[stack-guard]" line is the - * same marker the floor check uses to tell the two apart. + * grows, which on Linux is simply correct. */ - if (!guard_map_start(NULL)) { + if (!maps_name_the_shim()) { give_up(name, "no region table to fill", 1); return; } @@ -809,7 +800,7 @@ int main(int argc, char **argv) EXPECT_EQ(bad, 0, "stack clobbered by brk"); TEST("break stops below the stack mapping"); - unsigned long stack_start = stack_map_start(); + unsigned long stack_start = stack_map_start(NULL); EXPECT_TRUE(stack_start != 0 && final_brk < stack_start, "no stack line in /proc/self/maps, or the break ran into it"); @@ -834,8 +825,7 @@ int main(int argc, char **argv) EXPECT_TRUE(ok, "large allocation did not hold its contents"); } - /* Last, because it unmaps the guard page for good. */ - test_stack_floor(); + test_stack_room(final_brk); printf("\n brk moved %zu MiB, ended at 0x%lx, %d skipped\n", grown >> 20, final_brk, skips); diff --git a/tests/test-exec-handoff.c b/tests/test-exec-handoff.c index 4065111b..cf880692 100644 --- a/tests/test-exec-handoff.c +++ b/tests/test-exec-handoff.c @@ -37,6 +37,10 @@ * a caller that connected once. Measured before the fix: about 60% of a * connect loop under exec pressure reported EISCONN, against 4000/4000 * clean on a real kernel. + * 7. A copy waiting to be refused is still waiting afterwards. A splice into a + * file already at RLIMIT_FSIZE waits for a byte before it can refuse it, and + * has moved nothing when the handoff breaks that wait, so the restart must + * put it back rather than report an EINTR no signal explains. * * Every case execs a path that does not exist, so the handoff fails before its * point of no return and the leader stays in the original image to be checked. @@ -44,7 +48,8 @@ * Syscalls exercised: execve(221), clone(220), read(63), write(64), pipe2(59), * wait4(260), rt_sigaction(134), tgkill(131), * nanosleep(101), futex(98), rt_sigtimedwait(137), - * socket(198), connect(203), accept4(242) + * socket(198), connect(203), accept4(242), splice(76), + * prlimit64(261), ftruncate(46), lseek(62), ioctl(29) */ #include @@ -59,7 +64,10 @@ #include #include #include +#include #include +#include +#include #include #include #include @@ -576,6 +584,74 @@ static void expect_connect_survives_handoff(void) EXPECT_EQ(bad, 0, "connect reported the host's retry state to the guest"); } +/* The leader parks in a splice from the empty pipe into a file that is already + * at the limit, with no EINTR retry. The worker's byte is what ends the wait, + * and Linux refuses it without taking it: EFBIG, and the byte still queued. + * SIGXFSZ is ignored so the refusal is all there is to read, and ignored before + * the file is sized so a smaller inherited limit fails the setup instead. + */ +static void expect_fsize_wait_survives_handoff(void) +{ + enum { LIMIT = 4096 }; + char path[] = "/tmp/elfuse-exec-handoff.XXXXXX"; + struct rlimit old, cut; + pthread_t t; + int fd = mkstemp(path); + + signal(SIGXFSZ, SIG_IGN); + if (fd < 0 || getrlimit(RLIMIT_FSIZE, &old) < 0 || + ftruncate(fd, LIMIT) < 0 || lseek(fd, LIMIT, SEEK_SET) != LIMIT) { + FAIL("file at the size limit could not be set up"); + signal(SIGXFSZ, SIG_DFL); + if (fd >= 0) { + close(fd); + unlink(path); + } + return; + } + + cut = old; + cut.rlim_cur = LIMIT; + atomic_store(&worker_fails, 0); + atomic_store(&leader_parked, 0); + + ssize_t n = 0; + int err = 0, queued = 0; + char c = 0; + /* pthread_create returns its error rather than setting errno. */ + int setup = setrlimit(RLIMIT_FSIZE, &cut) < 0 + ? errno + : pthread_create(&t, NULL, doomed_exec_worker, NULL); + if (setup == 0) { + atomic_store(&leader_parked, 1); + n = splice(pipe_rd, NULL, fd, NULL, 1, 0); + err = errno; + pthread_join(t, NULL); + + /* Counted before it is read: a splice that took the byte would leave + * this read with nothing to return. + */ + if (ioctl(pipe_rd, FIONREAD, &queued) == 0 && queued == 1) + read_byte_retrying(pipe_rd, &c); + } + + setrlimit(RLIMIT_FSIZE, &old); + signal(SIGXFSZ, SIG_DFL); + close(fd); + unlink(path); + + errno = setup ? setup : err; + if (setup != 0) + FAIL("limit or worker could not be set up"); + else if (n < 0 && err == EINTR) + FAIL("leader splice returned EINTR across a sibling execve"); + else if (n != -1 || err != EFBIG) + FAIL("leader splice was not refused with EFBIG"); + else + EXPECT_TRUE(c == 'x' && atomic_load(&worker_fails) == 0, + "refused splice did not leave its byte in the pipe"); +} + int main(void) { int fds[2]; @@ -613,6 +689,9 @@ int main(void) TEST("connect under sustained handoff pressure"); expect_connect_survives_handoff(); + TEST("splice at the file size limit across a handoff"); + expect_fsize_wait_survives_handoff(); + SUMMARY("test-exec-handoff"); return fails > 0 ? 1 : 0; } diff --git a/tests/test-exec-limits.c b/tests/test-exec-limits.c index 8f6a0d5f..b3f4e2dd 100644 --- a/tests/test-exec-limits.c +++ b/tests/test-exec-limits.c @@ -11,6 +11,7 @@ #include #include #include +#include int main(int argc, char **argv) { @@ -21,6 +22,22 @@ int main(int argc, char **argv) extern char **environ; + /* The 2 MB budget below is a quarter of the stack limit (Linux + * bprm_stack_limits), so pin the limit instead of inheriting one: under a + * raised limit the "too large" exec succeeds and this binary runs again. + * The make that ships with macOS hands its children such a limit. + */ + struct rlimit stack_limit; + if (getrlimit(RLIMIT_STACK, &stack_limit) < 0) { + perror("getrlimit"); + return 1; + } + stack_limit.rlim_cur = 8 * 1024 * 1024; + if (setrlimit(RLIMIT_STACK, &stack_limit) < 0) { + perror("setrlimit"); + return 1; + } + // 1. Test single string too long (> 128 KB) -> expect E2BIG { size_t size = 130 * 1024; diff --git a/tests/test-fork-ipc-protocol-host.c b/tests/test-fork-ipc-protocol-host.c index 30d64bde..75f407fd 100644 --- a/tests/test-fork-ipc-protocol-host.c +++ b/tests/test-fork-ipc-protocol-host.c @@ -22,9 +22,10 @@ #define PREVIOUS_ELFN_MAGIC 0x454C464EU #define PREVIOUS_ELFO_MAGIC 0x454C464FU #define PREVIOUS_ELFP_MAGIC 0x454C4650U +#define PREVIOUS_ELFQ_MAGIC 0x454C4651U -_Static_assert(FORK_IPC_PROTOCOL_MAGIC == 0x454C4651U, - "fork IPC protocol magic must remain ELFQ until the next " +_Static_assert(FORK_IPC_PROTOCOL_MAGIC == 0x454C4652U, + "fork IPC protocol magic must remain ELFR until the next " "incompatible wire-format change"); _Static_assert(IPC_MAGIC_HEADER == FORK_IPC_PROTOCOL_MAGIC, "header magic must be the protocol identity"); @@ -40,6 +41,8 @@ _Static_assert(FORK_IPC_PROTOCOL_MAGIC != PREVIOUS_ELFO_MAGIC, "per-fd description ownership requires rejecting ELFO peers"); _Static_assert(FORK_IPC_PROTOCOL_MAGIC != PREVIOUS_ELFP_MAGIC, "the per-fd intercept marker requires rejecting ELFP peers"); +_Static_assert(FORK_IPC_PROTOCOL_MAGIC != PREVIOUS_ELFQ_MAGIC, + "the rlimit table in the header requires rejecting ELFQ peers"); _Static_assert(IPC_MAGIC_SENTINEL != FORK_IPC_PROTOCOL_MAGIC, "process-state sentinel must not alias the header protocol"); diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 0aa6894f..f50ae677 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -299,6 +299,7 @@ QEMU_SKIP=" test-mremap-tail-emfile test-credentials test-credentials-fakeroot + test-proc-limits-fakeroot test-fakeroot-exec test-setuid-exec test-setuid-exec-fakeroot @@ -356,6 +357,9 @@ QEMU_SKIP=" # descriptor reserve with file-backed fillers, and verifies that the filler # failure is descriptor-driven before asserting the mremap ENOMEM path; real # Linux has neither emulated table nor reserve. +# test-proc-limits-fakeroot: reruns test-proc-limits under --fakeroot so the +# prlimit64 credential checks, which only root can set up, run in elfuse +# too; the reference lane already runs the plain test as genuine root. # test-credentials: pins elfuse's restricted "fakeroot" setuid/capset/ # getgroups model (deliberately narrower than real root); the qemu # reference lane runs as genuine root, which has unrestricted privilege. @@ -719,6 +723,8 @@ run_unit_tests() "$bindir/test-process-lifecycle" test_check "$runner" "test-cloexec" "PASS" "$bindir/test-cloexec" test_rc "$runner" "test-exec-limits" 0 "$bindir/test-exec-limits" + test_rc "$runner" "test-rlimit-enforce" 0 "$bindir/test-rlimit-enforce" + test_rc "$runner" "test-rlimit-stack" 0 "$bindir/test-rlimit-stack" test_rc "$runner" "test-session" 0 "$bindir/test-session" test_rc "$runner" "test-pidfd" 0 "$bindir/test-pidfd" @@ -812,6 +818,7 @@ run_unit_tests() test_rc "$runner" "test-procfs" 0 "$bindir/test-procfs" test_rc "$runner" "test-procfs-exec" 0 "$bindir/test-procfs-exec" test_rc "$runner" "test-proc-limits" 0 "$bindir/test-proc-limits" + test_rc "$runner" "test-proc-limits-fakeroot" 0 --fakeroot "$bindir/test-proc-limits" test_rc "$runner" "test-proc-fidelity" 0 "$bindir/test-proc-fidelity" test_rc "$runner" "test-proc-smap" 0 "$bindir/test-proc-smap" diff --git a/tests/test-proc-limits.c b/tests/test-proc-limits.c index c80a8bd6..c90cb771 100644 --- a/tests/test-proc-limits.c +++ b/tests/test-proc-limits.c @@ -1,17 +1,23 @@ /* - * Test /proc/self/limits synthetic file + * Test guest rlimit state and the /proc/self/limits synthetic file * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 * * Verifies that /proc/self/limits is readable and contains expected rows - * matching prlimit64 values. + * matching prlimit64 values, and that a limit set through setrlimit or + * prlimit64 reads back, crosses fork and execve, and can be reached by pid. */ +#include #include +#include #include #include #include +#include +#include +#include #include "test-harness.h" #include "raw-syscall.h" @@ -27,11 +33,349 @@ int passes = 0, fails = 0; -int main(void) +/* One soft value per limit that no other limit uses, so a slot that aliases + * another reads back wrong. Each is far above what this test consumes. NICE and + * RTPRIO start with a hard limit of 0, which only root can raise, so they get + * their own values in the runs that are root and stay 0 in the one that is not. + */ +static const struct { + int resource; + const char *name; + rlim_t cur; +} tracked[] = { + {RLIMIT_CPU, "RLIMIT_CPU round trip", 100000}, + {RLIMIT_FSIZE, "RLIMIT_FSIZE round trip", (rlim_t) 1 << 40}, + {RLIMIT_DATA, "RLIMIT_DATA round trip", (rlim_t) 1 << 41}, + {RLIMIT_STACK, "RLIMIT_STACK round trip", (rlim_t) 16 << 20}, + {RLIMIT_RSS, "RLIMIT_RSS round trip", (rlim_t) 1 << 42}, + {RLIMIT_MEMLOCK, "RLIMIT_MEMLOCK round trip", (rlim_t) 1 << 36}, + {RLIMIT_AS, "RLIMIT_AS round trip", (rlim_t) 1 << 43}, + {RLIMIT_LOCKS, "RLIMIT_LOCKS round trip", 1234567}, + {RLIMIT_SIGPENDING, "RLIMIT_SIGPENDING round trip", 97}, + {RLIMIT_MSGQUEUE, "RLIMIT_MSGQUEUE round trip", 4096}, + {RLIMIT_NICE, "RLIMIT_NICE round trip", 13}, + {RLIMIT_RTPRIO, "RLIMIT_RTPRIO round trip", 14}, + {RLIMIT_RTTIME, "RLIMIT_RTTIME round trip", 7654321}, +}; +#define N_TRACKED ((int) (sizeof(tracked) / sizeof(tracked[0]))) + +/* The soft value tracked[i] is set to: its own, or the hard limit if lower. The + * hard limits are settled before the first child is made, so every process in + * this test agrees. + */ +static rlim_t tracked_cur(int i) +{ + struct rlimit rl; + if (getrlimit(tracked[i].resource, &rl) < 0) + return 0; + return tracked[i].cur > rl.rlim_max ? rl.rlim_max : tracked[i].cur; +} + +/* Number of tracked limits whose soft value is not the one this test set. */ +static int tracked_mismatches(void) +{ + int bad = 0; + for (int i = 0; i < N_TRACKED; i++) { + struct rlimit rl; + if (getrlimit(tracked[i].resource, &rl) < 0 || + rl.rlim_cur != tracked_cur(i)) + bad++; + } + return bad; +} + +/* Run @child_main in a fork child and return its exit status, or -1. */ +static int run_child(int (*child_main)(void *), void *arg) +{ + pid_t pid = fork(); + if (pid < 0) + return -1; + if (pid == 0) + _exit(child_main(arg)); + int status = 0; + if (waitpid(pid, &status, 0) < 0 || !WIFEXITED(status)) + return -1; + return WEXITSTATUS(status); +} + +static int child_reads_limits(void *arg) +{ + (void) arg; + return tracked_mismatches(); +} + +static int child_execs_self(void *arg) +{ + char *argv[] = {arg, "after-exec", NULL}; + execv(argv[0], argv); + return 127; +} + +static void test_rlimit_state(char *self) +{ + /* Give NICE and RTPRIO room for a value of their own where that is allowed. + */ + if (getuid() == 0) { + struct rlimit room = {.rlim_cur = 0, .rlim_max = 40}; + setrlimit(RLIMIT_NICE, &room); + setrlimit(RLIMIT_RTPRIO, &room); + } + + for (int i = 0; i < N_TRACKED; i++) { + struct rlimit rl, got = {0}; + TEST(tracked[i].name); + rlim_t want = tracked_cur(i); + if (getrlimit(tracked[i].resource, &rl) < 0) { + FAIL("getrlimit failed"); + continue; + } + rl.rlim_cur = want; + if (setrlimit(tracked[i].resource, &rl) < 0 || + getrlimit(tracked[i].resource, &got) < 0) + FAIL("setrlimit then getrlimit failed"); + else + EXPECT_TRUE(got.rlim_cur == want && got.rlim_max == rl.rlim_max, + "read-back differs from the value set"); + } + + /* macOS defines RLIMIT_RSS as RLIMIT_AS; the guest's two must not merge. */ + TEST("RSS and AS stay independent"); + { + struct rlimit rss, as; + if (getrlimit(RLIMIT_RSS, &rss) < 0 || getrlimit(RLIMIT_AS, &as) < 0) + FAIL("getrlimit failed"); + else + EXPECT_TRUE(rss.rlim_cur == (rlim_t) 1 << 42 && + as.rlim_cur == (rlim_t) 1 << 43, + "RSS and AS read back the same slot"); + } + + TEST("fork child reads parent limits"); + EXPECT_EQ(run_child(child_reads_limits, NULL), 0, + "child saw different limits"); + + TEST("execve keeps limits"); + EXPECT_EQ(run_child(child_execs_self, self), 0, + "image after execve saw different limits"); + + TEST("prlimit64 on own pid"); + { + struct rlimit rl = {0}; + if (prlimit(getpid(), RLIMIT_AS, NULL, &rl) < 0) + FAIL("prlimit64 failed"); + else + EXPECT_TRUE(rl.rlim_cur == (rlim_t) 1 << 43, "wrong soft limit"); + } + + TEST("prlimit64 on a dead pid is ESRCH"); + { + struct rlimit rl; + EXPECT_ERRNO(prlimit(999999, RLIMIT_AS, NULL, &rl), ESRCH, + "expected ESRCH"); + } + + TEST("soft above hard is EINVAL"); + { + struct rlimit rl = {.rlim_cur = 2, .rlim_max = 1}; + EXPECT_ERRNO(setrlimit(RLIMIT_CPU, &rl), EINVAL, "expected EINVAL"); + } + + /* The child blocks on the pipe until the parent has changed its limit. */ + TEST("prlimit64 sets a child's limit"); + { + int gate[2]; + struct rlimit own = {0}, old = {0}, after = {0}; + if (pipe(gate) < 0 || getrlimit(RLIMIT_CPU, &own) < 0) { + FAIL("setup failed"); + } else { + struct rlimit new = {.rlim_cur = own.rlim_cur - 1, + .rlim_max = own.rlim_max}; + pid_t pid = fork(); + if (pid == 0) { + char c; + struct rlimit rl = {0}; + close(gate[1]); + if (read(gate[0], &c, 1) != 1 || getrlimit(RLIMIT_CPU, &rl) < 0) + _exit(2); + _exit(rl.rlim_cur == new.rlim_cur ? 0 : 1); + } + close(gate[0]); + int rc = pid < 0 ? -1 : prlimit(pid, RLIMIT_CPU, &new, &old); + int status = -1; + (void) !write(gate[1], "x", 1); + close(gate[1]); + if (pid > 0) + waitpid(pid, &status, 0); + getrlimit(RLIMIT_CPU, &after); + EXPECT_TRUE(rc == 0 && old.rlim_cur == own.rlim_cur && + WIFEXITED(status) && WEXITSTATUS(status) == 0 && + after.rlim_cur == own.rlim_cur, + "child limit not set, or parent limit moved"); + } + } + + /* A child that has exited cannot be waited on to publish anything, so the + * call returns at once. What it returns is not compared: Linux still has + * the limits of a zombie and elfuse does not. + */ + TEST("prlimit64 on an exited child is prompt"); + { + struct rlimit rl; + struct timespec t0, t1; + siginfo_t info; + pid_t pid = fork(); + if (pid == 0) + _exit(0); + + /* Until it has exited, and without reaping it. */ + if (pid > 0) + waitid(P_PID, (id_t) pid, &info, WEXITED | WNOWAIT); + clock_gettime(CLOCK_MONOTONIC, &t0); + (void) prlimit(pid, RLIMIT_CPU, NULL, &rl); + clock_gettime(CLOCK_MONOTONIC, &t1); + long ms = (t1.tv_sec - t0.tv_sec) * 1000 + + (t1.tv_nsec - t0.tv_nsec) / 1000000; + if (pid > 0) + waitpid(pid, NULL, 0); + EXPECT_TRUE(pid > 0 && ms < 500, "waited on a child that had exited"); + } + + /* check_prlimit_permission needs two sets of ids, and only root can make a + * second one. The drop takes CAP_SYS_RESOURCE with it. + */ + if (getuid() != 0) + return; + + TEST("prlimit64 on other ids is EPERM"); + { + pid_t pid = fork(); + if (pid == 0) { + struct rlimit rl; + if (setresgid(1001, 1001, 1001) < 0 || + setresuid(1001, 1001, 1001) < 0) + _exit(2); + if (prlimit(getpid(), RLIMIT_CPU, NULL, &rl) < 0) + _exit(3); + _exit(prlimit(getppid(), RLIMIT_CPU, NULL, &rl) == -1 && + errno == EPERM + ? 0 + : 1); + } + int status = -1; + if (pid > 0) + waitpid(pid, &status, 0); + EXPECT_TRUE(WIFEXITED(status) && WEXITSTATUS(status) == 0, + "a pid with other ids was reached"); + } + + /* The child reports once it has dropped its ids, then waits. */ + TEST("root reaches a pid with other ids"); + { + int ready[2], gate[2]; + struct rlimit rl; + char c; + pid_t pid = pipe(ready) < 0 || pipe(gate) < 0 ? -1 : fork(); + if (pid == 0) { + close(gate[1]); + if (setresgid(1001, 1001, 1001) < 0 || + setresuid(1001, 1001, 1001) < 0) + _exit(2); + (void) !write(ready[1], "r", 1); + (void) !read(gate[0], &c, 1); + _exit(0); + } + int rc = -1; + if (pid > 0) { + close(gate[0]); + if (read(ready[0], &c, 1) == 1) + rc = prlimit(pid, RLIMIT_CPU, NULL, &rl); + close(gate[1]); + waitpid(pid, NULL, 0); + } + EXPECT_EQ(rc, 0, "CAP_SYS_RESOURCE did not reach the child"); + } +} + +/* Read the soft and hard columns of the /proc/self/limits row named @name. + * "unlimited" reads as RLIM_INFINITY. + * + * Returns 0, or -1 if the row is missing. + */ +static int limits_row(const char *name, struct rlimit *out) +{ + char buf[4096], soft[32], hard[32]; + int fd = open("/proc/self/limits", O_RDONLY); + ssize_t n = fd < 0 ? -1 : read(fd, buf, sizeof(buf) - 1); + if (fd >= 0) + close(fd); + if (n <= 0) + return -1; + buf[n] = '\0'; + + const char *row = strstr(buf, name); + if (!row || sscanf(row + strlen(name), "%31s %31s", soft, hard) != 2) + return -1; + out->rlim_cur = + strcmp(soft, "unlimited") ? strtoull(soft, NULL, 10) : RLIM_INFINITY; + out->rlim_max = + strcmp(hard, "unlimited") ? strtoull(hard, NULL, 10) : RLIM_INFINITY; + return 0; +} + +/* test_rlimit_state left these limits at different soft values; each row must + * show its own, the one getrlimit returns in this same run. + */ +static void test_limits_rows(void) +{ + static const struct { + int resource; + const char *row, *name; + } checked[] = { + {RLIMIT_AS, "Max address space", "address space row is RLIMIT_AS"}, + {RLIMIT_RSS, "Max resident set", "resident set row is RLIMIT_RSS"}, + {RLIMIT_FSIZE, "Max file size", "file size row is RLIMIT_FSIZE"}, + {RLIMIT_CPU, "Max cpu time", "cpu time row is RLIMIT_CPU"}, + {RLIMIT_LOCKS, "Max file locks", "file locks row is RLIMIT_LOCKS"}, + {RLIMIT_RTTIME, "Max realtime timeout", + "realtime timeout row is RLIMIT_RTTIME"}, + }; + enum { N_CHECKED = sizeof(checked) / sizeof(checked[0]) }; + rlim_t soft[N_CHECKED] = {0}; + + for (int i = 0; i < N_CHECKED; i++) { + struct rlimit want = {0}, row = {0}; + TEST(checked[i].name); + if (getrlimit(checked[i].resource, &want) < 0 || + limits_row(checked[i].row, &row) < 0) { + FAIL("getrlimit or the row is missing"); + continue; + } + soft[i] = row.rlim_cur; + EXPECT_TRUE( + row.rlim_cur == want.rlim_cur && row.rlim_max == want.rlim_max, + "row differs from getrlimit"); + } + + TEST("the rows are distinct"); + { + int distinct = 1; + for (int i = 0; i < N_CHECKED; i++) + for (int j = i + 1; j < N_CHECKED; j++) + if (soft[i] == soft[j]) + distinct = 0; + EXPECT_TRUE(distinct, "two rows print the same soft limit"); + } +} + +int main(int argc, char **argv) { char buf[4096]; - printf("test-proc-limits: /proc/self/limits synthetic file\n"); + /* The image test_rlimit_state execs into: report what survived. */ + if (argc > 1 && !strcmp(argv[1], "after-exec")) + return tracked_mismatches(); + + printf("test-proc-limits: guest rlimits and /proc/self/limits\n"); /* Open /proc/self/limits */ TEST("open /proc/self/limits"); @@ -121,6 +465,9 @@ int main(void) } done: + test_rlimit_state(argv[0]); + test_limits_rows(); + SUMMARY("test-proc-limits"); return fails ? 1 : 0; } diff --git a/tests/test-procfs.c b/tests/test-procfs.c index 2ef42d48..8663b281 100644 --- a/tests/test-procfs.c +++ b/tests/test-procfs.c @@ -9,7 +9,8 @@ * 1. /proc/self/auxv returns valid auxv with AT_PAGESZ=4096 * 2. /proc/self/environ contains at least one entry * 3. /proc/self/cmdline is non-empty - * 4. /proc/self/maps contains [heap] and [stack] + * 4. /proc/self/maps contains [heap] and [stack], every offset is + * page-aligned, the lines add up to VmSize, and [vdso] is listed * 5. /proc/self/status contains correct PID * * Syscalls: openat(56), read(63), close(57), getpid(172) @@ -18,6 +19,7 @@ #include #include #include +#include #include #include @@ -67,6 +69,33 @@ static bool parse_proc_stat_field_u64(const char *buf, return false; } +/* Walk the maps text in @buf: add each line's size to @total, leaving out the + * elfuse runtime records ([shim], [shim-data]) a Linux process does not have, + * and report whether every file offset is page-aligned. + */ +static bool maps_walk(char *buf, uint64_t *total) +{ + bool aligned = true; + *total = 0; + for (char *line = buf; *line;) { + char *nl = strchr(line, '\n'); + if (nl) + *nl = '\0'; + char *p; + uint64_t lo = strtoull(line, &p, 16); + uint64_t hi = strtoull(p + 1, &p, 16); + uint64_t off = strtoull(p + 6, NULL, 16); /* past " rwxp " */ + if (!strstr(line, "[shim")) + *total += hi - lo; + aligned &= (off & 0xfff) == 0; + if (!nl) + break; + *nl = '\n'; + line = nl + 1; + } + return aligned; +} + static bool parse_stack_range(const char *buf, uint64_t *start, uint64_t *end) { const char *line = buf; @@ -194,6 +223,28 @@ int main(void) } } + TEST("procfs: maps offsets page-aligned, lines add up to VmSize"); + { + uint64_t maps_total = 0; + n = raw_read_file_nul("/proc/self/maps", buf, sizeof(buf)); + bool aligned = n > 0 && maps_walk(buf, &maps_total); + n = raw_read_file_nul("/proc/self/status", buf, sizeof(buf)); + char *vm = n > 0 ? strstr(buf, "VmSize:") : NULL; + uint64_t vm_kb = vm ? strtoull(vm + 7, NULL, 10) : 0; + if (aligned && vm_kb * 1024 == maps_total) { + PASS(); + } else { + printf("FAIL: aligned %d, VmSize %llu kB, maps %llu kB\n", aligned, + (unsigned long long) vm_kb, + (unsigned long long) (maps_total / 1024)); + fails++; + } + } + + TEST("procfs: /proc/self/maps lists [vdso]"); + n = raw_read_file_nul("/proc/self/maps", buf, sizeof(buf)); + EXPECT_TRUE(n > 0 && strstr(buf, "[vdso]"), "no [vdso] line"); + TEST("procfs: /proc/self/status has correct PID"); { long pid = raw_getpid(); diff --git a/tests/test-rlimit-enforce.c b/tests/test-rlimit-enforce.c new file mode 100644 index 00000000..d9a713e8 --- /dev/null +++ b/tests/test-rlimit-enforce.c @@ -0,0 +1,661 @@ +/* + * Test that guest rlimits are enforced + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * RLIMIT_FSIZE at the write, truncate and file-copy boundaries, RLIMIT_CPU + * against a loop that makes no syscall, and RLIMIT_AS and RLIMIT_DATA where a + * mapping or the break would grow past them. Each limit is lowered in a fork + * child, so the process that reports the results keeps its own limits. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define FSIZE_LIMIT 4096 + +static volatile sig_atomic_t xfsz_count; + +static void on_xfsz(int sig) +{ + (void) sig; + xfsz_count++; +} + +/* True if @rc is -1 with EFBIG and exactly one more SIGXFSZ has arrived. */ +static int refused_with_xfsz(long rc, int *seen) +{ + int ok = rc == -1 && errno == EFBIG && xfsz_count == *seen + 1; + *seen = xfsz_count; + return ok; +} + +/* Runs in a child under RLIMIT_FSIZE = FSIZE_LIMIT. Nothing is printed while + * the limit is in force, because stdout may be a regular file too. The exit + * status is the number of the first step that went wrong, 0 if none did. + */ +static int fsize_child(void) +{ + char path[] = "/tmp/elfuse-rlimit-enforce.XXXXXX"; + char block[FSIZE_LIMIT]; + int fd = mkstemp(path); + struct rlimit rl; + struct sigaction sa = {.sa_handler = on_xfsz}; + int seen = 0, step = 0; + + memset(block, 'x', sizeof(block)); + if (fd < 0 || sigaction(SIGXFSZ, &sa, NULL) < 0 || + getrlimit(RLIMIT_FSIZE, &rl) < 0) + return 100; + rl.rlim_cur = FSIZE_LIMIT; + if (setrlimit(RLIMIT_FSIZE, &rl) < 0) + return 101; + + /* 1: a write that ends at the limit is whole. */ + step++; + if (write(fd, block, 4000) != 4000) + goto out; + + /* 2: a write that crosses the limit is cut at it, with no signal. */ + step++; + if (write(fd, block, 200) != 96 || xfsz_count != 0) + goto out; + + /* 3: a write that starts at the limit. */ + step++; + if (!refused_with_xfsz(write(fd, block, 1), &seen)) + goto out; + + /* 4: pwrite past the limit. */ + step++; + if (!refused_with_xfsz(pwrite(fd, block, 1, 2 * FSIZE_LIMIT), &seen)) + goto out; + + /* 5: a two-entry writev that crosses the limit is cut at it. */ + step++; + { + struct iovec iov[2] = {{block, 50}, {block, 100}}; + if (lseek(fd, FSIZE_LIMIT - 60, SEEK_SET) < 0 || + writev(fd, iov, 2) != 60 || xfsz_count != seen) + goto out; + } + + /* 6: an O_APPEND descriptor starts at end of file, which is the limit. */ + step++; + { + int afd = open(path, O_WRONLY | O_APPEND); + long rc = afd < 0 ? 0 : write(afd, block, 1); + int ok = refused_with_xfsz(rc, &seen); + + /* pwrite on it appends too, so an offset under the limit is no help. */ + rc = afd < 0 ? 0 : pwrite(afd, block, 1, 0); + ok = ok && refused_with_xfsz(rc, &seen); + if (afd >= 0) + close(afd); + if (!ok) + goto out; + } + + /* 7 and 8: growing past the limit, by descriptor and by path. */ + step++; + if (!refused_with_xfsz(ftruncate(fd, 2 * FSIZE_LIMIT), &seen)) + goto out; + step++; + if (!refused_with_xfsz(truncate(path, 2 * FSIZE_LIMIT), &seen)) + goto out; + + /* 9: a descriptor not open for writing is refused for that, with no signal, + * before the limit is looked at. + */ + step++; + { + int rfd = open(path, O_RDONLY); + int rc = rfd < 0 ? 0 : ftruncate(rfd, 2 * FSIZE_LIMIT); + int ok = rc == -1 && errno == EINVAL && xfsz_count == seen; + rc = rfd < 0 ? 0 : fallocate(rfd, 0, 0, 2 * FSIZE_LIMIT); + ok = ok && rc == -1 && errno == EBADF && xfsz_count == seen; + + /* So is a write, at the limit by position and past it by offset. */ + struct iovec iov = {block, 1}; + ok = ok && lseek(rfd, 0, SEEK_END) == FSIZE_LIMIT; + ok = ok && write(rfd, block, 1) == -1 && errno == EBADF; + ok = ok && writev(rfd, &iov, 1) == -1 && errno == EBADF; + ok = ok && pwrite(rfd, block, 1, 2 * FSIZE_LIMIT) == -1 && + errno == EBADF; + ok = ok && pwritev(rfd, &iov, 1, 2 * FSIZE_LIMIT) == -1 && + errno == EBADF && xfsz_count == seen; + if (rfd >= 0) + close(rfd); + if (!ok) + goto out; + } + + /* 10: shrinking, and growing back up to the limit, are not refused. */ + step++; + if (ftruncate(fd, 100) < 0 || ftruncate(fd, FSIZE_LIMIT) < 0 || + xfsz_count != seen) + goto out; + + /* 11: RWF_APPEND writes at end of file, now the limit, whatever offset it + * is given. The raw call, because the glibc wrapper reports any failure of + * a flagged request as ENOTSUP. + */ + step++; + { + struct iovec iov = {block, 1}; + if (!refused_with_xfsz( + syscall(SYS_pwritev2, fd, &iov, 1, 0L, 0L, RWF_APPEND), &seen)) + goto out; + } + + /* 12: a vector with no bytes in it writes nothing and is not refused, at + * the limit or past it. + */ + step++; + { + struct iovec iov[2] = {{block, 0}, {block, 0}}; + if (lseek(fd, 0, SEEK_END) != FSIZE_LIMIT || writev(fd, iov, 2) != 0 || + pwritev(fd, iov, 2, 2 * FSIZE_LIMIT) != 0 || + syscall(SYS_pwritev2, fd, iov, 2, 0L, 0L, RWF_APPEND) != 0 || + xfsz_count != seen) + goto out; + } + + step = 0; +out: + close(fd); + unlink(path); + return step; +} + +#define COPY_SRC_SIZE 20000 + +/* Empty @dst_fd, grow it to @dst_size and leave its position there; put + * @src_fd's position at @src_pos. + */ +static int copy_reset(int dst_fd, off_t dst_size, int src_fd, off_t src_pos) +{ + return ftruncate(dst_fd, 0) < 0 || ftruncate(dst_fd, dst_size) < 0 || + lseek(dst_fd, dst_size, SEEK_SET) < 0 || + lseek(src_fd, src_pos, SEEK_SET) < 0; +} + +/* Runs in a child under RLIMIT_FSIZE = FSIZE_LIMIT, for the calls that grow a + * file without write or truncate. A stream copy cut at the limit still raises + * SIGXFSZ, because the kernel goes on to offer the rest of its input to the + * file; copy_file_range checks its whole request up front and does not. + */ +static int fsize_copy_child(void) +{ + char src[] = "/tmp/elfuse-rlimit-src.XXXXXX"; + char dst[] = "/tmp/elfuse-rlimit-dst.XXXXXX"; + static char block[COPY_SRC_SIZE]; + int sfd = mkstemp(src), dfd = mkstemp(dst), pfd[2]; + struct rlimit rl; + struct sigaction sa = {.sa_handler = on_xfsz}; + int seen = 0, step = 0; + + memset(block, 'y', sizeof(block)); + if (sfd < 0 || dfd < 0 || pipe(pfd) < 0 || + write(sfd, block, sizeof(block)) != sizeof(block) || + sigaction(SIGXFSZ, &sa, NULL) < 0 || getrlimit(RLIMIT_FSIZE, &rl) < 0) + return 100; + rl.rlim_cur = FSIZE_LIMIT; + if (setrlimit(RLIMIT_FSIZE, &rl) < 0) + return 101; + + /* 1: sendfile across the limit is cut at it and raises the signal. */ + step++; + if (copy_reset(dfd, 0, sfd, 0) || + sendfile(dfd, sfd, NULL, 10000) != FSIZE_LIMIT || xfsz_count != 1 || + lseek(sfd, 0, SEEK_CUR) != FSIZE_LIMIT) + goto out; + seen = xfsz_count; + + /* 2: sendfile that starts at the limit. */ + step++; + if (!refused_with_xfsz(sendfile(dfd, sfd, NULL, 100), &seen)) + goto out; + + /* 3: no signal when the input ends where the limit is. */ + step++; + if (copy_reset(dfd, FSIZE_LIMIT - 96, sfd, COPY_SRC_SIZE - 96) || + sendfile(dfd, sfd, NULL, 5000) != 96 || xfsz_count != seen) + goto out; + + /* 4: copy_file_range across the limit is cut at it with no signal. */ + step++; + if (copy_reset(dfd, 0, sfd, 0) || + copy_file_range(sfd, NULL, dfd, NULL, 10000, 0) != FSIZE_LIMIT || + xfsz_count != seen) + goto out; + + /* 5: at the limit it is refused whatever the length. */ + step++; + if (!refused_with_xfsz(copy_file_range(sfd, NULL, dfd, NULL, 0, 0), &seen)) + goto out; + + /* 6: splice across the limit leaves the rest in the pipe. */ + step++; + if (copy_reset(dfd, 0, sfd, 0) || write(pfd[1], block, 10000) != 10000 || + splice(pfd[0], NULL, dfd, NULL, 10000, 0) != FSIZE_LIMIT || + xfsz_count != seen + 1 || + read(pfd[0], block, sizeof(block)) != 10000 - FSIZE_LIMIT) + goto out; + seen = xfsz_count; + + /* 7: fallocate may reach the limit and not pass it. */ + step++; + if (copy_reset(dfd, 0, sfd, 0) || fallocate(dfd, 0, 0, FSIZE_LIMIT) < 0 || + xfsz_count != seen || + !refused_with_xfsz(fallocate(dfd, 0, 0, FSIZE_LIMIT + 1), &seen)) + goto out; + + /* 8: with the file at the limit, a pipe that has nothing to give raises no + * signal: no byte reached the file. It is EAGAIN while a writer is left and + * end of input once the writer is gone. + */ + step++; + { + int empty[2]; + if (pipe2(empty, O_NONBLOCK) < 0 || + copy_reset(dfd, FSIZE_LIMIT, sfd, 0)) + goto out; + long rc = splice(empty[0], NULL, dfd, NULL, 100, SPLICE_F_NONBLOCK); + int ok = rc == -1 && errno == EAGAIN && xfsz_count == seen; + close(empty[1]); + rc = splice(empty[0], NULL, dfd, NULL, 100, SPLICE_F_NONBLOCK); + ok = ok && rc == 0 && xfsz_count == seen; + close(empty[0]); + if (!ok) + goto out; + } + + step = 0; +out: + close(sfd); + close(dfd); + unlink(src); + unlink(dst); + return step; +} + +static void *unblock_xfsz_and_wait(void *arg) +{ + sigset_t set; + char c; + sigemptyset(&set); + sigaddset(&set, SIGXFSZ); + pthread_sigmask(SIG_UNBLOCK, &set, NULL); + (void) !write(((int *) arg)[1], "r", 1); + (void) !read(((int *) arg)[2], &c, 1); + return NULL; +} + +/* Runs in a child. The writer has SIGXFSZ blocked and a second thread does not: + * a signal sent to the process would run the handler on that thread, and one + * sent to the writer stays pending on it. + */ +static int fsize_thread_child(void) +{ + char path[] = "/tmp/elfuse-rlimit-thread.XXXXXX"; + int fd = mkstemp(path), ready[2], hold[2], step = 1; + struct sigaction sa = {.sa_handler = on_xfsz}; + struct rlimit rl; + sigset_t set, pending; + pthread_t thread; + char c; + + sigemptyset(&set); + sigaddset(&set, SIGXFSZ); + if (fd < 0 || pipe(ready) < 0 || pipe(hold) < 0 || + sigaction(SIGXFSZ, &sa, NULL) < 0 || + pthread_sigmask(SIG_BLOCK, &set, NULL) != 0 || + getrlimit(RLIMIT_FSIZE, &rl) < 0) + return 100; + int fds[3] = {ready[0], ready[1], hold[0]}; + if (pthread_create(&thread, NULL, unblock_xfsz_and_wait, fds) != 0 || + read(ready[0], &c, 1) != 1) + return 101; + rl.rlim_cur = 0; + if (setrlimit(RLIMIT_FSIZE, &rl) < 0) + return 102; + + if (write(fd, "x", 1) != -1 || errno != EFBIG) + goto out; + step++; + usleep(100000); + if (xfsz_count != 0 || sigpending(&pending) < 0 || + !sigismember(&pending, SIGXFSZ)) + goto out; + step = 0; +out: + (void) !write(hold[1], "x", 1); + pthread_join(thread, NULL); + close(fd); + unlink(path); + return step; +} + +static int cpu_report_fd; + +static void on_xcpu(int sig) +{ + (void) sig; + (void) !write(cpu_report_fd, "x", 1); +} + +/* Runs in a child under RLIMIT_CPU = 1 s soft, 2 s hard, and never returns. It + * reports 'p' once it has run for a while with the limit set, which a guest + * stalled by the limit never does, then loops with no syscall, so only the + * limit can end it. + */ +static void cpu_child(int report_fd) +{ + struct rlimit rl = {.rlim_cur = 1, .rlim_max = 2}; + struct sigaction sa = {.sa_handler = on_xcpu}; + struct timespec t0, t; + + cpu_report_fd = report_fd; + if (sigaction(SIGXCPU, &sa, NULL) < 0 || setrlimit(RLIMIT_CPU, &rl) < 0) + _exit(100); + clock_gettime(CLOCK_MONOTONIC, &t0); + do { + clock_gettime(CLOCK_MONOTONIC, &t); + } while ((t.tv_sec - t0.tv_sec) * 1000000000L + t.tv_nsec - t0.tv_nsec < + 100000000L); + (void) !write(report_fd, "p", 1); + for (volatile unsigned long spin = 0;; spin++) + ; +} + +#define MIB ((size_t) 1 << 20) + +/* VmSize from /proc/self/status in bytes, which is what RLIMIT_AS bounds. */ +static size_t vm_size(void) +{ + char buf[4096]; + int fd = open("/proc/self/status", O_RDONLY); + ssize_t n = fd < 0 ? -1 : read(fd, buf, sizeof(buf) - 1); + if (fd >= 0) + close(fd); + if (n <= 0) + return 0; + buf[n] = '\0'; + char *p = strstr(buf, "VmSize:"); + return p ? (size_t) strtoul(p + 7, NULL, 10) * 1024 : 0; +} + +static int set_soft(int resource, rlim_t cur) +{ + struct rlimit rl; + if (getrlimit(resource, &rl) < 0) + return -1; + rl.rlim_cur = cur; + return setrlimit(resource, &rl); +} + +static void *map_anon(void *addr, size_t len, int prot, int flags) +{ + return mmap(addr, len, prot, flags | MAP_ANONYMOUS, -1, 0); +} + +/* Runs in a child with RLIMIT_AS 24 MiB above what is mapped. The exit status + * is the number of the first step that went wrong, 0 if none did. + */ +static int as_child(void) +{ + size_t base = vm_size(); + int rw = PROT_READ | PROT_WRITE; + if (base == 0 || set_soft(RLIMIT_AS, base + 24 * MIB) < 0) + return 100; + + /* 1: a mapping that stays below the limit. */ + void *a = map_anon(NULL, 16 * MIB, rw, MAP_PRIVATE); + if (a == MAP_FAILED) + return 1; + + /* 2: one that crosses it, with 8 MiB left. */ + errno = 0; + if (map_anon(NULL, 16 * MIB, rw, MAP_PRIVATE) != MAP_FAILED || + errno != ENOMEM) + return 2; + + /* 3: a shared or read-only mapping is address space too. */ + errno = 0; + if (map_anon(NULL, 16 * MIB, PROT_READ, MAP_SHARED) != MAP_FAILED || + errno != ENOMEM) + return 3; + + /* 4: MAP_FIXED over the first mapping replaces it and adds nothing. */ + if (map_anon(a, 16 * MIB, rw, MAP_PRIVATE | MAP_FIXED) != a) + return 4; + + /* 5: growing it by mremap crosses the limit. */ + errno = 0; + if (mremap(a, 16 * MIB, 32 * MIB, MREMAP_MAYMOVE) != MAP_FAILED || + errno != ENOMEM) + return 5; + + /* 6: what munmap gives back can be mapped again. */ + if (munmap(a, 16 * MIB) < 0 || + map_anon(NULL, 16 * MIB, rw, MAP_PRIVATE) == MAP_FAILED) + return 6; + + /* 7: a bad descriptor is reported before the limit is, for a hinted, a + * placed and a fixed request alike. + */ + void *at = (char *) a + 16 * MIB; + int fixed = MAP_PRIVATE | MAP_FIXED; + errno = 0; + if (mmap(NULL, 32 * MIB, PROT_READ, MAP_PRIVATE, -1, 0) != MAP_FAILED || + errno != EBADF) + return 7; + errno = 0; + if (mmap(at, 32 * MIB, PROT_READ, fixed, -1, 0) != MAP_FAILED || + errno != EBADF) + return 7; + return 0; +} + +/* Bytes /proc/self/maps lists, less the elfuse runtime records ([shim], + * [shim-data]) that a Linux process has no mapping for. + */ +static size_t maps_size(void) +{ + static char buf[64 * 1024]; + size_t len = 0; + ssize_t n = 0; + int fd = open("/proc/self/maps", O_RDONLY); + while (fd >= 0 && len < sizeof(buf) - 1 && + (n = read(fd, buf + len, sizeof(buf) - 1 - len)) > 0) + len += (size_t) n; + if (fd >= 0) + close(fd); + if (fd < 0 || n < 0) + return 0; + buf[len] = '\0'; + + size_t total = 0; + for (char *line = buf; *line; line = strchr(line, '\n') + 1) { + char *end; + unsigned long lo = strtoul(line, &end, 16); + unsigned long hi = strtoul(end + 1, NULL, 16); + char *nl = strchr(line, '\n'); + if (!nl) + break; + *nl = '\0'; + if (!strstr(line, "[shim")) + total += hi - lo; + *nl = '\n'; + } + return total; +} + +/* Runs in a child with RLIMIT_AS 1 MiB above what maps lists. The exit status + * is the number of the first step that went wrong, 0 if none did. + */ +static int as_exact_child(void) +{ + size_t base = maps_size(); + int rw = PROT_READ | PROT_WRITE; + if (base == 0 || set_soft(RLIMIT_AS, base + MIB) < 0) + return 100; + + /* 1: the last MiB under the limit. */ + if (map_anon(NULL, MIB, rw, MAP_PRIVATE) == MAP_FAILED) + return 1; + + /* 2: another MiB past it. */ + errno = 0; + if (map_anon(NULL, MIB, rw, MAP_PRIVATE) != MAP_FAILED || errno != ENOMEM) + return 2; + return 0; +} + +/* Runs in a child with RLIMIT_DATA at 24 MiB, far above the data this static + * binary starts with. The sizes stay small enough that the break has room to + * grow where no limit is set. + */ +static int data_child(void) +{ + int rw = PROT_READ | PROT_WRITE; + if (set_soft(RLIMIT_DATA, 24 * MIB) < 0) + return 100; + + /* 1: a break that stays below the limit. */ + if (sbrk(8 * MIB) == (void *) -1) + return 1; + + /* 2: one that crosses it leaves the break where it was. */ + void *before = sbrk(0); + errno = 0; + if (sbrk(32 * MIB) != (void *) -1 || errno != ENOMEM || sbrk(0) != before) + return 2; + + /* 3: a private writable mapping is data. */ + errno = 0; + if (map_anon(NULL, 32 * MIB, rw, MAP_PRIVATE) != MAP_FAILED || + errno != ENOMEM) + return 3; + + /* 4: a read-only or shared mapping of the same size is not. */ + char *ro = map_anon(NULL, 32 * MIB, PROT_READ, MAP_PRIVATE); + char *shared = map_anon(NULL, 32 * MIB, PROT_READ, MAP_SHARED); + if (ro == MAP_FAILED || shared == MAP_FAILED) + return 4; + + /* 5: making the private one writable turns it into data. */ + errno = 0; + if (mprotect(ro, 32 * MIB, rw) != -1 || errno != ENOMEM) + return 5; + + /* 6: a part that fits, and the whole of the shared one, may be. */ + if (mprotect(ro, 4 * MIB, rw) < 0 || mprotect(shared, 32 * MIB, rw) < 0) + return 6; + ro[0] = shared[0] = 1; + + /* 7: a range over two mappings stops at the one that does not fit, with the + * one below it already writable. The second mprotect below splits one + * mapping into the two. + */ + char *two = map_anon(NULL, 36 * MIB, PROT_NONE, MAP_PRIVATE); + int zero = open("/dev/zero", O_RDONLY); + if (two == MAP_FAILED || zero < 0 || + mprotect(two + 4 * MIB, 32 * MIB, PROT_READ) < 0) + return 100; + errno = 0; + if (mprotect(two, 36 * MIB, rw) != -1 || errno != ENOMEM || + read(zero, two, 1) != 1 || read(zero, two + 4 * MIB, 1) != -1) + return 7; + close(zero); + return 0; +} + +/* Run @child_main in a fork child and report its first failed step. */ +static void expect_child_steps(int (*child_main)(void)) +{ + int status = -1; + pid_t pid = fork(); + if (pid == 0) + _exit(child_main()); + if (pid < 0 || waitpid(pid, &status, 0) < 0 || !WIFEXITED(status)) { + FAIL("child did not exit"); + } else if (WEXITSTATUS(status) != 0) { + printf("FAIL: step %d\n", WEXITSTATUS(status)); + fails++; + } else { + PASS(); + } +} + +int main(void) +{ + printf("test-rlimit-enforce: guest rlimits are enforced\n"); + + TEST("RLIMIT_FSIZE on write and truncate"); + expect_child_steps(fsize_child); + + TEST("RLIMIT_FSIZE on sendfile, copy, splice, fallocate"); + expect_child_steps(fsize_copy_child); + + TEST("SIGXFSZ goes to the writing thread"); + expect_child_steps(fsize_thread_child); + + { + int report[2], status = -1; + char got[8]; + ssize_t n = -1; + pid_t pid = pipe(report) < 0 ? -1 : fork(); + if (pid == 0) { + close(report[0]); + cpu_child(report[1]); + } + if (pid > 0) { + close(report[1]); + waitpid(pid, &status, 0); + n = read(report[0], got, sizeof(got)); + close(report[0]); + } + + TEST("guest runs under a CPU limit"); + EXPECT_TRUE(n >= 1 && got[0] == 'p', "no progress after setrlimit"); + + TEST("SIGXCPU at the soft CPU limit"); + EXPECT_TRUE(n >= 2 && got[1] == 'x', "the spin loop got no SIGXCPU"); + + TEST("SIGKILL at the hard CPU limit"); + EXPECT_TRUE(WIFSIGNALED(status) && WTERMSIG(status) == SIGKILL, + "the spin loop was not killed"); + } + + TEST("RLIMIT_AS on mmap and mremap"); + expect_child_steps(as_child); + + TEST("RLIMIT_AS counts only guest mappings"); + expect_child_steps(as_exact_child); + + TEST("RLIMIT_DATA on brk, mmap and mprotect"); + expect_child_steps(data_child); + + SUMMARY("test-rlimit-enforce"); + return fails ? 1 : 0; +} diff --git a/tests/test-rlimit-stack.c b/tests/test-rlimit-stack.c new file mode 100644 index 00000000..58581b06 --- /dev/null +++ b/tests/test-rlimit-stack.c @@ -0,0 +1,549 @@ +/* + * Test that the main stack grows on demand, up to RLIMIT_STACK + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Linux maps the argument block plus 128 KiB at execve and grows the stack one + * fault at a time, for an access by the program or by a syscall acting on its + * pointer, until the region would pass the soft limit. The limit also sizes + * what execve accepts as arguments. Each check sets the limit in a fork child + * and execs this binary again in the mode named by argv[1], because the limit + * that counts is the one in force at execve. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define KIB 1024UL +#define MIB (1024UL * 1024) +#define PAGE 4096UL + +/* Keep a buffer the optimizer cannot reason about, so its frame is real. */ +#define KEEP(p) __asm__ volatile("" ::"r"(p) : "memory") + +/* Extent of the "[stack]" line of /proc/self/maps. Returns 0, or -1. */ +static int stack_range(unsigned long *lo, unsigned long *hi) +{ + char line[512]; + FILE *f = fopen("/proc/self/maps", "r"); + int rc = -1; + + while (f && fgets(line, sizeof(line), f)) { + const char *tag = strstr(line, "[stack]"); + if (tag && (tag[7] == '\n' || tag[7] == '\0') && + sscanf(line, "%lx-%lx", lo, hi) == 2) + rc = 0; + } + if (f) + fclose(f); + return rc; +} + +/* Write one byte at @addr in a fork child. + * + * Returns the signal that killed the child, or 0. + */ +static int touch_in_child(unsigned long addr) +{ + int status = 0; + pid_t pid = fork(); + if (pid == 0) { + *(volatile char *) addr = 1; + _exit(0); + } + if (pid < 0 || waitpid(pid, &status, 0) < 0) + return -1; + return WIFSIGNALED(status) ? WTERMSIG(status) : 0; +} + +static unsigned long soft_limit(void) +{ + struct rlimit rl; + return getrlimit(RLIMIT_STACK, &rl) < 0 ? 0 : (unsigned long) rl.rlim_cur; +} + +/* Each mode returns the number of the first step that went wrong, 0 if none + * did, and that is the exit status of the image. + */ + +/* The region starts small, and an access below it moves its start to the page + * accessed, which reads as zero. + */ +static int mode_grow(void) +{ + unsigned long lo, hi, now; + if (stack_range(&lo, &hi) < 0) + return 100; + + if (hi - lo > MIB) + return 1; + + *(volatile char *) (hi - 512 * KIB) = 1; + if (stack_range(&now, &hi) < 0 || now != hi - 512 * KIB) + return 2; + + if (*(volatile char *) (hi - 600 * KIB) != 0 || + stack_range(&now, &hi) < 0 || now != hi - 600 * KIB) + return 3; + return 0; +} + +/* A syscall that writes through a pointer under the stack grows it too. */ +static int mode_syscall(void) +{ + unsigned long lo, hi, now; + int pfd[2]; + if (stack_range(&lo, &hi) < 0 || pipe(pfd) < 0 || + write(pfd[1], "abc", 3) != 3) + return 100; + + char *buf = (char *) (hi - 1536 * KIB); + if (read(pfd[0], buf, 3) != 3 || memcmp(buf, "abc", 3) != 0) + return 1; + if (stack_range(&now, &hi) < 0 || now != hi - 1536 * KIB) + return 2; + return 0; +} + +/* The soft limit is the size of the region: its last byte can be reached and + * the one below cannot, by the program or by a syscall. + */ +static int mode_edge(void) +{ + unsigned long lo, hi, limit = soft_limit(); + int zero = open("/dev/zero", O_RDONLY); + if (stack_range(&lo, &hi) < 0 || zero < 0 || limit == 0) + return 100; + + if (touch_in_child(hi - limit) != 0) + return 1; + if (touch_in_child(hi - limit - 1) != SIGSEGV) + return 2; + errno = 0; + if (read(zero, (void *) (hi - limit - PAGE), PAGE) != -1 || errno != EFAULT) + return 3; + return 0; +} + +/* With no limit the stack passes the 8 MiB a default one stops at, and a fork + * child has what the parent wrote down there. + */ +static int mode_deep(void) +{ + unsigned long lo, hi, now; + if (stack_range(&lo, &hi) < 0) + return 100; + + volatile char *deep = (volatile char *) (hi - 48 * MIB); + *deep = 0x5a; + if (stack_range(&now, &hi) < 0 || now != hi - 48 * MIB) + return 1; + + int status = -1; + pid_t pid = fork(); + if (pid == 0) + _exit(*deep == 0x5a ? 0 : 1); + if (pid < 0 || waitpid(pid, &status, 0) < 0 || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) + return 2; + return 0; +} + +/* A limit lowered under the size the stack already has takes nothing away and + * stops the next growth. + */ +static int mode_lower(void) +{ + unsigned long lo, hi; + struct rlimit rl; + if (stack_range(&lo, &hi) < 0 || getrlimit(RLIMIT_STACK, &rl) < 0) + return 100; + + *(volatile char *) (hi - MIB) = 1; + rl.rlim_cur = 64 * KIB; + if (setrlimit(RLIMIT_STACK, &rl) < 0) + return 101; + + *(volatile char *) (hi - MIB) = 2; + if (touch_in_child(hi - MIB - 1) != SIGSEGV) + return 1; + return 0; +} + +/* File offset column of the "[stack]" line, or -1. */ +static long stack_offset(void) +{ + char line[512]; + FILE *f = fopen("/proc/self/maps", "r"); + long offset = -1; + + while (f && fgets(line, sizeof(line), f)) { + const char *tag = strstr(line, "[stack]"); + unsigned long lo, hi, off; + if (tag && (tag[7] == '\n' || tag[7] == '\0') && + sscanf(line, "%lx-%lx %*s %lx", &lo, &hi, &off) == 3) + offset = (long) off; + } + if (f) + fclose(f); + return offset; +} + +/* What acts on a range of the grown stack acts on that range and no other. @top + * is where the region started before it grew, so it and the page touched a + * megabyte down are the two ends of the growth. + */ +static int mode_region(void) +{ + unsigned long top, hi; + if (stack_range(&top, &hi) < 0) + return 100; + + volatile char *low = (volatile char *) (hi - MIB); + volatile char *kept = (volatile char *) top; + *kept = 0x22; + *low = 0x11; + if (stack_offset() != 0) + return 1; + + /* MADV_DONTNEED empties the page it names. */ + if (madvise((void *) low, PAGE, MADV_DONTNEED) < 0 || *low != 0 || + *kept != 0x22) + return 2; + + /* A page taken away and given back is still that page. */ + if (mprotect((void *) low, PAGE, PROT_NONE) < 0 || + mprotect((void *) low, PAGE, PROT_READ | PROT_WRITE) < 0) + return 101; + *low = 0x33; + if (*kept != 0x22) + return 3; + return 0; +} + +static volatile sig_atomic_t usr1_count; + +static void on_usr1(int sig) +{ + (void) sig; + usr1_count++; +} + +/* Signal delivery writes the handler's frame under the stack pointer. Here that + * is a megabyte under anything the program has touched. + */ +static __attribute__((noinline)) int raise_under_frame(void) +{ + char frame[MIB]; + KEEP(frame); + return raise(SIGUSR1); +} + +static int mode_signal(void) +{ + struct sigaction sa = {.sa_handler = on_usr1}; + if (sigaction(SIGUSR1, &sa, NULL) < 0) + return 100; + if (raise_under_frame() != 0 || usr1_count != 1) + return 1; + return 0; +} + +/* A limit under the usual 128 KiB of headroom is the whole initial region. */ +static int mode_small(void) +{ + unsigned long lo, hi; + if (stack_range(&lo, &hi) < 0) + return 100; + return hi - lo == soft_limit() ? 0 : 1; +} + +/* Whether the stack starts at @hi - @below after an access there. */ +static int grown_to(unsigned long hi, unsigned long below) +{ + unsigned long now, top; + return stack_range(&now, &top) == 0 && top == hi && now == hi - below; +} + +/* Accesses the kernel makes for the program without a fault of the program's + * own: each one names a page under the stack that nothing has touched, and + * grows the stack to it. Under elfuse these are the accesses the EL1 shim + * serves or emulates itself. + */ +static int mode_kernel(void) +{ + unsigned long lo, hi; + int fd = open("/dev/urandom", O_RDONLY); + if (stack_range(&lo, &hi) < 0 || fd < 0) + return 100; + + /* 1 and 2: random bytes, by read and by getrandom. */ + if (read(fd, (void *) (hi - 1 * MIB), 16) != 16 || !grown_to(hi, 1 * MIB)) + return 1; + if (getrandom((void *) (hi - 2 * MIB), 16, 0) != 16 || + !grown_to(hi, 2 * MIB)) + return 2; + close(fd); + + /* 3: a futex timeout there reads as zero, so a wait that would block on a + * matching word ends at once. The word itself is not tried: the kernel + * reads it with page faults off and answers EFAULT without growing. + */ + static int word; + errno = 0; + if (syscall(SYS_futex, &word, FUTEX_WAIT, 0, hi - 3 * MIB, NULL, 0) != -1 || + errno != ETIMEDOUT || !grown_to(hi, 3 * MIB)) + return 3; + +#ifdef __aarch64__ + /* 4: DC ZVA zeroes a block of 4 << BS bytes, DCZID_EL0[3:0], unless bit 4 + * forbids it. + */ + unsigned long dczid; + __asm__("mrs %0, dczid_el0" : "=r"(dczid)); + if (!(dczid & 16) && (4UL << (dczid & 15)) <= PAGE) { + __asm__ volatile("dc zva, %0" ::"r"(hi - 4 * MIB) : "memory"); + if (!grown_to(hi, 4 * MIB)) + return 4; + } +#endif + return 0; +} + +/* An address hint just under the stack is not taken: a placed mapping stays + * stack_guard_gap, 1 MiB, clear of it (vm_start_gap), and the stack still grows + * to the page the hint named. + */ +static int mode_gap(void) +{ + unsigned long lo, hi, now; + if (stack_range(&lo, &hi) < 0) + return 100; + + unsigned long p = (unsigned long) mmap((void *) (lo - 64 * KIB), PAGE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (p == (unsigned long) MAP_FAILED) + return 100; + if (p + PAGE > lo - MIB && p < hi) + return 1; + + *(volatile char *) (lo - 64 * KIB) = 1; + if (stack_range(&now, &hi) < 0 || now != lo - 64 * KIB) + return 2; + return 0; +} + +/* More mappings than elfuse has region records for, which a real kernel takes + * in its stride. Past that point elfuse no longer knows every mapping, and + * neither the stack nor an mprotect under a finite RLIMIT_DATA may start + * failing for it. + */ +static int mode_many(void) +{ + unsigned long lo, hi, now, span = 64 * MIB; + struct rlimit rl; + if (stack_range(&lo, &hi) < 0 || getrlimit(RLIMIT_DATA, &rl) < 0) + return 100; + rl.rlim_cur = 1024 * MIB; + if (setrlimit(RLIMIT_DATA, &rl) < 0) + return 100; + + char *p = mmap(NULL, span, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (p == MAP_FAILED) + return 100; + + /* A mapping inside the stack's window, 2 MiB and two pages long. Once the + * table is full, unmapping all but its first and last page leaves the last + * one with no record under elfuse. + */ + char *fixed = mmap((void *) (hi - 7 * MIB), 2 * MIB + 2 * PAGE, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + if (fixed == MAP_FAILED) + return 100; + char *last = fixed + 2 * MIB + PAGE; + *last = 42; + + /* Every other page becomes a mapping of its own. Under elfuse the break is + * refused once the table is full, and each split after that logs a line, so + * stop there. + */ + for (unsigned long off = 0; off + 2 * PAGE <= span; off += 2 * PAGE) { + if (mprotect(p + off, PAGE, PROT_READ) != 0) + break; + + unsigned long cur = (unsigned long) syscall(SYS_brk, 0UL); + int grew = (unsigned long) syscall(SYS_brk, cur + PAGE) >= cur + PAGE; + syscall(SYS_brk, cur); + if (!grew) + break; + } + + if (munmap(fixed + PAGE, 2 * MIB) != 0) + return 100; + + *(volatile char *) (hi - 2 * MIB) = 1; + if (stack_range(&now, &hi) < 0 || now != hi - 2 * MIB) + return 1; + if (mprotect(p, PAGE, PROT_READ | PROT_WRITE) != 0) + return 2; + + /* The page under that last page is not the stack's to grow into. */ + if (syscall(SYS_getrandom, last - PAGE, 1, 0) != -1 || errno != EFAULT) + return 3; + if (*last != 42) + return 4; + return 0; +} + +static const struct { + const char *name; + int (*run)(void); +} modes[] = { + {"grow", mode_grow}, {"syscall", mode_syscall}, {"edge", mode_edge}, + {"deep", mode_deep}, {"lower", mode_lower}, {"signal", mode_signal}, + {"small", mode_small}, {"region", mode_region}, {"gap", mode_gap}, + {"many", mode_many}, {"kernel", mode_kernel}, +}; + +/* Exec @self with argv[1] = @mode and @arg_bytes of further arguments, under a + * soft RLIMIT_STACK of @limit. + * + * Returns the image's exit status, -1 if a signal ended it, or -2 if execve + * failed with E2BIG. + */ +static int run_under(char *self, rlim_t limit, char *mode, size_t arg_bytes) +{ + enum { EACH = 100 * 1024 }; + size_t n = arg_bytes / EACH; + char **argv = calloc(n + 3, sizeof(*argv)); + int status = -1; + + if (!argv) + return -3; + argv[0] = self; + argv[1] = mode; + for (size_t i = 0; i < n; i++) { + argv[2 + i] = malloc(EACH); + if (!argv[2 + i]) + return -3; + memset(argv[2 + i], 'a', EACH - 1); + argv[2 + i][EACH - 1] = '\0'; + } + + pid_t pid = fork(); + if (pid == 0) { + struct rlimit rl; + if (getrlimit(RLIMIT_STACK, &rl) < 0) + _exit(120); + rl.rlim_cur = limit; + if (setrlimit(RLIMIT_STACK, &rl) < 0) + _exit(121); + + /* An empty environment, so what fits depends on the arguments alone and + * not on what the caller of this test had exported. + */ + char *envp[] = {NULL}; + execve(self, argv, envp); + _exit(errno == E2BIG ? 122 : 123); + } + if (pid > 0) + waitpid(pid, &status, 0); + for (size_t i = 0; i < n; i++) + free(argv[2 + i]); + free(argv); + + if (!WIFEXITED(status)) + return -1; + return WEXITSTATUS(status) == 122 ? -2 : WEXITSTATUS(status); +} + +static void expect_mode(const char *name, char *self, rlim_t limit, char *mode) +{ + TEST(name); + int rc = run_under(self, limit, mode, 0); + if (rc == 0) { + PASS(); + } else { + printf("FAIL: status %d\n", rc); + fails++; + } +} + +/* execve takes @fits bytes of arguments under @limit and refuses @too_big. */ +static void expect_args(const char *name, + char *self, + rlim_t limit, + size_t fits, + size_t too_big) +{ + TEST(name); + int ok = run_under(self, limit, "nop", fits); + int big = run_under(self, limit, "nop", too_big); + if (ok == 0 && big == -2) { + PASS(); + } else { + printf("FAIL: %zu KiB gave %d, %zu KiB gave %d\n", fits / KIB, ok, + too_big / KIB, big); + fails++; + } +} + +int main(int argc, char **argv) +{ + if (argc > 1 && !strcmp(argv[1], "nop")) + return 0; + for (size_t i = 0; argc > 1 && i < sizeof(modes) / sizeof(modes[0]); i++) + if (!strcmp(argv[1], modes[i].name)) + return modes[i].run(); + + printf("test-rlimit-stack: the stack grows up to RLIMIT_STACK\n"); + + expect_mode("stack starts small, grows by touch", argv[0], 8 * MIB, "grow"); + expect_mode("a syscall grows the stack", argv[0], 8 * MIB, "syscall"); + expect_mode("a signal frame grows the stack", argv[0], 8 * MIB, "signal"); + expect_mode("random bytes, futex and DC ZVA grow it", argv[0], 8 * MIB, + "kernel"); + expect_mode("the region follows the stack down", argv[0], 8 * MIB, + "region"); + expect_mode("a hint under the stack is not taken", argv[0], 8 * MIB, "gap"); + expect_mode("the stack grows past a full table", argv[0], 8 * MIB, "many"); + expect_mode("limit of 1 MiB is the last byte", argv[0], MIB, "edge"); + expect_mode("limit of 8 MiB is the last byte", argv[0], 8 * MIB, "edge"); + expect_mode("limit of 64 MiB is the last byte", argv[0], 64 * MIB, "edge"); + expect_mode("no limit: 48 MiB deep, and in fork", argv[0], RLIM_INFINITY, + "deep"); + expect_mode("a lowered limit keeps what is mapped", argv[0], 8 * MIB, + "lower"); + expect_mode("limit of 64 KiB is the initial stack", argv[0], 64 * KIB, + "small"); + + /* A quarter of the limit, between 128 KiB and 6 MiB. */ + expect_args("execve arguments under 1 MiB", argv[0], MIB, 200 * KIB, + 300 * KIB); + expect_args("execve arguments under 8 MiB", argv[0], 8 * MIB, 1900 * KIB, + 2200 * KIB); + expect_args("execve arguments under 64 MiB", argv[0], 64 * MIB, 5800 * KIB, + 6200 * KIB); + + SUMMARY("test-rlimit-stack"); + return fails ? 1 : 0; +} diff --git a/tests/test-rlimit-unchanged-host.c b/tests/test-rlimit-unchanged-host.c new file mode 100644 index 00000000..d42d3a7a --- /dev/null +++ b/tests/test-rlimit-unchanged-host.c @@ -0,0 +1,93 @@ +/* + * A guest that sets its rlimits leaves elfuse's own limits where they were + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Guest limits are guest state, so setrlimit in the guest must not reach host + * setrlimit. Only elfuse can read the limits of its own process, which is what + * ELFUSE_HOST_RLIMIT_CHECK is for: each elfuse process records its host limits + * at start and fails its exit, with a line on stderr, when one has moved. This + * runs test-proc-limits, which sets every limit elfuse tracks in a parent and + * in fork children, with the check armed. + */ + +#include +#include +#include +#include +#include +#include + +#include "test-harness.h" + +int passes = 0, fails = 0; + +#define ELFUSE_BIN "build/elfuse" +#define GUEST_BIN "build/test-proc-limits" + +/* Run the guest with the check armed, its stdout dropped and its stderr in + * @err_fd. + * + * Returns the exit status of the top-level elfuse, or -1. + */ +static int run_guest_checked(int err_fd) +{ + pid_t pid = fork(); + if (pid < 0) + return -1; + + if (pid == 0) { + int null_fd = open("/dev/null", O_WRONLY); + if (null_fd < 0 || dup2(null_fd, STDOUT_FILENO) < 0 || + dup2(err_fd, STDERR_FILENO) < 0 || + setenv("ELFUSE_HOST_RLIMIT_CHECK", "1", 1) < 0) + _exit(127); + execl(ELFUSE_BIN, ELFUSE_BIN, GUEST_BIN, (char *) NULL); + _exit(127); + } + + int status = 0; + if (waitpid(pid, &status, 0) < 0) + return -1; + return WIFEXITED(status) ? WEXITSTATUS(status) : -1; +} + +int main(void) +{ + printf("test-rlimit-unchanged-host: host limits across a guest run\n"); + + if (access(ELFUSE_BIN, X_OK) != 0 || access(GUEST_BIN, R_OK) != 0) { + printf(" SKIP: %s or %s not built\n", ELFUSE_BIN, GUEST_BIN); + return 0; + } + + char err_path[] = "/tmp/elfuse-rlimit-unchanged-XXXXXX"; + int err_fd = mkstemp(err_path); + if (err_fd < 0) { + TEST("stderr capture"); + FAIL("mkstemp"); + SUMMARY("test-rlimit-unchanged-host"); + return 1; + } + unlink(err_path); + + int rc = run_guest_checked(err_fd); + + /* A fork child reports its exit status through the lifecycle registry + * before its atexit handlers run, so a child whose check fired shows only + * as the line it wrote. + */ + char err[4096] = {0}; + ssize_t n = pread(err_fd, err, sizeof(err) - 1, 0); + close(err_fd); + + TEST("guest exits 0 with the check armed"); + EXPECT_EQ(rc, 0, "guest or the top-level check failed"); + + TEST("no elfuse process moved a host limit"); + EXPECT_TRUE(n >= 0 && !strstr(err, "host rlimit"), err); + + SUMMARY("test-rlimit-unchanged-host"); + return fails ? 1 : 0; +} diff --git a/tests/test-rseq.c b/tests/test-rseq.c index 76c996a9..424ec836 100644 --- a/tests/test-rseq.c +++ b/tests/test-rseq.c @@ -5,9 +5,14 @@ * SPDX-License-Identifier: Apache-2.0 * * Tests: register, unregister, double-register, unregister-without-register, - * invalid length, invalid flags, signature mismatch on unregister + * invalid length, invalid flags, signature mismatch on unregister, and a + * signal delivered while registered (signal delivery reads the rseq area + * to abort a critical section, the access make check-locks checks) */ +#include +#include + #include "test-harness.h" #include "raw-syscall.h" @@ -22,6 +27,14 @@ struct rseq { unsigned int padding[3]; } __attribute__((aligned(32))); +static volatile sig_atomic_t got_usr1; + +static void on_usr1(int sig) +{ + (void) sig; + got_usr1 = 1; +} + int main(void) { int passes = 0, fails = 0; @@ -82,6 +95,14 @@ int main(void) EXPECT_TRUE(rc == 0, "re-register failed"); } + TEST("signal delivered while registered"); + { + signal(SIGUSR1, on_usr1); + kill(getpid(), SIGUSR1); + EXPECT_TRUE(got_usr1, "handler did not run"); + signal(SIGUSR1, SIG_DFL); + } + /* Unregister with wrong signature returns -EINVAL */ TEST("unregister wrong sig returns -EINVAL"); { diff --git a/tests/test-signal.c b/tests/test-signal.c index 233609a1..fe4c0be1 100644 --- a/tests/test-signal.c +++ b/tests/test-signal.c @@ -16,6 +16,8 @@ * 6. X7 survives delivery and rt_sigreturn (aarch64: the host uses X7 on the * HVC #5 return to ask the shim for a ptrace detour, and the tails that * rebuild EL0 state never restore it from the saved frame) + * 7. A bad old-value pointer to rt_sigaction or rt_sigprocmask is EFAULT with + * the new action or mask already in place, as in Linux */ #include @@ -25,6 +27,7 @@ #include #include #include +#include #include static volatile sig_atomic_t handler_called = 0; @@ -288,6 +291,42 @@ int main(void) } #endif + /* Test 7: raw syscalls, because libc copies the old value out itself and + * would fault in user space instead of returning EFAULT. + */ + printf("test-signal: 7. bad old pointer keeps the new state... "); + { + struct { + unsigned long handler, flags, restorer, mask; + } kact = {(unsigned long) sigusr1_handler, 0, 0, 0}; + unsigned long set = 1UL << (SIGUSR2 - 1); + void *bad = (void *) 8; + errno = 0; + long a = syscall(SYS_rt_sigaction, SIGUSR2, &kact, bad, 8); + int a_errno = errno; + errno = 0; + long m = syscall(SYS_rt_sigprocmask, SIG_BLOCK, &set, bad, 8); + int m_errno = errno; + + struct sigaction now; + sigset_t cur; + sigaction(SIGUSR2, NULL, &now); + sigprocmask(SIG_BLOCK, NULL, &cur); + if (a == -1 && a_errno == EFAULT && m == -1 && m_errno == EFAULT && + now.sa_handler == sigusr1_handler && sigismember(&cur, SIGUSR2)) { + printf("PASS\n"); + } else { + printf("FAIL (a=%ld/%d m=%ld/%d installed=%d blocked=%d)\n", a, + a_errno, m, m_errno, now.sa_handler == sigusr1_handler, + sigismember(&cur, SIGUSR2)); + failures++; + } + signal(SIGUSR2, SIG_DFL); + sigemptyset(&cur); + sigaddset(&cur, SIGUSR2); + sigprocmask(SIG_UNBLOCK, &cur, NULL); + } + if (failures == 0) { printf("test-signal: all tests passed -- PASS\n"); return 0;