diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a173585..e4c51fa1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -116,6 +116,148 @@ jobs: timeout-minutes: 20 run: make test-macos-leaks + # ============================================================ + # FreeBSD cross-compile gate (#402) + # + # Why this exists: the FreeBSD spelling of every `#ifdef` in the + # tree used to be compiled in exactly one place, the release + # workflow's build-freebsd leg, which runs only AFTER merge and was + # best-effort. A FreeBSD-only break therefore passed PR CI, passed + # the release, and surfaced only when somebody built on FreeBSD. + # That is how MNT_NODEV (removed in FreeBSD 10, still present on + # macOS and OpenBSD) reached main. + # + # `make ci` cannot catch this on any single host: it compiles only + # the branch of each platform conditional that matches the runner. + # Compiling FOR FreeBSD is the only thing that reads the FreeBSD + # branches, so it has to happen before merge, and it has to be + # required. No continue-on-error here on purpose. + # ============================================================ + ci-freebsd-cross: + name: FreeBSD / cross-compile (x86_64) + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + + - name: Install toolchain + run: sudo apt-get update && sudo apt-get install -y gcc make bc curl xz-utils + + # The pinned zig and the FreeBSD base sysroot (headers + libc) live in the + # sibling aether-crossbuild repo, same as the release leg uses. zig cc does + # not bundle a FreeBSD libc, so the base is what makes FreeBSD headers + # visible to the compile. + - name: Checkout aether-crossbuild + uses: actions/checkout@v4 + with: + repository: aether-lang-dev/aether-crossbuild + path: crossbuild + fetch-depth: 1 + + # Cache the downloaded tarballs, not the unpacked trees: get-zig.sh and + # fetch-freebsd-base.sh both skip the download when the tarball is already + # present and then re-extract, and fetch-freebsd-base.sh deletes and + # rebuilds its sysroot dir every run. Keyed on deps.lock, which pins both + # URLs and their checksums, so a hit is always the pinned bytes. + - name: Restore cross-toolchain download cache + id: cross_cache + uses: actions/cache/restore@v4 + with: + path: | + crossbuild/toolchain + crossbuild/work/downloads + key: freebsd-cross-${{ hashFiles('crossbuild/deps.lock') }} + + - name: Provision zig + FreeBSD base sysroot + id: prov + run: | + cd crossbuild + ./scripts/get-zig.sh + ./scripts/fetch-freebsd-base.sh x86_64 15 + ZIG="$(find "$PWD/toolchain" -maxdepth 2 -name zig -type f | head -1)" + SR="$PWD/bases/x86_64-freebsd15" + test -x "$ZIG" || { echo "zig not provisioned"; exit 1; } + test -f "$SR/lib/libc.so.7" || { echo "FreeBSD base sysroot missing libc.so.7"; exit 1; } + echo "zig=$ZIG" >> "$GITHUB_OUTPUT" + echo "sysroot=$SR" >> "$GITHUB_OUTPUT" + + - name: Save cross-toolchain download cache (merge to main, on miss only) + if: >- + github.event_name == 'push' && github.ref == 'refs/heads/main' + && steps.cross_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + crossbuild/toolchain + crossbuild/work/downloads + key: freebsd-cross-${{ hashFiles('crossbuild/deps.lock') }} + + - name: Cross-build for FreeBSD (FREEBSD=1) + run: | + make compiler ae stdlib \ + FREEBSD=1 \ + ZIG="${{ steps.prov.outputs.zig }}" \ + AETHER_SYSROOT="${{ steps.prov.outputs.sysroot }}" + # A cross-build that silently produced Linux binaries would pass the + # compile and prove nothing, so confirm the target really is FreeBSD. + file build/aetherc build/ae + file build/ae | grep -q 'FreeBSD' || { echo "ae is not a FreeBSD binary"; exit 1; } + + # ============================================================ + # FreeBSD native build + test (#402) — POST-MERGE + # + # The cross gate above proves the FreeBSD branches COMPILE. It + # cannot run anything: a FreeBSD ELF needs a FreeBSD kernel. This + # job boots a real FreeBSD VM and runs the C unit suite, so the + # runtime, scheduler (kqueue poller) and stdlib are exercised on + # the platform rather than merely type-checked for it. + # + # Why this one runs on push to main and not on pull requests: + # booting a VM, installing packages and building the tree from + # scratch costs tens of minutes, and what it adds over the cross + # gate is RUNTIME divergence, which is far rarer than a compile + # break and rarely PR-specific. The compile-break class, which is + # what actually broke FreeBSD, is fully covered before merge by + # the cross gate. Every merge to main runs this, so coverage is + # continuous rather than scheduled. + # + # What makes this safe as a post-merge check is that it HARD + # FAILS. The MNT_NODEV break did not hide because its check ran + # after merge; it hid because that check was continue-on-error + # and excluded from the release gate, so failing looked exactly + # like passing. Nothing here is allowed to fail quietly. + # + # Scope is `make test` (the 229 C unit tests), not the full + # `make ci`: the .ae suites and examples shell out through the + # freshly built toolchain and would put a multi-hour build inside + # an emulated VM. The C suite is where the platform-specific + # runtime and stdlib code actually lives. + # ============================================================ + ci-freebsd-native: + name: FreeBSD / native build + unit tests + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - uses: actions/checkout@v4 + + - name: Build and test inside a FreeBSD VM + uses: vmactions/freebsd-vm@v1 + with: + release: '14.2' + usesh: true + # FreeBSD base ships clang, bc and BSD make; this tree needs GNU make, + # and pkgconf for the Makefile's optional-library probes. bc is NOT a + # package here (it lives in base), so asking pkg for it fails the run. + prepare: | + pkg install -y gmake pkgconf + run: | + set -e + gmake compiler ae stdlib + gmake test + # ============================================================ # contrib/host bridge check — syntax check of every host bridge # in stub mode (always-on), plus end-to-end build+link+run of diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 10f3fe08..550d00c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -442,17 +442,18 @@ jobs: # cc (Makefile FREEBSD=1 mode), against a FreeBSD base sysroot # # provisioned from the aether-crossbuild repo. # # # - # continue-on-error: this leg is BEST-EFFORT — a provisioning hiccup # - # (mirror down, upstream base moved) must never block the release of # - # the four first-class platforms. The artifact is also UNTESTED (no # - # FreeBSD to run `make test` on); it is validated only as a correct # - # FreeBSD ELF. If the leg fails or is skipped, publish simply omits # - # the FreeBSD asset. # + # This leg is REQUIRED, not best-effort. It used to be # + # continue-on-error with publish deliberately excluding it from the # + # gate, so a FreeBSD compile failure produced a green release that # + # silently shipped without the FreeBSD asset. That is precisely how # + # the MNT_NODEV break stayed invisible after merge (#402). A broken # + # FreeBSD build is now a broken release. PR CI cross-compiles for # + # FreeBSD on every pull request, so a code break is caught before it # + # can ever reach this point. # # ================================================================== # build-freebsd: name: Build -- freebsd-x86_64 (cross) needs: tag - continue-on-error: true if: | always() && ( (needs.tag.result == 'success' && needs.tag.outputs.tag != '') || @@ -548,13 +549,14 @@ jobs: # ================================================================== # publish: name: Publish release - # build-freebsd is in `needs` only so publish waits for its artifact to be - # available — it is NOT in the `if` gate, so a failed/skipped FreeBSD leg - # (continue-on-error) never blocks the release; publish just omits the asset. + # build-freebsd is in the `if` gate, not just in `needs`: a release that + # quietly drops a platform because its build broke is worse than no + # release, and dropping it silently is what hid the last FreeBSD break. needs: [tag, build, build-freebsd] runs-on: ubuntu-latest if: | - always() && needs.build.result == 'success' && ( + always() && needs.build.result == 'success' + && needs.build-freebsd.result == 'success' && ( (needs.tag.result == 'success' && needs.tag.outputs.tag != '') || startsWith(github.ref, 'refs/tags/') ) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c067de..5e49bdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `main`, the release pipeline automatically replaces `[current]` with the next version number before tagging the release. +## [current] + +### Added + +- **FreeBSD is a real CI target** (#402), split by what each check can catch. + Every pull request cross-compiles the toolchain for FreeBSD (`FREEBSD=1`, + zig cc against a pinned FreeBSD base sysroot): a compile break can only be + caught by compiling, and this is fast and deterministic. Every merge to main + additionally builds the tree and runs the C unit suite inside a native + FreeBSD VM, which is what covers runtime divergence (the kqueue poller) and + costs too much to put in front of every pull request. Both hard-fail. Until + now the only FreeBSD compile in the tree lived in the release workflow, so + nothing read the FreeBSD branch of any `#ifdef` before code landed. `make ci` + cannot cover this on its own: it compiles only the branch of each platform + conditional that matches the host it runs on. + +### Changed + +- **A failed FreeBSD build now fails the release** instead of silently shipping + without the FreeBSD asset. The leg was `continue-on-error` and deliberately + left out of the publish gate, so a broken FreeBSD build produced a green + release with a platform quietly missing. That is what kept the `MNT_NODEV` + break invisible after it merged. +- **Mount options are built from a table of the flags the platform actually + defines**, rather than a fixed format string with an empty-string substitute + for whatever is missing. A flag absent from the OS is absent from the table, + so FreeBSD (which removed `MNT_NODEV` in 10, where nodev became a no-op) + reports no nodev state instead of reporting it as off. + +### Removed + +- **The optional-macro portability probe** (`make ci-optional-macros`), added + one release ago to simulate a missing `MNT_NODEV` by preprocessing the source + and recompiling it. Compiling for FreeBSD in CI supersedes it: the probe + approximated one platform through a hand-maintained list of file/macro/anchor + triples that every future guard had to be added to by hand, and its awk + line-insertion and its hardcoded `-std=` both diverged from the real build + before it caught anything. `make ci` is back to 9 steps. + +### Fixed + +- `fs_is_socket` and `os_user_id_raw` (#1368) were defined without declarations + in `std/fs/aether_fs.h` and `std/os/aether_os.h`, the only functions in + either module missing a prototype. Documented the new stat kinds, + `fs.fs_is_socket` and `os.user_id()` in `docs/stdlib-reference.md`, where the + kind encoding still described only kinds 1 through 4. + ## [0.473.0] ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4884655..705014bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -244,7 +244,7 @@ Keep checked-in Aether source canonically formatted; CI enforces this Run the full CI suite locally, this is the same suite that GitHub Actions runs: ```bash -make ci # Full 10-step suite with -Werror (compiler, tests, examples, smoke tests) +make ci # Full 9-step suite with -Werror (compiler, tests, examples, smoke tests) HARDEN=1 make ci # Hardened-build sweep (-fstack-protector-all + _FORTIFY_SOURCE=2); # required for any PR that touches C in compiler/, runtime/, or std/. # Catches unchecked memcpy / printf-format-injection bugs early. diff --git a/Makefile b/Makefile index 45eaaf65..3639afc0 100644 --- a/Makefile +++ b/Makefile @@ -2118,41 +2118,38 @@ ci: clean @echo " Parallel: $(NPROC) jobs (build) / $(NPROC) (.ae tests) / $${SH_NPROC:-1} (shell tests)" @echo "===================================" @echo "" - @echo "[0/10] Restoring miniaudio object cache (if valid)..." + @echo "[0/9] Restoring miniaudio object cache (if valid)..." @$(MAKE) audio-cache-restore @echo "" - @echo "[1/10] Building compiler (-Werror)..." + @echo "[1/9] Building compiler (-Werror)..." @$(MAKE) -j$(NPROC) compiler EXTRA_CFLAGS=-Werror @$(MAKE) audio-cache-save @echo "" - @echo "[2/10] Building ae CLI..." + @echo "[2/9] Building ae CLI..." @$(MAKE) -j$(NPROC) ae @echo "" - @echo "[3/10] Building stdlib..." + @echo "[3/9] Building stdlib..." @$(MAKE) -j$(NPROC) stdlib @echo "" - @echo "[4/10] Running C unit tests..." + @echo "[4/9] Running C unit tests..." @$(MAKE) -j$(NPROC) test @echo "" - @echo "[5/10] Running .ae integration tests..." + @echo "[5/9] Running .ae integration tests..." @$(MAKE) test-ae @echo "" - @echo "[6/10] Building examples..." + @echo "[6/9] Building examples..." @$(MAKE) examples @echo "" - @echo "[7/10] Install smoke test..." + @echo "[7/9] Install smoke test..." @$(MAKE) test-install @echo "" - @echo "[8/10] ae test smoke check..." + @echo "[8/9] ae test smoke check..." @AETHER_HOME="" ./build/ae test examples/basics/hello.ae 2>&1 | tail -1 @echo " [PASS] ae test runs correctly" @echo "" - @echo "[9/10] Release archive smoke test..." + @echo "[9/9] Release archive smoke test..." @$(MAKE) test-release-archive @echo "" - @echo "[10/10] Optional-macro portability probe..." - @$(MAKE) ci-optional-macros - @echo "" @echo "===================================" @echo " CI PASSED — all checks green" @echo "===================================" @@ -2261,7 +2258,7 @@ asan-check: clean fi @echo "✓ ASan clean — no memory errors detected" -.PHONY: ci-optional-macros all compiler lsp apkg ae profiler docgen docs-server docs docs-serve test test-build test-valgrind test-asan test-macos-leaks test-memory test-manual-runtime test-cross test-install test-release-archive benchmark benchmark-ui examples run compile repl clean help self-test install stats stdlib stdlib-asan stdlib-memory stdlib-dbg ci ci-windows docker-ci docker-ci-windows docker-build-ci valgrind-check asan-check ci-coop ci-wasm ci-embedded ci-portability docker-ci-wasm docker-ci-embedded contrib-host-check contrib install-contrib stdlib-cov ci-coverage ci-coverage-clean ci-coverage-html +.PHONY: all compiler lsp apkg ae profiler docgen docs-server docs docs-serve test test-build test-valgrind test-asan test-macos-leaks test-memory test-manual-runtime test-cross test-install test-release-archive benchmark benchmark-ui examples run compile repl clean help self-test install stats stdlib stdlib-asan stdlib-memory stdlib-dbg ci ci-windows docker-ci docker-ci-windows docker-build-ci valgrind-check asan-check ci-coop ci-wasm ci-embedded ci-portability docker-ci-wasm docker-ci-embedded contrib-host-check contrib install-contrib stdlib-cov ci-coverage ci-coverage-clean ci-coverage-html # Cross-language benchmark UI (alias for benchmark) benchmark-ui: benchmark @@ -2561,46 +2558,6 @@ docker-ci-embedded: docker run --rm -v $(PWD):/aether -w /aether aether-embedded make ci-embedded # Run ALL portability checks (native coop + Docker WASM + Docker embedded) -# Optional-macro portability probe. Some platform macros this tree keys on -# exist on one BSD and not another: FreeBSD 10 removed MNT_NODEV while macOS -# and OpenBSD still define it, and that difference broke the FreeBSD build -# after passing macOS CI, because the fallback path had never been compiled -# anywhere. This target compiles the affected sources with each such macro -# forced absent, so both sides of every #ifdef are built on every run. -# -# The undef goes before the LAST match of the anchor, not the first: these -# files carry a no-op stub of the same function for platforms without the -# feature, and that stub sits ABOVE the system include that defines the -# macro, so undefining there would be silently reversed by the include. -# -# Add a line here whenever you guard a new optional platform macro. -ci-optional-macros: - @echo "=== Optional-macro portability probe ===" - @mkdir -p build/portability - @fail=0; \ - for probe in "std/fs/aether_fs.c:MNT_NODEV:fs_try_mounts(void) {"; do \ - src=$${probe%%:*}; rest=$${probe#*:}; \ - macro=$${rest%%:*}; anchor=$${rest#*:}; \ - out="build/portability/$$(basename $$src)"; \ - awk -v a="$$anchor" -v m="$$macro" \ - 'NR==FNR { if (index($$0, a)) last=FNR; next } \ - FNR==last { print "#undef " m } { print }' \ - "$$src" "$$src" > "$$out"; \ - printf ' %-28s without %s ... ' "$$(basename $$src)" "$$macro"; \ - if $(CC) -std=gnu11 -Werror -fsyntax-only \ - -I"$$(dirname $$src)" $(CFLAGS) "$$out" \ - 2>build/portability/err.log; then \ - echo "OK"; \ - else \ - echo "FAILED"; sed 's/^/ /' build/portability/err.log; fail=1; \ - fi; \ - done; \ - if [ $$fail -ne 0 ]; then \ - echo " A guarded platform macro's fallback path does not compile."; \ - exit 1; \ - fi - @echo " All optional-macro fallback paths compile." - ci-portability: ci-coop docker-ci-wasm docker-ci-embedded @echo "" @echo "===================================" diff --git a/README.md b/README.md index ff6f52c3..74597c32 100644 --- a/README.md +++ b/README.md @@ -386,7 +386,7 @@ Same file is config, validation, conditional logic, and the entry point. No seco ### Running Tests ```bash -# Full CI suite (10 steps, -Werror), runs on your current platform +# Full CI suite (9 steps, -Werror), runs on your current platform make ci # Unit tests only (runtime C test suite) diff --git a/docs/stdlib-reference.md b/docs/stdlib-reference.md index fcad94d0..c72d4989 100644 --- a/docs/stdlib-reference.md +++ b/docs/stdlib-reference.md @@ -803,7 +803,7 @@ main() { - `dir.list(path)` → `(ptr, string)` - List contents (caller must `dir.list_free`) - `dir.list_count(list)` → `int` - Number of entries - `dir.list_get(list, index)` → `string` - Entry name at `index` -- `dir.list_kind(list, index)` → `int` - Entry's file kind from readdir's `d_type`, avoiding a `stat(2)` per entry: 1 = file, 2 = directory, 3 = symlink (target not followed), 4 = other; 0 = unknown (the filesystem didn't report a type, stat that entry to resolve it). Same encoding as `file_stat`'s kind. +- `dir.list_kind(list, index)` → `int` - Entry's file kind from readdir's `d_type`, avoiding a `stat(2)` per entry: 1 = file, 2 = directory, 3 = symlink (target not followed), 4 = other, 5 = socket, 6 = FIFO, 7 = device; 0 = unknown (the filesystem didn't report a type, stat that entry to resolve it). Same encoding as `file_stat`'s kind. Kinds 5-7 are POSIX-only: Windows reports those nodes as 4. The named constants `fs.STAT_KIND_FILE`, `STAT_KIND_DIR`, `STAT_KIND_SYMLINK`, `STAT_KIND_OTHER`, `STAT_KIND_SOCKET`, `STAT_KIND_FIFO` and `STAT_KIND_DEVICE` are exported from `std.fs`, prefer them over the bare numbers. - `dir.exists(path)` - 1 if a **directory** is at `path`, 0 otherwise. Returns 0 for regular files, even if they exist, see `fs.exists` for the path-agnostic check. - `dir.list_free(list)` - Free directory listing @@ -818,7 +818,8 @@ import std.fs // Walk: the callback sees every entry with its kind and depth. n, err = fs.walk(root, |path: string, kind: int, depth: int| { - // kind: 1 file / 2 dir / 3 symlink / 4 other (same as file_stat) + // kind: 1 file / 2 dir / 3 symlink / 4 other / 5 socket / 6 fifo / 7 device + // (same encoding as file_stat; see fs.STAT_KIND_*) if kind == 2 && string.ends_with(path, "/node_modules") == 1 { return 1 // skip this subtree } @@ -899,7 +900,8 @@ main() { - `fs.rename(from, to)` → `string` - POSIX `rename(2)` wrapper. Atomic when source and target are on the same filesystem. - `fs.create_dir_with_mode(path, mode)` → `string` - Like `fs.create_dir` but takes an explicit POSIX mode (0777-masked). Use this for private dirs (e.g. `0o700` for keys), sets the bits at creation time, closing the `mkdir` → `chmod` race window. Windows ignores the mode at the directory layer; the parameter is accepted for portability. - `fs.mtime(path)` → `(int, string)` - File's mtime as Unix epoch seconds, in the standard `(value, err)` shape. Distinguishes "stat failed" from "file's mtime is 0 (1970 epoch)", the older `file_mtime` extern collapsed both into a single 0 sentinel and is kept only for back-compat. -- `fs.file_stat(path)` → `(kind, size, mtime, err)` - One `lstat(2)`; symlinks report kind 3, target is not followed. +- `fs.file_stat(path)` → `(kind, size, mtime, err)` - One `lstat(2)`; symlinks report kind 3, target is not followed. Kind is 1 = file, 2 = directory, 3 = symlink, 4 = other, 5 = socket, 6 = FIFO, 7 = device (char and block devices share 7). Sockets, FIFOs and devices are distinguished on POSIX only; Windows reports them as 4. Use the `fs.STAT_KIND_*` constants rather than the literals. +- `fs.fs_is_socket(path)` → `int` - 1 if `path` is a UNIX-domain socket, 0 otherwise (including when nothing is there). Unlike `fs.fs_is_symlink` this **follows** symlinks, since a caller asking "is this a socket" wants the target. POSIX only; always 0 on Windows. Pair it with `os.user_id()` to locate a per-user runtime socket such as `/run/user/${os.user_id()}/podman/podman.sock`. - `fs.read_binary(path)` → `(content, length, err)` - Length-aware read preserving embedded NULs. ### Structured-error pilot @@ -1934,6 +1936,7 @@ main() { - `os.setenv(name, value)` → `string` - Set environment variable, returns "" on success or an error string. Same C-side function as `io.setenv` use `os.setenv` when you've already imported `std.os` for `os.getenv`. - `os.unsetenv(name)` → `string` - Unset environment variable, returns "" on success or an error string. Same C-side function as `io.unsetenv`. - `os.getpid()` → `int` - Process identifier of the current process. POSIX `getpid(2)`; Windows `_getpid()`. Useful for tmpfile names (`/tmp/myprog.${os.getpid()}.tmp`), per-process locks, log prefixes, and stable tagging across forked children. Returns 0 on platforms compiled without filesystem support. +- `os.user_id()` → `int` - Effective user id of the calling process (POSIX `geteuid(2)`). Windows has no numeric uid model and returns -1, so treat any negative result as "unavailable" rather than as a uid. Mainly for building per-user runtime paths like `/run/user/${os.user_id()}/`. - `os.now_utc_iso8601()` → `string` - Current UTC time as ISO-8601 (`YYYY-MM-DDThh:mm:ssZ`). Returns `""` (never null) on clock/format failure. Thread-safe. - `os.wall_seconds()` → `long` - Whole seconds since the Unix epoch (POSIX `gettimeofday`; Windows `GetSystemTimeAsFileTime`). NTP-jumpable, pair with `wall_micros` for sub-second precision, or use the monotonic accessors below for elapsed-time measurements. - `os.wall_micros()` → `int` - Sub-second microsecond fraction (0..999999) from the same `struct timeval` as `wall_seconds`. diff --git a/std/fs/aether_fs.c b/std/fs/aether_fs.c index 8fc21a9d..40c50854 100644 --- a/std/fs/aether_fs.c +++ b/std/fs/aether_fs.c @@ -1171,26 +1171,40 @@ int fs_try_mounts(void) { * this body would not compile. It falls through to the unsupported * branch and reports the error rather than shipping a shape nobody * has built, which is how the MNT_NODEV break below reached CI. */ + /* The optional mount flags this platform actually has. A flag is listed + * only where the OS defines it, so the table IS the platform's flag set + * rather than a full list with substitutes for the missing entries. + * MNT_NODEV is the reason this is a table: nodev became a no-op on + * FreeBSD and the macro was removed in FreeBSD 10, so a FreeBSD mount + * has no nodev state and must report none, while macOS and OpenBSD + * still carry it. MNT_RDONLY is not here because it is not optional: + * every entry reports ro or rw. */ + static const struct { unsigned long long mask; const char* name; } + k_optional_mount_flags[] = { + { MNT_NOSUID, ",nosuid" }, +#ifdef MNT_NODEV + { MNT_NODEV, ",nodev" }, +#endif + { MNT_NOEXEC, ",noexec" }, + }; struct statfs* mntbuf = NULL; int n = getmntinfo(&mntbuf, MNT_NOWAIT); if (n <= 0) return -1; for (int i = 0; i < n; i++) { - /* MNT_NODEV is not universal: FreeBSD deprecated it (it became a - * no-op) and removed the macro in FreeBSD 10, while macOS and - * OpenBSD still define it. Probe the macro, not the platform, so - * a future removal elsewhere degrades to omitting the flag - * instead of failing the build. */ -#ifdef MNT_NODEV - const char* nodev_opt = (mntbuf[i].f_flags & MNT_NODEV) ? ",nodev" : ""; -#else - const char* nodev_opt = ""; -#endif char opts[128]; - snprintf(opts, sizeof(opts), "%s%s%s%s", - (mntbuf[i].f_flags & MNT_RDONLY) ? "ro" : "rw", - (mntbuf[i].f_flags & MNT_NOSUID) ? ",nosuid" : "", - nodev_opt, - (mntbuf[i].f_flags & MNT_NOEXEC) ? ",noexec" : ""); + int used = snprintf(opts, sizeof(opts), "%s", + (mntbuf[i].f_flags & MNT_RDONLY) ? "ro" : "rw"); + if (used < 0) return -1; + for (size_t k = 0; + k < sizeof(k_optional_mount_flags) / sizeof(k_optional_mount_flags[0]); + k++) { + if (!((unsigned long long)mntbuf[i].f_flags & k_optional_mount_flags[k].mask)) + continue; + int w = snprintf(opts + used, sizeof(opts) - (size_t)used, "%s", + k_optional_mount_flags[k].name); + if (w < 0 || (size_t)w >= sizeof(opts) - (size_t)used) break; + used += w; + } if (!fs_mounts_append(mntbuf[i].f_mntfromname, mntbuf[i].f_mntonname, mntbuf[i].f_fstypename, opts)) { fs_release_mounts(); diff --git a/std/fs/aether_fs.h b/std/fs/aether_fs.h index d173a299..66c7dbe5 100644 --- a/std/fs/aether_fs.h +++ b/std/fs/aether_fs.h @@ -81,12 +81,19 @@ int fs_mkdir_p_raw(const char* path); // follow), 0 otherwise. Pure boolean query — no wrapper // needed, matches file_exists / dir_exists shape. // +// fs_is_socket: returns 1 if `path` is a UNIX-domain socket, 0 +// otherwise (including when the path does not exist). +// DOES follow symlinks, unlike fs_is_symlink: a caller +// asking "is this a socket" wants the target. POSIX +// only; returns 0 on Windows. +// // fs_unlink_raw: remove a file or symlink. Will NOT remove a directory // — use dir_delete_raw for that. Returns 1 on success, // 0 on failure. int fs_symlink_raw(const char* target, const char* link_path); char* fs_readlink_raw(const char* path); int fs_is_symlink(const char* path); +int fs_is_socket(const char* path); int fs_unlink_raw(const char* path); // Non-atomic binary write to `path` — opens "wb", writes exactly diff --git a/std/os/aether_os.h b/std/os/aether_os.h index 51b35b3c..10bbee8a 100644 --- a/std/os/aether_os.h +++ b/std/os/aether_os.h @@ -149,6 +149,12 @@ char* os_platform_raw(void); // _getpid(). Returns 0 on platforms without filesystem (no-op stub). int os_getpid_raw(void); +// Effective user id of the calling process. POSIX geteuid(2). Windows +// has no numeric uid model, so it returns -1 there and on +// no-filesystem builds; callers treat a negative result as +// "unavailable" rather than as a real uid. +int os_user_id_raw(void); + // Wall-clock time as the two fields of POSIX struct timeval: whole // seconds since the Unix epoch, and the sub-second microsecond // fraction (0..999999). POSIX gettimeofday(2); Windows