this repo has no description
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Phase 5.1 + Phase 6: Directory Services stubs, NixOS VM tests, flake checks

Phase 5.1 — Directory Services stubs:
- dseditgroup: group create/edit/delete/checkmember/read (src/dirserv/)
- sysadminctl: addUser/deleteUser with UID/GID/home/shell (src/dirserv/)
- dscl: read/list/create/delete/append/search on /Users and /Groups (src/dirserv/)
- All operations are idempotent with input validation
- Wired into CMake build, installs to libexec/darling/usr/sbin/
- 78-test regression suite (tests/dirserv/test_dirserv.sh)

Phase 6.1 — NixOS VM integration test (tests/nix-in-darling.nix):
- 7 stages: Darling boot, sandbox-exec, dirserv stubs, Nix install,
core commands, currentSystem verification, trivial derivation builds

Phase 6.6 — Darling smoke test (tests/darling-smoke.nix):
- Lightweight NixOS VM test (no network), 8 stages covering shell,
macOS identity, filesystem, sandbox-exec, diskutil, dirserv stubs

Phase 6.2 — Wired tests into flake.nix:
- checks: darling-build, darling-smoke, nix-in-darling, dirserv-stubs
- dirserv-stubs runs as pure shell test (no Darling needed)

Also: updated run-tests.sh with dirserv suite (6 suites total),
updated PLAN.md and plan docs with task completions.

+3091 -30
+3
.gitignore
··· 33 33 tests/* 34 34 !tests/sandbox/ 35 35 !tests/syscall/ 36 + !tests/dirserv/ 37 + !tests/darling-smoke.nix 38 + !tests/nix-in-darling.nix 36 39 37 40 # The suggested build folder 38 41 build
+61 -7
PLAN.md
··· 17 17 | Phase 2 — Sandbox | ✅ Done | `src/sandbox/sandbox.c` (fixed), `src/sandbox-exec/` (new), `tests/sandbox/` (new) | 18 18 | Phase 3 — Nix Install | 🚧 In progress | `scripts/install-nix-in-darling.sh`, `scripts/darling-nix`, `scripts/verify-nix.sh` | 19 19 | Phase 4 — Building | 🚧 Tooling ready | `scripts/build-trivial.sh` (new) | 20 - | Phase 5 — Daemon | 📋 Planned | — | 21 - | Phase 6 — CI | 🚧 In progress | `.github/workflows/nix.yml` (new) | 20 + | Phase 5 — Daemon | 🚧 Stubs done | `src/dirserv/` (new), `tests/dirserv/` (new) | 21 + | Phase 6 — CI | 🚧 In progress | `.github/workflows/nix.yml`, `tests/darling-smoke.nix`, `tests/nix-in-darling.nix` (new) | 22 22 | Phase 7 — Remote Builder | 📋 Planned | — | 23 23 | Phase 8 — Stretch | 📋 Planned | — | 24 24 25 25 ### Recently Completed 26 26 27 + - **Phase 5.1 — Directory Services stubs**: Created `src/dirserv/` with three 28 + shell-script stubs (`dseditgroup`, `sysadminctl`, `dscl`) that translate 29 + macOS Directory Services commands to direct `/etc/passwd` and `/etc/group` 30 + file operations within the Darling prefix. These are required by the Nix 31 + multi-user installer to create the `nixbld` group and `_nixbldN` build users. 32 + - `dseditgroup`: create/delete/edit groups, add/remove members, checkmember, 33 + read group info. Idempotent operations, input validation. 34 + - `sysadminctl`: addUser/deleteUser with UID, GID, home, shell, fullName. 35 + Handles `-password`, `-adminUser`, `-roleAccount` flags (ignored). Idempotent. 36 + - `dscl`: read/list/create/delete/append/search on `/Users` and `/Groups` 37 + paths. Supports `.` and `/Local/Default` datasources. Full key coverage 38 + for Nix installer needs (UniqueID, PrimaryGroupID, NFSHomeDirectory, 39 + UserShell, RealName, GroupMembership, etc.). 40 + - Wired into CMake build via `src/dirserv/CMakeLists.txt`; installs to 41 + `libexec/darling/usr/sbin/`. 42 + - Comprehensive test suite: `tests/dirserv/test_dirserv.sh` with 60+ tests 43 + covering all three tools individually and a full Nix installer simulation 44 + (create group → create 5 build users → add to group → verify → idempotent 45 + re-run → cleanup). 46 + - **Phase 6.1 — NixOS VM test**: Created `tests/nix-in-darling.nix` — full 47 + end-to-end NixOS VM integration test that boots Darling, verifies the shell, 48 + sandbox-exec, Directory Services stubs, installs Nix, tests core commands 49 + (version, eval, store verify), confirms `builtins.currentSystem == 50 + x86_64-darwin`, and builds trivial derivations. 51 + - **Phase 6.6 — Darling smoke test**: Created `tests/darling-smoke.nix` — 52 + lightweight NixOS VM test (no network required) that verifies Darling boots, 53 + shell works, macOS identity is correct, filesystem operations work, 54 + sandbox-exec/diskutil/Directory Services stubs are functional, and no 55 + unimplemented syscall warnings appear during basic operations. 56 + - **Phase 6.2 — Wired tests into `flake.nix`**: Added `checks` output with 57 + three entries: `darling-build` (package builds), `darling-smoke` (VM smoke 58 + test), `nix-in-darling` (full integration test), and `dirserv-stubs` (pure 59 + shell unit test for Directory Services stubs, runnable without Darling). 60 + - **Test runner updated**: Added `dirserv` suite to `scripts/run-tests.sh` 61 + (now 6 suites total). 27 62 - **Test runner**: Created `scripts/run-tests.sh` — unified test runner that 28 63 copies all regression test sources into a Darling prefix, compiles C suites 29 64 with the macOS toolchain, executes them (and shell-based suites), and ··· 138 173 │ ├── build-trivial.sh # NEW — Progressive derivation build tests (Phase 4) 139 174 │ ├── darling-nix # Host-side Nix command wrapper (Phase 3) 140 175 │ ├── install-nix-in-darling.sh # Automated Nix installer (Phase 3) 141 - │ ├── run-tests.sh # NEW — Unified regression test runner 176 + │ ├── run-tests.sh # NEW — Unified regression test runner (6 suites) 142 177 │ ├── triage-syscalls.sh # Automated syscall triage (Phase 1) 143 178 │ └── verify-nix.sh # NEW — Standalone Nix health-check (Phase 3) 144 179 ├── src/ 180 + │ ├── dirserv/ # NEW — Directory Services stubs (Phase 5) 181 + │ │ ├── CMakeLists.txt 182 + │ │ ├── dscl # dscl stub (read/list/create/delete/append/search) 183 + │ │ ├── dseditgroup # dseditgroup stub (create/edit/delete/checkmember/read) 184 + │ │ └── sysadminctl # sysadminctl stub (addUser/deleteUser) 145 185 │ ├── sandbox/sandbox.c # Fixed sandbox API stubs (Phase 2) 146 186 │ ├── sandbox-exec/ # NEW — sandbox-exec stub (Phase 2) 147 187 │ │ ├── CMakeLists.txt 148 188 │ │ └── sandbox-exec.c 149 189 │ └── diskutil/diskutil # Extended with info/list verbs (Phase 3) 150 190 ├── tests/ 191 + │ ├── darling-smoke.nix # NEW — NixOS VM smoke test (Phase 6.6) 192 + │ ├── nix-in-darling.nix # NEW — NixOS VM integration test (Phase 6.1) 193 + │ ├── dirserv/ # NEW — Directory Services regression tests 194 + │ │ └── test_dirserv.sh # 60+ tests for dseditgroup/sysadminctl/dscl 151 195 │ ├── sandbox/ # NEW — sandbox regression tests 152 196 │ │ ├── test_sandbox_api.c # C-level sandbox API tests 153 197 │ │ └── test_sandbox_exec.sh # Shell-level sandbox-exec tests ··· 194 238 - `utimensat` — utimensat/setattrlistat timestamps (16 tests) 195 239 - `sandbox_api` — sandbox C API stubs 196 240 - `sandbox_exec` — sandbox-exec integration (20 tests) 241 + - `dirserv` — Directory Services stubs (60+ tests) 197 242 198 243 2. **Phase 1.7 — Live triage**: Run `scripts/triage-syscalls.sh` inside a 199 244 Darling prefix with Nix installed to discover any remaining unimplemented ··· 259 304 | 3.1 | ✅ | `install-nix-in-darling.sh` installer script | 260 305 | 3.3 | ✅ | `verify-nix.sh` standalone verification | 261 306 | 3.4 | ✅ | `darling-nix` host-side wrapper | 307 + | 3.5 | ✅ | Channel/registry setup (integrated into installer step 6) | 262 308 | 4.1 | ✅ | `build-trivial.sh` progressive build tests (tooling ready) | 309 + | 5.1 | ✅ | Directory Services stubs (`dseditgroup`, `sysadminctl`, `dscl`) | 310 + | 6.1 | ✅ | NixOS VM test (`tests/nix-in-darling.nix`) | 311 + | 6.2 | ✅ | Wired tests into `flake.nix` (checks output) | 263 312 | 6.3 | ✅ | `.github/workflows/nix.yml` CI workflow | 264 - | — | ✅ | `run-tests.sh` unified test runner | 313 + | 6.6 | ✅ | Darling smoke test (`tests/darling-smoke.nix`) | 314 + | — | ✅ | `run-tests.sh` unified test runner (6 suites) | 265 315 | — | ✅ | `getattrlist` attribute buffer ordering bug fixed | 266 316 | — | ✅ | `diskutil info`/`list` stubs | 317 + | — | ✅ | `dirserv-stubs` pure shell check (runnable without Darling) | 267 318 268 - ### Script Quick Reference 319 + ### Script & Test Quick Reference 269 320 270 - | Script | Purpose | When to Use | 271 - |--------|---------|-------------| 321 + | Script / Test | Purpose | When to Use | 322 + |---------------|---------|-------------| 272 323 | `scripts/run-tests.sh` | Compile & run all regression tests inside Darling | After building Darling with changes | 273 324 | `scripts/triage-syscalls.sh` | Discover unimplemented syscalls during Nix operations | After installing Nix inside Darling | 274 325 | `scripts/install-nix-in-darling.sh` | Install Nix package manager inside a Darling prefix | One-time setup | 275 326 | `scripts/verify-nix.sh` | Health-check a Nix installation inside Darling | After install, or to diagnose regressions | 276 327 | `scripts/darling-nix` | Run Nix commands inside Darling from the host | Day-to-day Nix usage | 277 328 | `scripts/build-trivial.sh` | Test derivation building with 5 progressive levels | After Nix is installed and verified | 329 + | `nix build .#checks.x86_64-linux.dirserv-stubs` | Run Directory Services stub tests (no Darling needed) | After editing `src/dirserv/` | 330 + | `nix build .#checks.x86_64-linux.darling-smoke -L` | NixOS VM smoke test (no network) | After building Darling | 331 + | `nix build .#checks.x86_64-linux.nix-in-darling -L` | Full Nix-in-Darling integration test | End-to-end validation | 278 332 279 333 See [plan/README.md](./plan/README.md) for the full priority table and effort 280 334 estimates.
+65
flake.nix
··· 28 28 # NixOS module is autoloaded from ./nix/nixosModule.nix 29 29 30 30 packages.darling-sdk = pkgs: pkgs.darling.sdk; 31 + 32 + # ── Checks (Phase 6.2) ─────────────────────────────────────────── 33 + # 34 + # NixOS VM integration tests and lightweight validation checks. 35 + # Run with: 36 + # nix flake check # all checks 37 + # nix build .#checks.x86_64-linux.darling-smoke -L 38 + # nix build .#checks.x86_64-linux.nix-in-darling -L 39 + # 40 + # See: plan/08-phase6-ci.md (Tasks 6.1, 6.2) 41 + checks = pkgs: 42 + let 43 + darling = pkgs.darling; 44 + in 45 + { 46 + # ── Build check ───────────────────────────────────────────────── 47 + # Ensure the package builds successfully. This is redundant with 48 + # `packages.default` but makes `nix flake check` self-contained. 49 + darling-build = darling; 50 + 51 + # ── Darling smoke test (Phase 6.6) ────────────────────────────── 52 + # Lightweight NixOS VM test: boots Darling, verifies shell, 53 + # sandbox-exec, diskutil, and Directory Services stubs. 54 + # No network access required — completes in a few minutes. 55 + darling-smoke = import ./tests/darling-smoke.nix { 56 + inherit pkgs darling; 57 + }; 58 + 59 + # ── Nix-in-Darling integration test (Phase 6.1) ──────────────── 60 + # Full end-to-end test: installs Nix inside Darling, verifies 61 + # core commands, evaluator, currentSystem, and trivial builds. 62 + # Requires network access (downloads Nix installer + store paths). 63 + nix-in-darling = import ./tests/nix-in-darling.nix { 64 + inherit pkgs darling; 65 + }; 66 + 67 + # ── Directory Services stubs unit test ────────────────────────── 68 + # Runs the shell-based test suite for dseditgroup, sysadminctl, 69 + # and dscl stubs on the host (no Darling needed — pure shell). 70 + dirserv-stubs = pkgs.runCommandLocal "dirserv-stubs-test" { 71 + nativeBuildInputs = with pkgs; [ 72 + coreutils 73 + gawk 74 + gnugrep 75 + gnused 76 + findutils 77 + ]; 78 + } '' 79 + # The test script uses sed to rewrite /etc/passwd and /etc/group 80 + # paths to temp files, so it's safe to run outside Darling. 81 + # We create a directory layout that matches what the test expects: 82 + # <workdir>/tests/dirserv/test_dirserv.sh 83 + # <workdir>/src/dirserv/{dseditgroup,sysadminctl,dscl} 84 + workdir=$(mktemp -d) 85 + mkdir -p "$workdir/tests/dirserv" "$workdir/src/dirserv" 86 + cp ${./tests/dirserv/test_dirserv.sh} "$workdir/tests/dirserv/test_dirserv.sh" 87 + cp ${./src/dirserv/dseditgroup} "$workdir/src/dirserv/dseditgroup" 88 + cp ${./src/dirserv/sysadminctl} "$workdir/src/dirserv/sysadminctl" 89 + cp ${./src/dirserv/dscl} "$workdir/src/dirserv/dscl" 90 + chmod +x "$workdir/src/dirserv"/* 91 + export HOME=$(mktemp -d) 92 + sh "$workdir/tests/dirserv/test_dirserv.sh" 93 + touch $out 94 + ''; 95 + }; 31 96 }; 32 97 }
+58 -15
plan/07-phase5-daemon.md
··· 35 35 36 36 ## Tasks 37 37 38 - ### 5.1 — Implement Directory Services Stubs 38 + ### 5.1 — Implement Directory Services Stubs ✅ 39 + 40 + > **Status**: Complete. Implemented as shell scripts in `src/dirserv/` 41 + > (`dseditgroup`, `sysadminctl`, `dscl`). Installed to 42 + > `libexec/darling/usr/sbin/` via CMake. Comprehensive test suite at 43 + > `tests/dirserv/test_dirserv.sh` (60+ tests). Pure-shell check available 44 + > via `nix build .#checks.x86_64-linux.dirserv-stubs`. 39 45 40 46 The Nix installer uses these commands to create build users and groups: 41 47 ··· 57 63 58 64 #### `dseditgroup` stub 59 65 60 - Create `src/tools/dseditgroup` (or a shell script installed to 61 - `libexec/darling/usr/sbin/dseditgroup`) that handles: 66 + Implemented at `src/dirserv/dseditgroup` — a POSIX shell script that handles: 62 67 63 68 | Invocation | Translation | 64 69 |---|---| 65 70 | `dseditgroup -o create -q -i <GID> <name>` | `echo "<name>:x:<GID>:" >> /etc/group` (if not exists) | 66 71 | `dseditgroup -o edit -a <user> -t user <group>` | Append `<user>` to the group's member list in `/etc/group` | 72 + | `dseditgroup -o edit -d <user> -t user <group>` | Remove `<user>` from the group's member list | 67 73 | `dseditgroup -o delete <name>` | Remove the group from `/etc/group` | 68 74 | `dseditgroup -o checkmember -m <user> <group>` | Check if user is in the group; exit 0 if yes, non-zero if no | 75 + | `dseditgroup -o read <name>` | Print group info in Apple-style key-value format | 69 76 70 - Does not need to support the full `dseditgroup` interface — only what the Nix 71 - installer uses. 77 + Also supports `-q` (quiet) on all operations and auto-assigns GIDs ≥ 30000 if 78 + `-i` is omitted on create. 72 79 73 80 #### `sysadminctl` stub 74 81 75 - Create a stub that handles: 82 + Implemented at `src/dirserv/sysadminctl` — a POSIX shell script that handles: 76 83 77 84 | Invocation | Translation | 78 85 |---|---| 79 86 | `sysadminctl -addUser <name> -UID <uid> -GID <gid> -home <dir> -shell <shell>` | `echo "<name>:x:<uid>:<gid>::<dir>:<shell>" >> /etc/passwd` | 87 + | `sysadminctl -addUser <name> -UID <uid> -GID <gid> ... -fullName <gecos>` | Same, with GECOS field populated | 80 88 | `sysadminctl -deleteUser <name>` | Remove the user from `/etc/passwd` | 89 + 90 + Also silently ignores `-password`, `-adminUser`, `-adminPassword`, and 91 + `-roleAccount` flags (macOS-specific, not meaningful in a Darling prefix). 92 + Auto-assigns UIDs ≥ 300 if `-UID` is omitted. 81 93 82 94 #### `dscl` stub 83 95 84 - The Nix installer may also use `dscl` in some code paths: 96 + Implemented at `src/dirserv/dscl` — a POSIX shell script that handles: 85 97 86 98 | Invocation | Translation | 87 99 |---|---| 88 100 | `dscl . -read /Groups/<name> PrimaryGroupID` | Parse `/etc/group` and print the GID | 89 101 | `dscl . -read /Users/<name> UniqueID` | Parse `/etc/passwd` and print the UID | 90 - | `dscl . -list /Users` | List all usernames from `/etc/passwd` | 91 - | `dscl . -create /Users/<name> ...` | Append to `/etc/passwd` | 102 + | `dscl . -read /Users/<name> [key]` | Print all or specific user record keys | 103 + | `dscl . -read /Groups/<name> [key]` | Print all or specific group record keys | 104 + | `dscl . -list /Users [key]` | List all usernames (optionally with a key column) | 105 + | `dscl . -list /Groups [key]` | List all groups (optionally with a key column) | 106 + | `dscl . -create /Users/<name> [key value]` | Create user or set a key on an existing user | 107 + | `dscl . -create /Groups/<name> [key value]` | Create group or set a key on an existing group | 108 + | `dscl . -delete /Users/<name> [key]` | Delete user record (or clear a key) | 109 + | `dscl . -delete /Groups/<name> [key]` | Delete group record (or clear a key) | 110 + | `dscl . -append /Groups/<name> GroupMembership <user>` | Add member(s) to a group | 111 + | `dscl . -search /Users UniqueID <uid>` | Find user by UID | 112 + | `dscl . -search /Groups PrimaryGroupID <gid>` | Find group by GID | 113 + 114 + Supports `.` and `/Local/Default` as datasources. Keys supported for users: 115 + UniqueID, PrimaryGroupID, RealName, NFSHomeDirectory, UserShell, RecordName, 116 + Password (always locked), IsHidden (ignored), AuthenticationAuthority (stub). 117 + Keys supported for groups: PrimaryGroupID, RecordName, GroupMembership, 118 + GeneratedUID. 92 119 93 120 **Implementation notes**: 94 121 95 122 - These stubs modify files within the Darling prefix (`~/.darling/etc/passwd`, 96 123 `~/.darling/etc/group`), not the host's files. This is safe. 97 - - Do NOT use `useradd`/`groupadd` (those operate on the host). Directly 98 - manipulate the prefix's files. 99 - - Add basic input validation (duplicate detection, numeric ranges). 100 - - Make them idempotent — running the installer twice should not create duplicate 101 - entries. 124 + - They do NOT use `useradd`/`groupadd` (those operate on the host). They 125 + directly manipulate the prefix's files using `awk`/`sed`/`grep`. 126 + - All operations are idempotent — running the installer twice does not create 127 + duplicate entries. 128 + - Input validation: rejects invalid usernames/group names, non-numeric 129 + UID/GID, duplicate UID/GID conflicts. 130 + - Atomic-ish updates: uses `mktemp` + `mv` pattern for file modifications. 131 + - Wired into the CMake build via `src/dirserv/CMakeLists.txt`; installed to 132 + `libexec/darling/usr/sbin/`. 102 133 103 134 **Testing**: 104 135 105 136 ```bash 106 - # Inside darling shell: 137 + # Run the pure-shell test suite (no Darling needed): 138 + nix build .#checks.x86_64-linux.dirserv-stubs -L 139 + 140 + # Or run via the test runner inside Darling: 141 + ./scripts/run-tests.sh --suite dirserv --verbose 142 + 143 + # Manual testing inside darling shell: 107 144 dseditgroup -o create -q -i 30000 nixbld 108 145 grep nixbld /etc/group 109 146 # Expected: nixbld:x:30000: ··· 111 148 sysadminctl -addUser _nixbld1 -UID 300 -GID 30000 -home /var/empty -shell /usr/bin/false 112 149 grep _nixbld1 /etc/passwd 113 150 # Expected: _nixbld1:x:300:30000::/var/empty:/usr/bin/false 151 + 152 + dscl . -read /Groups/nixbld PrimaryGroupID 153 + # Expected: PrimaryGroupID: 30000 154 + 155 + dscl . -search /Users UniqueID 300 156 + # Expected: _nixbld1 ( UniqueID = 300 ) 114 157 ``` 115 158 116 159 ---
+22 -4
plan/08-phase6-ci.md
··· 30 30 31 31 ## Tasks 32 32 33 - ### 6.1 — NixOS VM Test: Nix-in-Darling 33 + ### 6.1 — NixOS VM Test: Nix-in-Darling ✅ 34 + 35 + > **Status**: Complete. Implemented at `tests/nix-in-darling.nix`. Full 36 + > end-to-end NixOS VM test covering 7 stages: Darling boot, sandbox-exec, 37 + > Directory Services stubs, Nix installation, core commands, currentSystem 38 + > verification, and trivial derivation builds. Wired into flake checks as 39 + > `checks.x86_64-linux.nix-in-darling`. 34 40 35 41 Create a NixOS VM test at `tests/nix-in-darling.nix` that exercises the full 36 42 Nix-inside-Darling pipeline end-to-end. ··· 107 113 108 114 --- 109 115 110 - ### 6.2 — Wire Tests into `flake.nix` 116 + ### 6.2 — Wire Tests into `flake.nix` ✅ 117 + 118 + > **Status**: Complete. The `flake.nix` now exposes four checks: 119 + > `darling-build`, `darling-smoke`, `nix-in-darling`, and `dirserv-stubs`. 120 + > Run with `nix flake check` or target individual checks via 121 + > `nix build .#checks.x86_64-linux.<name> -L`. 111 122 112 123 Add the NixOS VM test to the flake's `checks` output: 113 124 ··· 135 146 136 147 --- 137 148 138 - ### 6.3 — GitHub Actions Workflow 149 + ### 6.3 — GitHub Actions Workflow ✅ 139 150 140 151 Replace or supplement the existing `.github/workflows/actions.yaml` with a 141 152 Nix-native workflow. ··· 518 529 519 530 --- 520 531 521 - ### 6.6 — Darling Build Smoke Test 532 + ### 6.6 — Darling Build Smoke Test ✅ 533 + 534 + > **Status**: Complete. Implemented at `tests/darling-smoke.nix`. Lightweight 535 + > NixOS VM test (no network required) covering 8 stages: binary existence, 536 + > shell functionality, macOS identity, filesystem basics, sandbox-exec, 537 + > diskutil, Directory Services stubs, and unimplemented syscall warnings. 538 + > Wired into flake checks as `checks.x86_64-linux.darling-smoke`. 539 + 522 540 523 541 A lighter-weight test that doesn't need a NixOS VM — just verifies Darling 524 542 builds from source with Nix:
+7 -1
plan/README.md
··· 54 54 55 55 | File | Description | 56 56 |---|---| 57 - | `scripts/run-tests.sh` | Unified test runner — compiles and runs all regression tests inside Darling | 57 + | `scripts/run-tests.sh` | Unified test runner — compiles and runs all regression tests inside Darling (6 suites) | 58 58 | `scripts/install-nix-in-darling.sh` | Automated Nix installer for Darling prefixes | 59 59 | `scripts/verify-nix.sh` | Standalone health-check for a Nix installation inside Darling | 60 60 | `scripts/build-trivial.sh` | Progressive derivation build tests (5 levels) for Phase 4 | 61 61 | `scripts/darling-nix` | Host-side wrapper to run Nix commands inside Darling | 62 62 | `scripts/triage-syscalls.sh` | Automated syscall triage — discovers unimplemented syscalls during Nix ops | 63 + | `src/dirserv/dseditgroup` | Directory Services stub — group create/edit/delete/checkmember/read (Phase 5.1) | 64 + | `src/dirserv/sysadminctl` | Directory Services stub — addUser/deleteUser with UID/GID/home/shell (Phase 5.1) | 65 + | `src/dirserv/dscl` | Directory Services stub — read/list/create/delete/append/search (Phase 5.1) | 66 + | `tests/darling-smoke.nix` | NixOS VM smoke test — Darling boot, stubs, filesystem, no network (Phase 6.6) | 67 + | `tests/nix-in-darling.nix` | NixOS VM integration test — full Nix install + eval + build (Phase 6.1) | 68 + | `tests/dirserv/test_dirserv.sh` | Shell-level tests for Directory Services stubs (60+ tests) | 63 69 | `tests/sandbox/test_sandbox_api.c` | C-level regression tests for sandbox API stubs | 64 70 | `tests/sandbox/test_sandbox_exec.sh` | Shell-level tests for the `sandbox-exec` stub binary | 65 71 | `tests/syscall/test_renameatx_np.c` | renameatx_np regression tests (plain rename, SWAP, EXCL, invalid flags) |
+9 -3
scripts/run-tests.sh
··· 12 12 # --prefix <path> Darling prefix path (default: ~/.darling or $DPREFIX) 13 13 # --suite <name> Run only the named suite (can be repeated) 14 14 # Available: renameatx_np, setattrlist_flags, utimensat, 15 - # sandbox_api, sandbox_exec 15 + # sandbox_api, sandbox_exec, dirserv 16 16 # --keep Keep compiled test binaries in the prefix after running 17 17 # --verbose Show full test output even on success 18 18 # --help Show this help message ··· 78 78 --prefix <path> Darling prefix (default: ~/.darling or $DPREFIX) 79 79 --suite <name> Run only the named suite (repeatable) 80 80 Available: renameatx_np, setattrlist_flags, utimensat, 81 - sandbox_api, sandbox_exec 81 + sandbox_api, sandbox_exec, dirserv 82 82 --keep Keep compiled binaries in the prefix after running 83 83 --verbose Show full test output even on success 84 84 --help Show this help ··· 89 89 utimensat — utimensat/setattrlistat timestamps (16 tests) 90 90 sandbox_api — sandbox C API stubs 91 91 sandbox_exec — sandbox-exec integration (shell tests) 92 + dirserv — Directory Services stubs (dseditgroup, sysadminctl, dscl) 92 93 EOF 93 94 exit 0 94 95 } ··· 159 160 SUITE_DESC[sandbox_exec]="sandbox-exec stub — flag parsing, exec, exit codes, Nix patterns" 160 161 SUITE_CFLAGS[sandbox_exec]="" 161 162 162 - ALL_SUITES=(renameatx_np setattrlist_flags utimensat sandbox_api sandbox_exec) 163 + SUITE_TYPE[dirserv]="sh" 164 + SUITE_SOURCE[dirserv]="tests/dirserv/test_dirserv.sh" 165 + SUITE_DESC[dirserv]="Directory Services stubs — dseditgroup, sysadminctl, dscl (Phase 5.1)" 166 + SUITE_CFLAGS[dirserv]="" 167 + 168 + ALL_SUITES=(renameatx_np setattrlist_flags utimensat sandbox_api sandbox_exec dirserv) 163 169 164 170 # ── Determine which suites to run ────────────────────────────────────────── 165 171
+1
src/CMakeLists.txt
··· 293 293 add_subdirectory(clt) 294 294 add_subdirectory(diskutil) 295 295 add_subdirectory(sandbox-exec) 296 + add_subdirectory(dirserv) 296 297 add_subdirectory(ditto) 297 298 298 299 # these aren't used by anything we build (they're just included because they're also present in macOS)
+18
src/dirserv/CMakeLists.txt
··· 1 + project(dirserv) 2 + 3 + # Directory Services command-line stubs for Darling. 4 + # 5 + # These shell scripts translate macOS Directory Services commands 6 + # (dseditgroup, sysadminctl, dscl) to direct /etc/passwd and /etc/group 7 + # file operations within the Darling prefix. They implement only the 8 + # subset needed by the Nix installer and multi-user Nix daemon setup. 9 + # 10 + # See: plan/07-phase5-daemon.md (Task 5.1) 11 + 12 + install(FILES dseditgroup sysadminctl dscl 13 + DESTINATION libexec/darling/usr/sbin 14 + PERMISSIONS 15 + OWNER_READ OWNER_WRITE OWNER_EXECUTE 16 + GROUP_READ GROUP_EXECUTE 17 + WORLD_READ WORLD_EXECUTE 18 + )
+617
src/dirserv/dscl
··· 1 + #!/bin/sh 2 + # dscl — Directory Services command-line stub for Darling 3 + # 4 + # Translates macOS dscl commands to direct /etc/passwd and /etc/group 5 + # file lookups. Only implements the subset needed by the Nix installer 6 + # and common system-management scripts: 7 + # 8 + # dscl . -read /Groups/<name> [<key>] 9 + # dscl . -read /Users/<name> [<key>] 10 + # dscl . -list /Users [<key>] 11 + # dscl . -list /Groups [<key>] 12 + # dscl . -create /Users/<name> <key> <value> 13 + # dscl . -create /Groups/<name> <key> <value> 14 + # dscl . -delete /Users/<name> 15 + # dscl . -delete /Groups/<name> 16 + # dscl . -append /Groups/<name> GroupMembership <user> 17 + # dscl . -search /Users UniqueID <uid> 18 + # dscl . -search /Groups PrimaryGroupID <gid> 19 + # 20 + # The datasource argument (first positional arg) is accepted but only 21 + # "." (local directory) and "/Local/Default" are handled — all others 22 + # produce an error. 23 + # 24 + # This stub operates on the Darling prefix's /etc/passwd and /etc/group 25 + # files, NOT the host's. 26 + # 27 + # See: plan/07-phase5-daemon.md (Task 5.1) 28 + 29 + set -eu 30 + 31 + PASSWD_FILE="/etc/passwd" 32 + GROUP_FILE="/etc/group" 33 + 34 + # ── Helpers ───────────────────────────────────────────────────────────────── 35 + 36 + errcho() { 37 + echo "$@" >&2 38 + } 39 + 40 + usage() { 41 + errcho "Usage: dscl <datasource> <command> <path> [<key> [<value> ...]]" 42 + errcho "" 43 + errcho "Datasources: . /Local/Default" 44 + errcho "" 45 + errcho "Commands:" 46 + errcho " -read Read a record (or a specific key from a record)" 47 + errcho " -list List records under a path (optionally show a key)" 48 + errcho " -create Create a record or set a key on a record" 49 + errcho " -delete Delete a record (or a key from a record)" 50 + errcho " -append Append a value to a multi-valued key" 51 + errcho " -search Search records for a matching key=value" 52 + exit 64 53 + } 54 + 55 + # user_exists <name> 56 + user_exists() { 57 + grep -q "^${1}:" "$PASSWD_FILE" 2>/dev/null 58 + } 59 + 60 + # group_exists <name> 61 + group_exists() { 62 + grep -q "^${1}:" "$GROUP_FILE" 2>/dev/null 63 + } 64 + 65 + # get_passwd_field <name> <field_num> (1-indexed: 1=name 2=pw 3=uid 4=gid 5=gecos 6=home 7=shell) 66 + get_passwd_field() { 67 + awk -F: -v name="$1" -v fld="$2" '$1 == name { print $fld; exit }' "$PASSWD_FILE" 68 + } 69 + 70 + # get_group_field <name> <field_num> (1=name 2=pw 3=gid 4=members) 71 + get_group_field() { 72 + awk -F: -v name="$1" -v fld="$2" '$1 == name { print $fld; exit }' "$GROUP_FILE" 73 + } 74 + 75 + # print_user_record <name> [<key>] 76 + # Prints all (or a single) key-value pairs for a user, mimicking dscl output. 77 + print_user_record() { 78 + _name="$1" 79 + _key="${2:-}" 80 + 81 + if ! user_exists "$_name"; then 82 + errcho "dscl: record not found: /Users/$_name" 83 + return 1 84 + fi 85 + 86 + _uid=$(get_passwd_field "$_name" 3) 87 + _gid=$(get_passwd_field "$_name" 4) 88 + _gecos=$(get_passwd_field "$_name" 5) 89 + _home=$(get_passwd_field "$_name" 6) 90 + _shell=$(get_passwd_field "$_name" 7) 91 + 92 + if [ -n "$_key" ]; then 93 + case "$_key" in 94 + UniqueID) echo "UniqueID: $_uid" ;; 95 + PrimaryGroupID) echo "PrimaryGroupID: $_gid" ;; 96 + RealName) echo "RealName: $_gecos" ;; 97 + NFSHomeDirectory) echo "NFSHomeDirectory: $_home" ;; 98 + UserShell) echo "UserShell: $_shell" ;; 99 + RecordName) echo "RecordName: $_name" ;; 100 + Password) echo "Password: ********" ;; 101 + RecordType) echo "RecordType: dsRecTypeStandard:Users" ;; 102 + AuthenticationAuthority) echo "AuthenticationAuthority: ;ShadowHash;" ;; 103 + *) 104 + errcho "dscl: no such key '$_key' (or not implemented)" 105 + return 1 106 + ;; 107 + esac 108 + else 109 + echo "AppleMetaNodeLocation: /Local/Default" 110 + echo "RecordName: $_name" 111 + echo "RecordType: dsRecTypeStandard:Users" 112 + echo "UniqueID: $_uid" 113 + echo "PrimaryGroupID: $_gid" 114 + echo "RealName: $_gecos" 115 + echo "NFSHomeDirectory: $_home" 116 + echo "UserShell: $_shell" 117 + fi 118 + } 119 + 120 + # print_group_record <name> [<key>] 121 + print_group_record() { 122 + _name="$1" 123 + _key="${2:-}" 124 + 125 + if ! group_exists "$_name"; then 126 + errcho "dscl: record not found: /Groups/$_name" 127 + return 1 128 + fi 129 + 130 + _gid=$(get_group_field "$_name" 3) 131 + _members=$(get_group_field "$_name" 4) 132 + 133 + if [ -n "$_key" ]; then 134 + case "$_key" in 135 + PrimaryGroupID) echo "PrimaryGroupID: $_gid" ;; 136 + RecordName) echo "RecordName: $_name" ;; 137 + RecordType) echo "RecordType: dsRecTypeStandard:Groups" ;; 138 + GroupMembership) 139 + if [ -n "$_members" ]; then 140 + # Print each member on its own line (like real dscl) 141 + printf "GroupMembership:" 142 + echo "$_members" | tr ',' '\n' | while read -r _m; do 143 + printf " %s" "$_m" 144 + done 145 + printf "\n" 146 + else 147 + echo "GroupMembership:" 148 + fi 149 + ;; 150 + GeneratedUID) 151 + echo "GeneratedUID: 00000000-0000-0000-0000-$(printf '%012d' "$_gid")" 152 + ;; 153 + *) 154 + errcho "dscl: no such key '$_key' (or not implemented)" 155 + return 1 156 + ;; 157 + esac 158 + else 159 + echo "AppleMetaNodeLocation: /Local/Default" 160 + echo "RecordName: $_name" 161 + echo "RecordType: dsRecTypeStandard:Groups" 162 + echo "PrimaryGroupID: $_gid" 163 + echo "GeneratedUID: 00000000-0000-0000-0000-$(printf '%012d' "$_gid")" 164 + if [ -n "$_members" ]; then 165 + printf "GroupMembership:" 166 + echo "$_members" | tr ',' '\n' | while read -r _m; do 167 + printf " %s" "$_m" 168 + done 169 + printf "\n" 170 + fi 171 + fi 172 + } 173 + 174 + # next_uid — find the next available UID >= 300 175 + next_uid() { 176 + awk -F: '{ if ($3 >= 300 && $3 > max) max = $3 } END { print (max ? max + 1 : 300) }' "$PASSWD_FILE" 177 + } 178 + 179 + # next_gid — find the next available GID >= 30000 180 + next_gid() { 181 + awk -F: '{ if ($3 >= 30000 && $3 > max) max = $3 } END { print (max ? max + 1 : 30000) }' "$GROUP_FILE" 182 + } 183 + 184 + # ── Argument validation ───────────────────────────────────────────────────── 185 + 186 + if [ "$#" -lt 2 ]; then 187 + usage 188 + fi 189 + 190 + # Datasource — accept and skip "." or "/Local/Default" 191 + DATASOURCE="$1" 192 + shift 193 + 194 + case "$DATASOURCE" in 195 + .|/Local/Default|localhost) 196 + ;; 197 + *) 198 + errcho "dscl: unsupported datasource '$DATASOURCE' (only . and /Local/Default are supported)" 199 + exit 1 200 + ;; 201 + esac 202 + 203 + # Command 204 + COMMAND="$1" 205 + shift 206 + 207 + # ── Dispatch ──────────────────────────────────────────────────────────────── 208 + 209 + case "$COMMAND" in 210 + 211 + # ── -read ─────────────────────────────────────────────────────────────────── 212 + -read|-readall) 213 + if [ "$#" -lt 1 ]; then 214 + errcho "dscl: -read requires a path" 215 + usage 216 + fi 217 + RECORD_PATH="$1" 218 + shift 219 + KEY="${1:-}" 220 + 221 + case "$RECORD_PATH" in 222 + /Users/*) 223 + RECORD_NAME="${RECORD_PATH#/Users/}" 224 + print_user_record "$RECORD_NAME" "$KEY" 225 + ;; 226 + /Groups/*) 227 + RECORD_NAME="${RECORD_PATH#/Groups/}" 228 + print_group_record "$RECORD_NAME" "$KEY" 229 + ;; 230 + *) 231 + errcho "dscl: unsupported path '$RECORD_PATH' (expected /Users/<name> or /Groups/<name>)" 232 + exit 1 233 + ;; 234 + esac 235 + ;; 236 + 237 + # ── -list ─────────────────────────────────────────────────────────────────── 238 + -list|-ls) 239 + if [ "$#" -lt 1 ]; then 240 + errcho "dscl: -list requires a path" 241 + usage 242 + fi 243 + RECORD_PATH="$1" 244 + shift 245 + KEY="${1:-}" 246 + 247 + case "$RECORD_PATH" in 248 + /Users) 249 + if [ -z "$KEY" ]; then 250 + awk -F: '{ print $1 }' "$PASSWD_FILE" 251 + else 252 + case "$KEY" in 253 + UniqueID) 254 + awk -F: '{ printf "%-24s %s\n", $1, $3 }' "$PASSWD_FILE" 255 + ;; 256 + PrimaryGroupID) 257 + awk -F: '{ printf "%-24s %s\n", $1, $4 }' "$PASSWD_FILE" 258 + ;; 259 + NFSHomeDirectory) 260 + awk -F: '{ printf "%-24s %s\n", $1, $6 }' "$PASSWD_FILE" 261 + ;; 262 + UserShell) 263 + awk -F: '{ printf "%-24s %s\n", $1, $7 }' "$PASSWD_FILE" 264 + ;; 265 + RealName) 266 + awk -F: '{ printf "%-24s %s\n", $1, $5 }' "$PASSWD_FILE" 267 + ;; 268 + RecordName) 269 + awk -F: '{ print $1 }' "$PASSWD_FILE" 270 + ;; 271 + *) 272 + errcho "dscl: unsupported key '$KEY' for /Users list" 273 + exit 1 274 + ;; 275 + esac 276 + fi 277 + ;; 278 + /Groups) 279 + if [ -z "$KEY" ]; then 280 + awk -F: '{ print $1 }' "$GROUP_FILE" 281 + else 282 + case "$KEY" in 283 + PrimaryGroupID) 284 + awk -F: '{ printf "%-24s %s\n", $1, $3 }' "$GROUP_FILE" 285 + ;; 286 + RecordName) 287 + awk -F: '{ print $1 }' "$GROUP_FILE" 288 + ;; 289 + GroupMembership) 290 + awk -F: '{ if ($4 != "") printf "%-24s %s\n", $1, $4 }' "$GROUP_FILE" 291 + ;; 292 + *) 293 + errcho "dscl: unsupported key '$KEY' for /Groups list" 294 + exit 1 295 + ;; 296 + esac 297 + fi 298 + ;; 299 + *) 300 + errcho "dscl: unsupported path '$RECORD_PATH' (expected /Users or /Groups)" 301 + exit 1 302 + ;; 303 + esac 304 + ;; 305 + 306 + # ── -create ───────────────────────────────────────────────────────────────── 307 + -create) 308 + if [ "$#" -lt 1 ]; then 309 + errcho "dscl: -create requires a path" 310 + usage 311 + fi 312 + RECORD_PATH="$1" 313 + shift 314 + KEY="${1:-}" 315 + [ -n "$KEY" ] && shift 316 + VALUE="${*:-}" 317 + 318 + case "$RECORD_PATH" in 319 + /Users/*) 320 + RECORD_NAME="${RECORD_PATH#/Users/}" 321 + if [ -z "$KEY" ]; then 322 + # Create the user record with defaults if it doesn't exist 323 + if ! user_exists "$RECORD_NAME"; then 324 + _uid=$(next_uid) 325 + echo "${RECORD_NAME}:x:${_uid}:0:::/usr/bin/false" >> "$PASSWD_FILE" 326 + fi 327 + else 328 + # Set a specific key on an existing user, or create + set 329 + if ! user_exists "$RECORD_NAME"; then 330 + _uid=$(next_uid) 331 + echo "${RECORD_NAME}:x:${_uid}:0:::/usr/bin/false" >> "$PASSWD_FILE" 332 + fi 333 + 334 + TMPFILE=$(mktemp "${PASSWD_FILE}.XXXXXX") 335 + case "$KEY" in 336 + UniqueID) 337 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 338 + 'BEGIN{OFS=":"} $1==name{$3=val} {print}' \ 339 + "$PASSWD_FILE" > "$TMPFILE" 340 + ;; 341 + PrimaryGroupID) 342 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 343 + 'BEGIN{OFS=":"} $1==name{$4=val} {print}' \ 344 + "$PASSWD_FILE" > "$TMPFILE" 345 + ;; 346 + RealName) 347 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 348 + 'BEGIN{OFS=":"} $1==name{$5=val} {print}' \ 349 + "$PASSWD_FILE" > "$TMPFILE" 350 + ;; 351 + NFSHomeDirectory) 352 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 353 + 'BEGIN{OFS=":"} $1==name{$6=val} {print}' \ 354 + "$PASSWD_FILE" > "$TMPFILE" 355 + ;; 356 + UserShell) 357 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 358 + 'BEGIN{OFS=":"} $1==name{$7=val} {print}' \ 359 + "$PASSWD_FILE" > "$TMPFILE" 360 + ;; 361 + Password) 362 + # Ignored — always locked 363 + rm -f "$TMPFILE" 364 + exit 0 365 + ;; 366 + IsHidden|AuthenticationAuthority) 367 + # Ignored — macOS-specific keys not meaningful in /etc/passwd 368 + rm -f "$TMPFILE" 369 + exit 0 370 + ;; 371 + *) 372 + rm -f "$TMPFILE" 373 + errcho "dscl: unsupported key '$KEY' for /Users create" 374 + exit 1 375 + ;; 376 + esac 377 + mv "$TMPFILE" "$PASSWD_FILE" 378 + fi 379 + ;; 380 + /Groups/*) 381 + RECORD_NAME="${RECORD_PATH#/Groups/}" 382 + if [ -z "$KEY" ]; then 383 + # Create the group record with defaults if it doesn't exist 384 + if ! group_exists "$RECORD_NAME"; then 385 + _gid=$(next_gid) 386 + echo "${RECORD_NAME}:x:${_gid}:" >> "$GROUP_FILE" 387 + fi 388 + else 389 + if ! group_exists "$RECORD_NAME"; then 390 + _gid=$(next_gid) 391 + echo "${RECORD_NAME}:x:${_gid}:" >> "$GROUP_FILE" 392 + fi 393 + 394 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 395 + case "$KEY" in 396 + PrimaryGroupID) 397 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 398 + 'BEGIN{OFS=":"} $1==name{$3=val} {print}' \ 399 + "$GROUP_FILE" > "$TMPFILE" 400 + ;; 401 + RealName|Password|IsHidden) 402 + # Ignored 403 + rm -f "$TMPFILE" 404 + exit 0 405 + ;; 406 + GroupMembership) 407 + # -create with GroupMembership replaces the entire member list 408 + awk -F: -v name="$RECORD_NAME" -v val="$VALUE" \ 409 + 'BEGIN{OFS=":"} $1==name{$4=val} {print}' \ 410 + "$GROUP_FILE" > "$TMPFILE" 411 + ;; 412 + *) 413 + rm -f "$TMPFILE" 414 + errcho "dscl: unsupported key '$KEY' for /Groups create" 415 + exit 1 416 + ;; 417 + esac 418 + mv "$TMPFILE" "$GROUP_FILE" 419 + fi 420 + ;; 421 + *) 422 + errcho "dscl: unsupported path '$RECORD_PATH'" 423 + exit 1 424 + ;; 425 + esac 426 + ;; 427 + 428 + # ── -delete ───────────────────────────────────────────────────────────────── 429 + -delete) 430 + if [ "$#" -lt 1 ]; then 431 + errcho "dscl: -delete requires a path" 432 + usage 433 + fi 434 + RECORD_PATH="$1" 435 + shift 436 + KEY="${1:-}" 437 + 438 + case "$RECORD_PATH" in 439 + /Users/*) 440 + RECORD_NAME="${RECORD_PATH#/Users/}" 441 + if ! user_exists "$RECORD_NAME"; then 442 + exit 0 # idempotent 443 + fi 444 + if [ -z "$KEY" ]; then 445 + # Delete the entire user record 446 + TMPFILE=$(mktemp "${PASSWD_FILE}.XXXXXX") 447 + grep -v "^${RECORD_NAME}:" "$PASSWD_FILE" > "$TMPFILE" || true 448 + mv "$TMPFILE" "$PASSWD_FILE" 449 + else 450 + # Delete a specific key — for /etc/passwd, this means clearing the field 451 + TMPFILE=$(mktemp "${PASSWD_FILE}.XXXXXX") 452 + case "$KEY" in 453 + RealName) 454 + awk -F: -v name="$RECORD_NAME" \ 455 + 'BEGIN{OFS=":"} $1==name{$5=""} {print}' \ 456 + "$PASSWD_FILE" > "$TMPFILE" 457 + ;; 458 + *) 459 + rm -f "$TMPFILE" 460 + errcho "dscl: cannot delete key '$KEY' from a user record" 461 + exit 1 462 + ;; 463 + esac 464 + mv "$TMPFILE" "$PASSWD_FILE" 465 + fi 466 + ;; 467 + /Groups/*) 468 + RECORD_NAME="${RECORD_PATH#/Groups/}" 469 + if ! group_exists "$RECORD_NAME"; then 470 + exit 0 # idempotent 471 + fi 472 + if [ -z "$KEY" ]; then 473 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 474 + grep -v "^${RECORD_NAME}:" "$GROUP_FILE" > "$TMPFILE" || true 475 + mv "$TMPFILE" "$GROUP_FILE" 476 + else 477 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 478 + case "$KEY" in 479 + GroupMembership) 480 + # Clear the member list 481 + awk -F: -v name="$RECORD_NAME" \ 482 + 'BEGIN{OFS=":"} $1==name{$4=""} {print}' \ 483 + "$GROUP_FILE" > "$TMPFILE" 484 + ;; 485 + *) 486 + rm -f "$TMPFILE" 487 + errcho "dscl: cannot delete key '$KEY' from a group record" 488 + exit 1 489 + ;; 490 + esac 491 + mv "$TMPFILE" "$GROUP_FILE" 492 + fi 493 + ;; 494 + *) 495 + errcho "dscl: unsupported path '$RECORD_PATH'" 496 + exit 1 497 + ;; 498 + esac 499 + ;; 500 + 501 + # ── -append ───────────────────────────────────────────────────────────────── 502 + -append) 503 + if [ "$#" -lt 3 ]; then 504 + errcho "dscl: -append requires <path> <key> <value>" 505 + usage 506 + fi 507 + RECORD_PATH="$1" 508 + KEY="$2" 509 + shift 2 510 + VALUE="$*" 511 + 512 + case "$RECORD_PATH" in 513 + /Groups/*) 514 + RECORD_NAME="${RECORD_PATH#/Groups/}" 515 + if ! group_exists "$RECORD_NAME"; then 516 + errcho "dscl: group '$RECORD_NAME' not found" 517 + exit 1 518 + fi 519 + 520 + case "$KEY" in 521 + GroupMembership) 522 + # Append user(s) to the member list 523 + CURRENT=$(get_group_field "$RECORD_NAME" 4) 524 + 525 + # Process each value as a potential member to add 526 + for NEW_MEMBER in $VALUE; do 527 + # Check if already a member (idempotent) 528 + case ",$CURRENT," in 529 + *,"$NEW_MEMBER",*) 530 + continue 531 + ;; 532 + esac 533 + if [ -z "$CURRENT" ]; then 534 + CURRENT="$NEW_MEMBER" 535 + else 536 + CURRENT="${CURRENT},${NEW_MEMBER}" 537 + fi 538 + done 539 + 540 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 541 + awk -F: -v name="$RECORD_NAME" -v members="$CURRENT" \ 542 + 'BEGIN{OFS=":"} $1==name{$4=members} {print}' \ 543 + "$GROUP_FILE" > "$TMPFILE" 544 + mv "$TMPFILE" "$GROUP_FILE" 545 + ;; 546 + *) 547 + errcho "dscl: unsupported key '$KEY' for -append on /Groups" 548 + exit 1 549 + ;; 550 + esac 551 + ;; 552 + /Users/*) 553 + errcho "dscl: -append is not supported for /Users records in this stub" 554 + exit 1 555 + ;; 556 + *) 557 + errcho "dscl: unsupported path '$RECORD_PATH'" 558 + exit 1 559 + ;; 560 + esac 561 + ;; 562 + 563 + # ── -search ───────────────────────────────────────────────────────────────── 564 + -search) 565 + if [ "$#" -lt 3 ]; then 566 + errcho "dscl: -search requires <path> <key> <value>" 567 + usage 568 + fi 569 + SEARCH_PATH="$1" 570 + KEY="$2" 571 + VALUE="$3" 572 + 573 + case "$SEARCH_PATH" in 574 + /Users) 575 + case "$KEY" in 576 + UniqueID) 577 + awk -F: -v val="$VALUE" '$3 == val { printf "%-24s ( %s )\n", $1, "UniqueID = " val }' "$PASSWD_FILE" 578 + ;; 579 + PrimaryGroupID) 580 + awk -F: -v val="$VALUE" '$4 == val { printf "%-24s ( %s )\n", $1, "PrimaryGroupID = " val }' "$PASSWD_FILE" 581 + ;; 582 + RecordName) 583 + awk -F: -v val="$VALUE" '$1 == val { printf "%-24s ( %s )\n", $1, "RecordName = " val }' "$PASSWD_FILE" 584 + ;; 585 + *) 586 + errcho "dscl: unsupported search key '$KEY' for /Users" 587 + exit 1 588 + ;; 589 + esac 590 + ;; 591 + /Groups) 592 + case "$KEY" in 593 + PrimaryGroupID) 594 + awk -F: -v val="$VALUE" '$3 == val { printf "%-24s ( %s )\n", $1, "PrimaryGroupID = " val }' "$GROUP_FILE" 595 + ;; 596 + RecordName) 597 + awk -F: -v val="$VALUE" '$1 == val { printf "%-24s ( %s )\n", $1, "RecordName = " val }' "$GROUP_FILE" 598 + ;; 599 + *) 600 + errcho "dscl: unsupported search key '$KEY' for /Groups" 601 + exit 1 602 + ;; 603 + esac 604 + ;; 605 + *) 606 + errcho "dscl: unsupported search path '$SEARCH_PATH'" 607 + exit 1 608 + ;; 609 + esac 610 + ;; 611 + 612 + # ── Unknown command ───────────────────────────────────────────────────────── 613 + *) 614 + errcho "dscl: unknown command '$COMMAND'" 615 + usage 616 + ;; 617 + esac
+389
src/dirserv/dseditgroup
··· 1 + #!/bin/sh 2 + # dseditgroup — Directory Services group management stub for Darling 3 + # 4 + # Translates macOS dseditgroup commands to direct /etc/group file operations. 5 + # Only implements the subset needed by the Nix installer: 6 + # 7 + # dseditgroup -o create [-q] [-i <GID>] <groupname> 8 + # dseditgroup -o edit -a <user> -t user <groupname> 9 + # dseditgroup -o delete <groupname> 10 + # dseditgroup -o checkmember -m <user> <groupname> 11 + # dseditgroup -o read <groupname> 12 + # 13 + # This stub operates on the Darling prefix's /etc/group file, NOT the host's. 14 + # 15 + # See: plan/07-phase5-daemon.md (Task 5.1) 16 + 17 + set -eu 18 + 19 + GROUP_FILE="/etc/group" 20 + 21 + # ── Helpers ───────────────────────────────────────────────────────────────── 22 + 23 + errcho() { 24 + echo "$@" >&2 25 + } 26 + 27 + usage() { 28 + errcho "Usage: dseditgroup -o <operation> [options] <groupname>" 29 + errcho "" 30 + errcho "Operations:" 31 + errcho " create Create a new group" 32 + errcho " edit Edit an existing group (add/remove members)" 33 + errcho " delete Delete a group" 34 + errcho " checkmember Check if a user is a member of a group" 35 + errcho " read Read group information" 36 + errcho "" 37 + errcho "Options:" 38 + errcho " -q Quiet mode (suppress informational output)" 39 + errcho " -i <GID> Group ID (for create)" 40 + errcho " -a <user> User to add (for edit)" 41 + errcho " -d <user> User to remove (for edit)" 42 + errcho " -t <type> Record type (user/group, for edit)" 43 + errcho " -m <user> User to check membership (for checkmember)" 44 + exit 64 45 + } 46 + 47 + # group_exists <name> — exit 0 if the group exists in /etc/group 48 + group_exists() { 49 + grep -q "^${1}:" "$GROUP_FILE" 2>/dev/null 50 + } 51 + 52 + # get_group_gid <name> — print the GID of the named group 53 + get_group_gid() { 54 + awk -F: -v name="$1" '$1 == name { print $3; exit }' "$GROUP_FILE" 55 + } 56 + 57 + # get_group_members <name> — print the comma-separated member list 58 + get_group_members() { 59 + awk -F: -v name="$1" '$1 == name { print $4; exit }' "$GROUP_FILE" 60 + } 61 + 62 + # next_gid — find the next available GID >= 30000 63 + next_gid() { 64 + awk -F: '{ if ($3 >= 30000 && $3 > max) max = $3 } END { print (max ? max + 1 : 30000) }' "$GROUP_FILE" 65 + } 66 + 67 + # ── Parse top-level arguments ─────────────────────────────────────────────── 68 + 69 + OPERATION="" 70 + QUIET=0 71 + 72 + # We need at least: dseditgroup -o <op> <groupname> 73 + if [ "$#" -lt 3 ]; then 74 + usage 75 + fi 76 + 77 + # Parse -o <operation> which must come first (or early) 78 + while [ "$#" -gt 0 ]; do 79 + case "$1" in 80 + -o) 81 + [ "$#" -ge 2 ] || usage 82 + OPERATION="$2" 83 + shift 2 84 + break 85 + ;; 86 + -q) 87 + QUIET=1 88 + shift 89 + ;; 90 + *) 91 + errcho "dseditgroup: expected -o <operation>, got '$1'" 92 + usage 93 + ;; 94 + esac 95 + done 96 + 97 + # ── Dispatch by operation ─────────────────────────────────────────────────── 98 + 99 + case "$OPERATION" in 100 + 101 + # ── create ────────────────────────────────────────────────────────────────── 102 + create) 103 + GID="" 104 + # Parse remaining options 105 + while [ "$#" -gt 1 ]; do 106 + case "$1" in 107 + -q) 108 + QUIET=1 109 + shift 110 + ;; 111 + -i) 112 + [ "$#" -ge 2 ] || usage 113 + GID="$2" 114 + shift 2 115 + ;; 116 + -r) 117 + # -r <realname> — ignored, not needed for /etc/group 118 + shift 2 119 + ;; 120 + *) 121 + break 122 + ;; 123 + esac 124 + done 125 + 126 + # Last argument is the group name 127 + if [ "$#" -lt 1 ]; then 128 + errcho "dseditgroup: create requires a group name" 129 + usage 130 + fi 131 + GROUPNAME="$1" 132 + 133 + # Validate group name (alphanumeric, underscore, dash) 134 + case "$GROUPNAME" in 135 + *[!a-zA-Z0-9_-]*) 136 + errcho "dseditgroup: invalid group name '$GROUPNAME'" 137 + exit 1 138 + ;; 139 + esac 140 + 141 + # Idempotent: if group already exists, succeed silently 142 + if group_exists "$GROUPNAME"; then 143 + [ "$QUIET" -eq 1 ] || echo "Group '$GROUPNAME' already exists" 144 + exit 0 145 + fi 146 + 147 + # Assign GID if not specified 148 + if [ -z "$GID" ]; then 149 + GID=$(next_gid) 150 + fi 151 + 152 + # Validate GID is numeric 153 + case "$GID" in 154 + *[!0-9]*) 155 + errcho "dseditgroup: GID must be numeric, got '$GID'" 156 + exit 1 157 + ;; 158 + esac 159 + 160 + # Check for GID conflicts 161 + if awk -F: -v gid="$GID" '$3 == gid { found=1; exit } END { exit !found }' "$GROUP_FILE" 2>/dev/null; then 162 + existing=$(awk -F: -v gid="$GID" '$3 == gid { print $1; exit }' "$GROUP_FILE") 163 + errcho "dseditgroup: GID $GID already in use by group '$existing'" 164 + exit 1 165 + fi 166 + 167 + # Create the group entry 168 + echo "${GROUPNAME}:x:${GID}:" >> "$GROUP_FILE" 169 + 170 + [ "$QUIET" -eq 1 ] || echo "Created group '$GROUPNAME' with GID $GID" 171 + ;; 172 + 173 + # ── edit ──────────────────────────────────────────────────────────────────── 174 + edit) 175 + ADD_USER="" 176 + DEL_USER="" 177 + RECORD_TYPE="user" 178 + 179 + while [ "$#" -gt 1 ]; do 180 + case "$1" in 181 + -q) 182 + QUIET=1 183 + shift 184 + ;; 185 + -a) 186 + [ "$#" -ge 2 ] || usage 187 + ADD_USER="$2" 188 + shift 2 189 + ;; 190 + -d) 191 + [ "$#" -ge 2 ] || usage 192 + DEL_USER="$2" 193 + shift 2 194 + ;; 195 + -t) 196 + [ "$#" -ge 2 ] || usage 197 + RECORD_TYPE="$2" 198 + shift 2 199 + ;; 200 + *) 201 + break 202 + ;; 203 + esac 204 + done 205 + 206 + if [ "$#" -lt 1 ]; then 207 + errcho "dseditgroup: edit requires a group name" 208 + usage 209 + fi 210 + GROUPNAME="$1" 211 + 212 + if ! group_exists "$GROUPNAME"; then 213 + errcho "dseditgroup: group '$GROUPNAME' not found" 214 + exit 1 215 + fi 216 + 217 + if [ -n "$ADD_USER" ]; then 218 + # Check if user is already a member (idempotent) 219 + MEMBERS=$(get_group_members "$GROUPNAME") 220 + case ",$MEMBERS," in 221 + *,"$ADD_USER",*) 222 + [ "$QUIET" -eq 1 ] || echo "User '$ADD_USER' is already a member of '$GROUPNAME'" 223 + exit 0 224 + ;; 225 + esac 226 + 227 + # Append user to the member list 228 + if [ -z "$MEMBERS" ]; then 229 + NEW_MEMBERS="$ADD_USER" 230 + else 231 + NEW_MEMBERS="${MEMBERS},${ADD_USER}" 232 + fi 233 + 234 + # Use a temp file for atomic-ish update 235 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 236 + awk -F: -v name="$GROUPNAME" -v members="$NEW_MEMBERS" \ 237 + 'BEGIN { OFS=":" } $1 == name { $4 = members } { print }' \ 238 + "$GROUP_FILE" > "$TMPFILE" 239 + mv "$TMPFILE" "$GROUP_FILE" 240 + 241 + [ "$QUIET" -eq 1 ] || echo "Added '$ADD_USER' to group '$GROUPNAME'" 242 + 243 + elif [ -n "$DEL_USER" ]; then 244 + MEMBERS=$(get_group_members "$GROUPNAME") 245 + 246 + # Remove the user from the comma-separated list 247 + NEW_MEMBERS=$(echo "$MEMBERS" | tr ',' '\n' | grep -v "^${DEL_USER}$" | paste -sd ',' -) 248 + 249 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 250 + awk -F: -v name="$GROUPNAME" -v members="$NEW_MEMBERS" \ 251 + 'BEGIN { OFS=":" } $1 == name { $4 = members } { print }' \ 252 + "$GROUP_FILE" > "$TMPFILE" 253 + mv "$TMPFILE" "$GROUP_FILE" 254 + 255 + [ "$QUIET" -eq 1 ] || echo "Removed '$DEL_USER' from group '$GROUPNAME'" 256 + else 257 + errcho "dseditgroup: edit requires -a <user> or -d <user>" 258 + usage 259 + fi 260 + ;; 261 + 262 + # ── delete ────────────────────────────────────────────────────────────────── 263 + delete) 264 + while [ "$#" -gt 1 ]; do 265 + case "$1" in 266 + -q) 267 + QUIET=1 268 + shift 269 + ;; 270 + *) 271 + break 272 + ;; 273 + esac 274 + done 275 + 276 + if [ "$#" -lt 1 ]; then 277 + errcho "dseditgroup: delete requires a group name" 278 + usage 279 + fi 280 + GROUPNAME="$1" 281 + 282 + if ! group_exists "$GROUPNAME"; then 283 + [ "$QUIET" -eq 1 ] || errcho "dseditgroup: group '$GROUPNAME' not found" 284 + exit 0 # idempotent — deleting non-existent group is fine 285 + fi 286 + 287 + TMPFILE=$(mktemp "${GROUP_FILE}.XXXXXX") 288 + grep -v "^${GROUPNAME}:" "$GROUP_FILE" > "$TMPFILE" || true 289 + mv "$TMPFILE" "$GROUP_FILE" 290 + 291 + [ "$QUIET" -eq 1 ] || echo "Deleted group '$GROUPNAME'" 292 + ;; 293 + 294 + # ── checkmember ───────────────────────────────────────────────────────────── 295 + checkmember) 296 + CHECK_USER="" 297 + 298 + while [ "$#" -gt 1 ]; do 299 + case "$1" in 300 + -q) 301 + QUIET=1 302 + shift 303 + ;; 304 + -m) 305 + [ "$#" -ge 2 ] || usage 306 + CHECK_USER="$2" 307 + shift 2 308 + ;; 309 + *) 310 + break 311 + ;; 312 + esac 313 + done 314 + 315 + if [ "$#" -lt 1 ]; then 316 + errcho "dseditgroup: checkmember requires a group name" 317 + usage 318 + fi 319 + GROUPNAME="$1" 320 + 321 + if [ -z "$CHECK_USER" ]; then 322 + errcho "dseditgroup: checkmember requires -m <user>" 323 + usage 324 + fi 325 + 326 + if ! group_exists "$GROUPNAME"; then 327 + errcho "dseditgroup: group '$GROUPNAME' not found" 328 + exit 1 329 + fi 330 + 331 + MEMBERS=$(get_group_members "$GROUPNAME") 332 + case ",$MEMBERS," in 333 + *,"$CHECK_USER",*) 334 + [ "$QUIET" -eq 1 ] || echo "yes $CHECK_USER is a member of $GROUPNAME" 335 + exit 0 336 + ;; 337 + *) 338 + [ "$QUIET" -eq 1 ] || echo "no $CHECK_USER is NOT a member of $GROUPNAME" 339 + exit 1 340 + ;; 341 + esac 342 + ;; 343 + 344 + # ── read ──────────────────────────────────────────────────────────────────── 345 + read) 346 + while [ "$#" -gt 1 ]; do 347 + case "$1" in 348 + -q) 349 + QUIET=1 350 + shift 351 + ;; 352 + *) 353 + break 354 + ;; 355 + esac 356 + done 357 + 358 + if [ "$#" -lt 1 ]; then 359 + errcho "dseditgroup: read requires a group name" 360 + usage 361 + fi 362 + GROUPNAME="$1" 363 + 364 + if ! group_exists "$GROUPNAME"; then 365 + errcho "dseditgroup: group '$GROUPNAME' not found" 366 + exit 1 367 + fi 368 + 369 + GID=$(get_group_gid "$GROUPNAME") 370 + MEMBERS=$(get_group_members "$GROUPNAME") 371 + 372 + echo "AppleMetaNodeLocation: /Local/Default" 373 + echo "GeneratedUID: 00000000-0000-0000-0000-$(printf '%012d' "$GID")" 374 + echo "PrimaryGroupID: $GID" 375 + echo "RecordName: $GROUPNAME" 376 + echo "RecordType: dsRecTypeStandard:Groups" 377 + if [ -n "$MEMBERS" ]; then 378 + echo "$MEMBERS" | tr ',' '\n' | while read -r member; do 379 + echo "GroupMembership: $member" 380 + done 381 + fi 382 + ;; 383 + 384 + # ── unknown operation ─────────────────────────────────────────────────────── 385 + *) 386 + errcho "dseditgroup: unknown operation '$OPERATION'" 387 + usage 388 + ;; 389 + esac
+271
src/dirserv/sysadminctl
··· 1 + #!/bin/sh 2 + # sysadminctl — macOS user management stub for Darling 3 + # 4 + # Translates macOS sysadminctl commands to direct /etc/passwd file operations. 5 + # Only implements the subset needed by the Nix installer: 6 + # 7 + # sysadminctl -addUser <name> [-UID <uid>] [-GID <gid>] 8 + # [-home <dir>] [-shell <shell>] [-fullName <name>] 9 + # sysadminctl -deleteUser <name> 10 + # 11 + # This stub operates on the Darling prefix's /etc/passwd file, NOT the host's. 12 + # 13 + # See: plan/07-phase5-daemon.md (Task 5.1) 14 + 15 + set -eu 16 + 17 + PASSWD_FILE="/etc/passwd" 18 + 19 + # ── Helpers ───────────────────────────────────────────────────────────────── 20 + 21 + errcho() { 22 + echo "$@" >&2 23 + } 24 + 25 + usage() { 26 + errcho "Usage: sysadminctl -addUser <username> [-UID <uid>] [-GID <gid>]" 27 + errcho " [-home <homedir>] [-shell <shell>] [-fullName <gecos>]" 28 + errcho " sysadminctl -deleteUser <username>" 29 + errcho "" 30 + errcho "Options:" 31 + errcho " -addUser <name> Create a new user" 32 + errcho " -deleteUser <name> Delete an existing user" 33 + errcho " -UID <uid> User ID (numeric)" 34 + errcho " -GID <gid> Primary group ID (numeric)" 35 + errcho " -home <dir> Home directory (default: /var/empty)" 36 + errcho " -shell <shell> Login shell (default: /usr/bin/false)" 37 + errcho " -fullName <gecos> Full name / GECOS field" 38 + errcho " -password <pass> Password (ignored — always set to locked)" 39 + errcho " -adminUser <user> Admin user for authentication (ignored)" 40 + errcho " -adminPassword <pw> Admin password (ignored)" 41 + exit 64 42 + } 43 + 44 + # user_exists <name> — exit 0 if the user exists in /etc/passwd 45 + user_exists() { 46 + grep -q "^${1}:" "$PASSWD_FILE" 2>/dev/null 47 + } 48 + 49 + # uid_exists <uid> — exit 0 if the UID is already taken 50 + uid_exists() { 51 + awk -F: -v uid="$1" '$3 == uid { found=1; exit } END { exit !found }' "$PASSWD_FILE" 2>/dev/null 52 + } 53 + 54 + # get_uid_owner <uid> — print the username that owns this UID 55 + get_uid_owner() { 56 + awk -F: -v uid="$1" '$3 == uid { print $1; exit }' "$PASSWD_FILE" 57 + } 58 + 59 + # next_uid — find the next available UID >= 300 (Nix build user range) 60 + next_uid() { 61 + awk -F: '{ if ($3 >= 300 && $3 > max) max = $3 } END { print (max ? max + 1 : 300) }' "$PASSWD_FILE" 62 + } 63 + 64 + # ── Argument parsing ──────────────────────────────────────────────────────── 65 + 66 + if [ "$#" -lt 2 ]; then 67 + usage 68 + fi 69 + 70 + ACTION="" 71 + USERNAME="" 72 + 73 + case "$1" in 74 + -addUser) 75 + ACTION="add" 76 + USERNAME="$2" 77 + shift 2 78 + ;; 79 + -deleteUser) 80 + ACTION="delete" 81 + USERNAME="$2" 82 + shift 2 83 + ;; 84 + -h|--help|-help) 85 + usage 86 + ;; 87 + *) 88 + errcho "sysadminctl: unknown option '$1'" 89 + usage 90 + ;; 91 + esac 92 + 93 + # Validate username (alphanumeric, underscore, dash, dot; max 256 chars) 94 + case "$USERNAME" in 95 + "") 96 + errcho "sysadminctl: username must not be empty" 97 + exit 1 98 + ;; 99 + *[!a-zA-Z0-9_.-]*) 100 + errcho "sysadminctl: invalid username '$USERNAME' (allowed: a-z A-Z 0-9 _ . -)" 101 + exit 1 102 + ;; 103 + esac 104 + 105 + if [ "${#USERNAME}" -gt 256 ]; then 106 + errcho "sysadminctl: username too long (max 256 characters)" 107 + exit 1 108 + fi 109 + 110 + # ── addUser ───────────────────────────────────────────────────────────────── 111 + 112 + if [ "$ACTION" = "add" ]; then 113 + UID_VAL="" 114 + GID_VAL="" 115 + HOME_DIR="/var/empty" 116 + SHELL="/usr/bin/false" 117 + GECOS="" 118 + 119 + while [ "$#" -gt 0 ]; do 120 + case "$1" in 121 + -UID) 122 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -UID requires an argument"; exit 1; } 123 + UID_VAL="$2" 124 + shift 2 125 + ;; 126 + -GID) 127 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -GID requires an argument"; exit 1; } 128 + GID_VAL="$2" 129 + shift 2 130 + ;; 131 + -home) 132 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -home requires an argument"; exit 1; } 133 + HOME_DIR="$2" 134 + shift 2 135 + ;; 136 + -shell) 137 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -shell requires an argument"; exit 1; } 138 + SHELL="$2" 139 + shift 2 140 + ;; 141 + -fullName) 142 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -fullName requires an argument"; exit 1; } 143 + GECOS="$2" 144 + shift 2 145 + ;; 146 + -password) 147 + # Ignored — we always set the password field to 'x' (locked) 148 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -password requires an argument"; exit 1; } 149 + shift 2 150 + ;; 151 + -adminUser|-admin) 152 + # Ignored — no authentication needed in a Darling prefix 153 + [ "$#" -ge 2 ] || { errcho "sysadminctl: $1 requires an argument"; exit 1; } 154 + shift 2 155 + ;; 156 + -adminPassword) 157 + # Ignored — no authentication needed in a Darling prefix 158 + [ "$#" -ge 2 ] || { errcho "sysadminctl: -adminPassword requires an argument"; exit 1; } 159 + shift 2 160 + ;; 161 + -roleAccount) 162 + # Boolean flag used by newer Nix installers — no argument 163 + shift 164 + ;; 165 + *) 166 + errcho "sysadminctl: unknown option '$1'" 167 + usage 168 + ;; 169 + esac 170 + done 171 + 172 + # Idempotent: if user already exists, succeed silently 173 + if user_exists "$USERNAME"; then 174 + echo "User '$USERNAME' already exists" 175 + exit 0 176 + fi 177 + 178 + # Assign UID if not specified 179 + if [ -z "$UID_VAL" ]; then 180 + UID_VAL=$(next_uid) 181 + fi 182 + 183 + # Validate UID is numeric 184 + case "$UID_VAL" in 185 + *[!0-9]*) 186 + errcho "sysadminctl: UID must be numeric, got '$UID_VAL'" 187 + exit 1 188 + ;; 189 + esac 190 + 191 + # Check for UID conflicts 192 + if uid_exists "$UID_VAL"; then 193 + existing=$(get_uid_owner "$UID_VAL") 194 + errcho "sysadminctl: UID $UID_VAL already in use by user '$existing'" 195 + exit 1 196 + fi 197 + 198 + # Default GID to 0 (wheel/root) if not specified 199 + if [ -z "$GID_VAL" ]; then 200 + GID_VAL=0 201 + fi 202 + 203 + # Validate GID is numeric 204 + case "$GID_VAL" in 205 + *[!0-9]*) 206 + errcho "sysadminctl: GID must be numeric, got '$GID_VAL'" 207 + exit 1 208 + ;; 209 + esac 210 + 211 + # Ensure /etc/passwd exists 212 + if [ ! -f "$PASSWD_FILE" ]; then 213 + errcho "sysadminctl: $PASSWD_FILE does not exist" 214 + exit 1 215 + fi 216 + 217 + # Ensure the home directory's parent exists (but don't create the home 218 + # directory itself — Nix build users use /var/empty which should already 219 + # exist). 220 + HOME_PARENT=$(dirname "$HOME_DIR") 221 + if [ ! -d "$HOME_PARENT" ] && [ "$HOME_PARENT" != "/" ]; then 222 + mkdir -p "$HOME_PARENT" 2>/dev/null || true 223 + fi 224 + 225 + # Create the passwd entry 226 + # Format: name:password:UID:GID:GECOS:home:shell 227 + echo "${USERNAME}:x:${UID_VAL}:${GID_VAL}:${GECOS}:${HOME_DIR}:${SHELL}" >> "$PASSWD_FILE" 228 + 229 + echo "Created user '$USERNAME' (UID=$UID_VAL, GID=$GID_VAL, home=$HOME_DIR, shell=$SHELL)" 230 + exit 0 231 + fi 232 + 233 + # ── deleteUser ────────────────────────────────────────────────────────────── 234 + 235 + if [ "$ACTION" = "delete" ]; then 236 + # Consume any remaining flags (some callers pass -secure, etc.) 237 + while [ "$#" -gt 0 ]; do 238 + case "$1" in 239 + -secure|-keepHome|-adminUser|-adminPassword) 240 + # Ignored — these are macOS-specific options 241 + if [ "$1" = "-adminUser" ] || [ "$1" = "-adminPassword" ]; then 242 + shift 2 2>/dev/null || shift 243 + else 244 + shift 245 + fi 246 + ;; 247 + *) 248 + errcho "sysadminctl: unknown option '$1' (ignoring)" 249 + shift 250 + ;; 251 + esac 252 + done 253 + 254 + # Idempotent: if user doesn't exist, succeed silently 255 + if ! user_exists "$USERNAME"; then 256 + echo "User '$USERNAME' does not exist" 257 + exit 0 258 + fi 259 + 260 + # Remove the user from /etc/passwd 261 + TMPFILE=$(mktemp "${PASSWD_FILE}.XXXXXX") 262 + grep -v "^${USERNAME}:" "$PASSWD_FILE" > "$TMPFILE" || true 263 + mv "$TMPFILE" "$PASSWD_FILE" 264 + 265 + echo "Deleted user '$USERNAME'" 266 + exit 0 267 + fi 268 + 269 + # Should not reach here 270 + errcho "sysadminctl: internal error — unknown action '$ACTION'" 271 + exit 1
+244
tests/darling-smoke.nix
··· 1 + # NixOS VM smoke test: Darling basics 2 + # 3 + # A lightweight test that verifies Darling boots, the shell is functional, 4 + # and the key stubs (sandbox-exec, diskutil, Directory Services) are present 5 + # and minimally operational. This test does NOT require network access and 6 + # should complete in a few minutes. 7 + # 8 + # Usage: 9 + # nix build .#checks.x86_64-linux.darling-smoke -L 10 + # 11 + # See: plan/08-phase6-ci.md (Task 6.6) 12 + { pkgs, darling, ... }: 13 + 14 + let 15 + nixos-lib = import (pkgs.path + "/nixos/lib") { }; 16 + in 17 + nixos-lib.runTest { 18 + name = "darling-smoke"; 19 + 20 + hostPkgs = pkgs; 21 + 22 + nodes.machine = { config, pkgs, lib, ... }: { 23 + virtualisation = { 24 + memorySize = 2048; 25 + diskSize = 8192; 26 + cores = 2; 27 + }; 28 + 29 + environment.systemPackages = [ 30 + darling 31 + ]; 32 + 33 + # Darling needs unprivileged user namespaces 34 + boot.kernel.sysctl = { 35 + "kernel.unprivileged_userns_clone" = 1; 36 + }; 37 + 38 + environment.etc."fuse.conf".text = '' 39 + user_allow_other 40 + ''; 41 + }; 42 + 43 + testScript = '' 44 + import re 45 + 46 + machine.start() 47 + machine.wait_for_unit("default.target") 48 + 49 + # ── Stage 1: Darling binary and prefix initialisation ────────────── 50 + 51 + with machine.nested("Stage 1: Darling binary exists and prefix initialises"): 52 + machine.succeed("command -v darling") 53 + 54 + # First invocation creates the prefix — may take a while 55 + machine.succeed("darling shell true", timeout=120) 56 + 57 + # ── Stage 2: Basic shell functionality ───────────────────────────── 58 + 59 + with machine.nested("Stage 2: Basic shell functionality"): 60 + result = machine.succeed("darling shell echo 'hello from darling'") 61 + assert "hello from darling" in result, f"echo failed: {result}" 62 + 63 + # Environment variables pass through 64 + result = machine.succeed("darling shell /bin/bash -c 'echo $HOME'") 65 + assert result.strip(), f"HOME was empty: {result}" 66 + 67 + # Exit codes propagate 68 + machine.succeed("darling shell /bin/bash -c 'exit 0'") 69 + machine.fail("darling shell /bin/bash -c 'exit 1'") 70 + 71 + # ── Stage 3: macOS identity ──────────────────────────────────────── 72 + 73 + with machine.nested("Stage 3: macOS identity"): 74 + result = machine.succeed("darling shell uname -s") 75 + assert "Darwin" in result, f"Expected 'Darwin', got: {result}" 76 + 77 + result = machine.succeed("darling shell uname -m") 78 + assert "x86_64" in result, f"Expected 'x86_64', got: {result}" 79 + 80 + result = machine.succeed("darling shell sw_vers -productName") 81 + assert result.strip(), f"sw_vers productName was empty" 82 + 83 + result = machine.succeed("darling shell sw_vers -productVersion") 84 + assert re.search(r"\d+\.\d+", result), f"Bad version format: {result}" 85 + machine.log(f"Emulated macOS version: {result.strip()}") 86 + 87 + # ── Stage 4: Filesystem basics ───────────────────────────────────── 88 + 89 + with machine.nested("Stage 4: Filesystem basics"): 90 + # Can create and read files 91 + machine.succeed( 92 + "darling shell /bin/bash -c 'echo test-content > /tmp/smoke-test.txt'" 93 + ) 94 + result = machine.succeed("darling shell cat /tmp/smoke-test.txt") 95 + assert "test-content" in result, f"File content mismatch: {result}" 96 + 97 + # Can create directories 98 + machine.succeed("darling shell mkdir -p /tmp/smoke-dir/sub") 99 + machine.succeed("darling shell test -d /tmp/smoke-dir/sub") 100 + 101 + # Symlinks work 102 + machine.succeed( 103 + "darling shell ln -sf /tmp/smoke-test.txt /tmp/smoke-link" 104 + ) 105 + result = machine.succeed("darling shell cat /tmp/smoke-link") 106 + assert "test-content" in result, f"Symlink read failed: {result}" 107 + 108 + # Can remove files 109 + machine.succeed("darling shell rm /tmp/smoke-test.txt /tmp/smoke-link") 110 + machine.succeed("darling shell rm -rf /tmp/smoke-dir") 111 + 112 + # ── Stage 5: sandbox-exec stub ───────────────────────────────────── 113 + 114 + with machine.nested("Stage 5: sandbox-exec stub"): 115 + machine.succeed("darling shell test -x /usr/bin/sandbox-exec") 116 + 117 + # Basic passthrough 118 + result = machine.succeed( 119 + "darling shell /usr/bin/sandbox-exec -f /dev/null /bin/echo sandbox-ok" 120 + ) 121 + assert "sandbox-ok" in result, f"sandbox-exec passthrough failed: {result}" 122 + 123 + # With -p (inline profile string) 124 + result = machine.succeed( 125 + "darling shell /usr/bin/sandbox-exec -p '(version 1)(allow default)' " 126 + "/bin/echo inline-ok" 127 + ) 128 + assert "inline-ok" in result, f"sandbox-exec -p failed: {result}" 129 + 130 + # With -D key=value parameters (Nix pattern) 131 + result = machine.succeed( 132 + "darling shell /usr/bin/sandbox-exec " 133 + "-f /dev/null " 134 + "-D _GLOBAL_TMP_DIR=/tmp " 135 + "-D IMPORT_DIR=/tmp " 136 + "/bin/echo nix-pattern-ok" 137 + ) 138 + assert "nix-pattern-ok" in result, f"sandbox-exec -D failed: {result}" 139 + 140 + # Exit code forwarding 141 + machine.fail( 142 + "darling shell /usr/bin/sandbox-exec -f /dev/null /bin/bash -c 'exit 42'" 143 + ) 144 + 145 + # ── Stage 6: diskutil stub ───────────────────────────────────────── 146 + 147 + with machine.nested("Stage 6: diskutil stub"): 148 + machine.succeed("darling shell test -x /usr/sbin/diskutil") 149 + 150 + # diskutil info / 151 + result = machine.succeed("darling shell /usr/sbin/diskutil info /") 152 + assert "APFS" in result or "apfs" in result, ( 153 + f"Expected APFS in diskutil info output: {result}" 154 + ) 155 + 156 + # diskutil info -plist / 157 + result = machine.succeed("darling shell /usr/sbin/diskutil info -plist /") 158 + assert "FilesystemType" in result, ( 159 + f"Expected plist output from diskutil info -plist: {result}" 160 + ) 161 + assert "apfs" in result, f"Expected apfs in plist output: {result}" 162 + 163 + # diskutil list 164 + result = machine.succeed("darling shell /usr/sbin/diskutil list") 165 + assert "disk0" in result, f"Expected disk0 in list output: {result}" 166 + 167 + # ── Stage 7: Directory Services stubs ────────────────────────────── 168 + 169 + with machine.nested("Stage 7: Directory Services stubs"): 170 + for tool in ["dseditgroup", "sysadminctl", "dscl"]: 171 + machine.succeed(f"darling shell test -x /usr/sbin/{tool}") 172 + 173 + # Create a test group 174 + machine.succeed( 175 + "darling shell /usr/sbin/dseditgroup -o create -q -i 39999 smoketest" 176 + ) 177 + result = machine.succeed("darling shell grep smoketest /etc/group") 178 + assert "39999" in result, f"Group GID mismatch: {result}" 179 + 180 + # Create a test user 181 + machine.succeed( 182 + "darling shell /usr/sbin/sysadminctl -addUser _smoketest " 183 + "-UID 399 -GID 39999 -home /var/empty -shell /usr/bin/false" 184 + ) 185 + result = machine.succeed("darling shell grep _smoketest /etc/passwd") 186 + assert "399" in result, f"User UID mismatch: {result}" 187 + 188 + # Add user to group 189 + machine.succeed( 190 + "darling shell /usr/sbin/dseditgroup -o edit " 191 + "-a _smoketest -t user smoketest" 192 + ) 193 + 194 + # Verify via dscl 195 + result = machine.succeed( 196 + "darling shell /usr/sbin/dscl . -read /Groups/smoketest GroupMembership" 197 + ) 198 + assert "_smoketest" in result, f"Member not found via dscl: {result}" 199 + 200 + result = machine.succeed( 201 + "darling shell /usr/sbin/dscl . -read /Users/_smoketest UniqueID" 202 + ) 203 + assert "399" in result, f"UID not found via dscl: {result}" 204 + 205 + # Checkmember 206 + machine.succeed( 207 + "darling shell /usr/sbin/dseditgroup -o checkmember " 208 + "-m _smoketest smoketest" 209 + ) 210 + 211 + # Cleanup 212 + machine.succeed( 213 + "darling shell /usr/sbin/sysadminctl -deleteUser _smoketest" 214 + ) 215 + machine.succeed( 216 + "darling shell /usr/sbin/dseditgroup -o delete smoketest" 217 + ) 218 + 219 + # ── Stage 8: No unimplemented syscall warnings ───────────────────── 220 + 221 + with machine.nested("Stage 8: Check for unimplemented syscall warnings"): 222 + # Run a few operations and capture stderr for syscall warnings 223 + result = machine.succeed( 224 + "darling shell /bin/bash -c '" 225 + "echo test > /tmp/syscall-check.txt && " 226 + "cat /tmp/syscall-check.txt && " 227 + "mv /tmp/syscall-check.txt /tmp/syscall-check2.txt && " 228 + "ln -sf /tmp/syscall-check2.txt /tmp/syscall-link && " 229 + "ls -la /tmp/syscall-link && " 230 + "rm -f /tmp/syscall-check2.txt /tmp/syscall-link && " 231 + "echo done" 232 + "' 2>&1" 233 + ) 234 + # Warn but don't fail — some stubs may still print warnings 235 + if "Unimplemented syscall" in result or "STUB" in result: 236 + machine.log( 237 + f"WARNING: Found unimplemented syscall or STUB warnings:\n{result}" 238 + ) 239 + else: 240 + machine.log("No unimplemented syscall warnings detected ✓") 241 + 242 + machine.log("All Darling smoke tests passed! ✓") 243 + ''; 244 + }
+984
tests/dirserv/test_dirserv.sh
··· 1 + #!/bin/sh 2 + # test_dirserv.sh — Regression tests for Directory Services stubs 3 + # 4 + # Tests dseditgroup, sysadminctl, and dscl stubs that translate 5 + # macOS Directory Services commands to /etc/passwd and /etc/group 6 + # file operations within a Darling prefix. 7 + # 8 + # Usage: 9 + # sh test_dirserv.sh 10 + # 11 + # Exit code: 12 + # 0 — all tests passed 13 + # 1 — one or more tests failed 14 + # 15 + # See: plan/07-phase5-daemon.md (Task 5.1) 16 + 17 + set -eu 18 + 19 + # ── Test framework ────────────────────────────────────────────────────────── 20 + 21 + TESTS_RUN=0 22 + TESTS_PASSED=0 23 + TESTS_FAILED=0 24 + CURRENT_TEST="" 25 + 26 + pass() { 27 + TESTS_PASSED=$((TESTS_PASSED + 1)) 28 + echo " PASS: $CURRENT_TEST" 29 + } 30 + 31 + fail() { 32 + TESTS_FAILED=$((TESTS_FAILED + 1)) 33 + echo " FAIL: $CURRENT_TEST — $1" 34 + } 35 + 36 + run_test() { 37 + TESTS_RUN=$((TESTS_RUN + 1)) 38 + CURRENT_TEST="$1" 39 + } 40 + 41 + # ── Setup ─────────────────────────────────────────────────────────────────── 42 + 43 + WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/test_dirserv.XXXXXX") 44 + trap 'rm -rf "$WORKDIR"' EXIT 45 + 46 + # We need stub /etc/passwd and /etc/group for the tools to operate on. 47 + # The tools reference /etc/passwd and /etc/group directly, so we use 48 + # wrapper scripts that override those paths. 49 + 50 + PASSWD_FILE="$WORKDIR/passwd" 51 + GROUP_FILE="$WORKDIR/group" 52 + 53 + # Seed with minimal entries (root user/group) 54 + cat > "$PASSWD_FILE" <<'EOF' 55 + root:x:0:0:System Administrator:/var/root:/bin/sh 56 + nobody:x:-2:-2:Unprivileged User:/var/empty:/usr/bin/false 57 + daemon:x:1:1:System Services:/var/root:/usr/bin/false 58 + EOF 59 + 60 + cat > "$GROUP_FILE" <<'EOF' 61 + wheel:x:0:root 62 + daemon:x:1:root 63 + nobody:x:-2: 64 + staff:x:20:root 65 + EOF 66 + 67 + # Find the scripts — they could be in a few locations 68 + SCRIPT_DIR="" 69 + for candidate in \ 70 + "$(dirname "$0")/../../src/dirserv" \ 71 + "/usr/sbin" \ 72 + "$(dirname "$0")/../src/dirserv"; do 73 + if [ -f "$candidate/dseditgroup" ]; then 74 + SCRIPT_DIR="$candidate" 75 + break 76 + fi 77 + done 78 + 79 + if [ -z "$SCRIPT_DIR" ]; then 80 + echo "ERROR: Cannot find Directory Services stubs (dseditgroup, sysadminctl, dscl)" 81 + echo " Looked in: ../../src/dirserv, /usr/sbin, ../src/dirserv" 82 + exit 2 83 + fi 84 + 85 + # Create wrapper scripts that redirect /etc/passwd and /etc/group 86 + # to our test files by modifying the variables the stubs use. 87 + # Since the stubs use hardcoded /etc/passwd and /etc/group, we create 88 + # thin wrappers that set up a temporary overlay. 89 + # 90 + # Actually, the stubs reference /etc/passwd and /etc/group directly. 91 + # We'll use a chroot-like approach: create a fake root and run from there. 92 + # But that requires root. Instead, let's create modified copies of the 93 + # stubs that use our test paths. 94 + 95 + mkdir -p "$WORKDIR/bin" 96 + 97 + for tool in dseditgroup sysadminctl dscl; do 98 + sed \ 99 + -e "s|/etc/passwd|${PASSWD_FILE}|g" \ 100 + -e "s|/etc/group|${GROUP_FILE}|g" \ 101 + "$SCRIPT_DIR/$tool" > "$WORKDIR/bin/$tool" 102 + chmod +x "$WORKDIR/bin/$tool" 103 + done 104 + 105 + DSEDITGROUP="$WORKDIR/bin/dseditgroup" 106 + SYSADMINCTL="$WORKDIR/bin/sysadminctl" 107 + DSCL="$WORKDIR/bin/dscl" 108 + 109 + echo "═══════════════════════════════════════════════════════════" 110 + echo " Directory Services Stubs — Regression Tests" 111 + echo "═══════════════════════════════════════════════════════════" 112 + echo "" 113 + echo " Tools: $SCRIPT_DIR" 114 + echo " Passwd file: $PASSWD_FILE" 115 + echo " Group file: $GROUP_FILE" 116 + echo "" 117 + 118 + # ═══════════════════════════════════════════════════════════════════════════ 119 + # dseditgroup tests 120 + # ═══════════════════════════════════════════════════════════════════════════ 121 + 122 + echo "── dseditgroup ──────────────────────────────────────────────" 123 + 124 + # --- create --- 125 + 126 + run_test "dseditgroup: create group with explicit GID" 127 + if $DSEDITGROUP -o create -q -i 30000 nixbld 2>/dev/null; then 128 + if grep -q "^nixbld:x:30000:" "$GROUP_FILE"; then 129 + pass 130 + else 131 + fail "group entry not found or GID mismatch" 132 + fi 133 + else 134 + fail "command returned non-zero" 135 + fi 136 + 137 + run_test "dseditgroup: create is idempotent (no error on duplicate)" 138 + if $DSEDITGROUP -o create -q -i 30000 nixbld 2>/dev/null; then 139 + # Count how many nixbld lines exist — should be exactly 1 140 + count=$(grep -c "^nixbld:" "$GROUP_FILE") 141 + if [ "$count" -eq 1 ]; then 142 + pass 143 + else 144 + fail "expected 1 entry, got $count" 145 + fi 146 + else 147 + fail "command returned non-zero on duplicate" 148 + fi 149 + 150 + run_test "dseditgroup: create group with auto-assigned GID" 151 + if $DSEDITGROUP -o create -q testgroup 2>/dev/null; then 152 + if grep -q "^testgroup:" "$GROUP_FILE"; then 153 + pass 154 + else 155 + fail "group entry not found" 156 + fi 157 + else 158 + fail "command returned non-zero" 159 + fi 160 + 161 + run_test "dseditgroup: create rejects duplicate GID" 162 + if $DSEDITGROUP -o create -q -i 30000 othergroup 2>"$WORKDIR/stderr.tmp"; then 163 + fail "should have failed with duplicate GID" 164 + else 165 + pass 166 + fi 167 + 168 + run_test "dseditgroup: create rejects invalid group name" 169 + if $DSEDITGROUP -o create -q 'bad name!' 2>/dev/null; then 170 + fail "should have rejected invalid name" 171 + else 172 + pass 173 + fi 174 + 175 + # --- edit (add member) --- 176 + 177 + run_test "dseditgroup: add user to group" 178 + if $DSEDITGROUP -o edit -a _nixbld1 -t user nixbld 2>/dev/null; then 179 + members=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE") 180 + case ",$members," in 181 + *,_nixbld1,*) pass ;; 182 + *) fail "user not in member list (got: '$members')" ;; 183 + esac 184 + else 185 + fail "command returned non-zero" 186 + fi 187 + 188 + run_test "dseditgroup: add second user to group" 189 + if $DSEDITGROUP -o edit -a _nixbld2 -t user nixbld 2>/dev/null; then 190 + members=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE") 191 + has1=0; has2=0 192 + case ",$members," in *,_nixbld1,*) has1=1 ;; esac 193 + case ",$members," in *,_nixbld2,*) has2=1 ;; esac 194 + if [ "$has1" -eq 1 ] && [ "$has2" -eq 1 ]; then 195 + pass 196 + else 197 + fail "expected both users in list (got: '$members')" 198 + fi 199 + else 200 + fail "command returned non-zero" 201 + fi 202 + 203 + run_test "dseditgroup: add user is idempotent" 204 + if $DSEDITGROUP -o edit -a _nixbld1 -t user nixbld 2>/dev/null; then 205 + count=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE" | tr ',' '\n' | grep -c "^_nixbld1$") 206 + if [ "$count" -eq 1 ]; then 207 + pass 208 + else 209 + fail "user appears $count times" 210 + fi 211 + else 212 + fail "command returned non-zero" 213 + fi 214 + 215 + run_test "dseditgroup: add user to nonexistent group fails" 216 + if $DSEDITGROUP -o edit -a foo -t user nosuchgroup 2>/dev/null; then 217 + fail "should have failed for nonexistent group" 218 + else 219 + pass 220 + fi 221 + 222 + # --- edit (remove member) --- 223 + 224 + run_test "dseditgroup: remove user from group" 225 + if $DSEDITGROUP -o edit -d _nixbld2 -t user nixbld 2>/dev/null; then 226 + members=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE") 227 + case ",$members," in 228 + *,_nixbld2,*) fail "_nixbld2 still in member list (got: '$members')" ;; 229 + *) pass ;; 230 + esac 231 + else 232 + fail "command returned non-zero" 233 + fi 234 + 235 + # --- checkmember --- 236 + 237 + run_test "dseditgroup: checkmember returns 0 for member" 238 + if $DSEDITGROUP -o checkmember -m _nixbld1 nixbld >/dev/null 2>&1; then 239 + pass 240 + else 241 + fail "expected exit 0 for existing member" 242 + fi 243 + 244 + run_test "dseditgroup: checkmember returns non-zero for non-member" 245 + if $DSEDITGROUP -o checkmember -m _nixbld99 nixbld >/dev/null 2>&1; then 246 + fail "expected non-zero for non-member" 247 + else 248 + pass 249 + fi 250 + 251 + run_test "dseditgroup: checkmember on nonexistent group fails" 252 + if $DSEDITGROUP -o checkmember -m root nosuchgroup 2>/dev/null; then 253 + fail "should have failed for nonexistent group" 254 + else 255 + pass 256 + fi 257 + 258 + # --- read --- 259 + 260 + run_test "dseditgroup: read group prints PrimaryGroupID" 261 + output=$($DSEDITGROUP -o read nixbld 2>/dev/null) 262 + if echo "$output" | grep -q "PrimaryGroupID: 30000"; then 263 + pass 264 + else 265 + fail "expected PrimaryGroupID: 30000 in output" 266 + fi 267 + 268 + run_test "dseditgroup: read group prints GroupMembership" 269 + output=$($DSEDITGROUP -o read nixbld 2>/dev/null) 270 + if echo "$output" | grep -q "GroupMembership:.*_nixbld1"; then 271 + pass 272 + else 273 + fail "expected GroupMembership containing _nixbld1" 274 + fi 275 + 276 + # --- delete --- 277 + 278 + run_test "dseditgroup: delete group" 279 + # First create a throwaway group 280 + $DSEDITGROUP -o create -q -i 99999 throwaway 2>/dev/null || true 281 + if $DSEDITGROUP -o delete throwaway 2>/dev/null; then 282 + if grep -q "^throwaway:" "$GROUP_FILE"; then 283 + fail "group still exists after delete" 284 + else 285 + pass 286 + fi 287 + else 288 + fail "command returned non-zero" 289 + fi 290 + 291 + run_test "dseditgroup: delete nonexistent group is idempotent" 292 + if $DSEDITGROUP -o delete throwaway 2>/dev/null; then 293 + pass 294 + else 295 + fail "should succeed silently for nonexistent group" 296 + fi 297 + 298 + # --- usage / error handling --- 299 + 300 + run_test "dseditgroup: no arguments prints usage" 301 + if $DSEDITGROUP 2>/dev/null; then 302 + fail "should have returned non-zero" 303 + else 304 + pass 305 + fi 306 + 307 + run_test "dseditgroup: unknown operation fails" 308 + if $DSEDITGROUP -o frobnicate testgroup 2>/dev/null; then 309 + fail "should have failed for unknown operation" 310 + else 311 + pass 312 + fi 313 + 314 + 315 + # ═══════════════════════════════════════════════════════════════════════════ 316 + # sysadminctl tests 317 + # ═══════════════════════════════════════════════════════════════════════════ 318 + 319 + echo "" 320 + echo "── sysadminctl ──────────────────────────────────────────────" 321 + 322 + run_test "sysadminctl: create user with all options" 323 + if $SYSADMINCTL -addUser _nixbld1 -UID 300 -GID 30000 \ 324 + -home /var/empty -shell /usr/bin/false 2>/dev/null; then 325 + if grep -q "^_nixbld1:x:300:30000:" "$PASSWD_FILE"; then 326 + pass 327 + else 328 + fail "user entry not found or fields mismatch" 329 + fi 330 + else 331 + fail "command returned non-zero" 332 + fi 333 + 334 + run_test "sysadminctl: create user is idempotent" 335 + if $SYSADMINCTL -addUser _nixbld1 -UID 300 -GID 30000 2>/dev/null; then 336 + count=$(grep -c "^_nixbld1:" "$PASSWD_FILE") 337 + if [ "$count" -eq 1 ]; then 338 + pass 339 + else 340 + fail "expected 1 entry, got $count" 341 + fi 342 + else 343 + fail "command returned non-zero on duplicate" 344 + fi 345 + 346 + run_test "sysadminctl: create user with defaults" 347 + if $SYSADMINCTL -addUser _nixbld2 -UID 301 -GID 30000 2>/dev/null; then 348 + line=$(grep "^_nixbld2:" "$PASSWD_FILE") 349 + if echo "$line" | grep -q "/var/empty"; then 350 + pass 351 + else 352 + fail "default home not set (got: '$line')" 353 + fi 354 + else 355 + fail "command returned non-zero" 356 + fi 357 + 358 + run_test "sysadminctl: create user with fullName" 359 + if $SYSADMINCTL -addUser _nixbld3 -UID 302 -GID 30000 \ 360 + -fullName "Nix Build User 3" -home /var/empty -shell /usr/bin/false 2>/dev/null; then 361 + gecos=$(awk -F: '$1 == "_nixbld3" { print $5 }' "$PASSWD_FILE") 362 + if [ "$gecos" = "Nix Build User 3" ]; then 363 + pass 364 + else 365 + fail "GECOS mismatch (got: '$gecos')" 366 + fi 367 + else 368 + fail "command returned non-zero" 369 + fi 370 + 371 + run_test "sysadminctl: create rejects duplicate UID" 372 + if $SYSADMINCTL -addUser _dupeuid -UID 300 -GID 30000 2>"$WORKDIR/stderr.tmp"; then 373 + fail "should have failed with duplicate UID" 374 + else 375 + pass 376 + fi 377 + 378 + run_test "sysadminctl: create rejects non-numeric UID" 379 + if $SYSADMINCTL -addUser _baduid -UID abc -GID 30000 2>/dev/null; then 380 + fail "should have rejected non-numeric UID" 381 + else 382 + pass 383 + fi 384 + 385 + run_test "sysadminctl: create rejects invalid username" 386 + if $SYSADMINCTL -addUser "bad user!" 2>/dev/null; then 387 + fail "should have rejected invalid username" 388 + else 389 + pass 390 + fi 391 + 392 + run_test "sysadminctl: ignores -password option" 393 + if $SYSADMINCTL -addUser _nixbld4 -UID 303 -GID 30000 \ 394 + -home /var/empty -shell /usr/bin/false -password "secret" 2>/dev/null; then 395 + if grep -q "^_nixbld4:x:" "$PASSWD_FILE"; then 396 + pass 397 + else 398 + fail "user not created or password field not 'x'" 399 + fi 400 + else 401 + fail "command returned non-zero" 402 + fi 403 + 404 + run_test "sysadminctl: ignores -adminUser and -adminPassword" 405 + if $SYSADMINCTL -addUser _nixbld5 -UID 304 -GID 30000 \ 406 + -home /var/empty -shell /usr/bin/false \ 407 + -adminUser admin -adminPassword pw 2>/dev/null; then 408 + if grep -q "^_nixbld5:" "$PASSWD_FILE"; then 409 + pass 410 + else 411 + fail "user not created" 412 + fi 413 + else 414 + fail "command returned non-zero" 415 + fi 416 + 417 + run_test "sysadminctl: handles -roleAccount flag" 418 + if $SYSADMINCTL -addUser _nixbld6 -UID 305 -GID 30000 \ 419 + -home /var/empty -shell /usr/bin/false -roleAccount 2>/dev/null; then 420 + if grep -q "^_nixbld6:" "$PASSWD_FILE"; then 421 + pass 422 + else 423 + fail "user not created" 424 + fi 425 + else 426 + fail "command returned non-zero" 427 + fi 428 + 429 + # --- deleteUser --- 430 + 431 + run_test "sysadminctl: delete user" 432 + # First verify the user exists 433 + if ! grep -q "^_nixbld4:" "$PASSWD_FILE"; then 434 + fail "prerequisite: _nixbld4 doesn't exist" 435 + else 436 + if $SYSADMINCTL -deleteUser _nixbld4 2>/dev/null; then 437 + if grep -q "^_nixbld4:" "$PASSWD_FILE"; then 438 + fail "user still exists after delete" 439 + else 440 + pass 441 + fi 442 + else 443 + fail "command returned non-zero" 444 + fi 445 + fi 446 + 447 + run_test "sysadminctl: delete nonexistent user is idempotent" 448 + if $SYSADMINCTL -deleteUser _nosuchuser 2>/dev/null; then 449 + pass 450 + else 451 + fail "should succeed silently for nonexistent user" 452 + fi 453 + 454 + # --- usage / error handling --- 455 + 456 + run_test "sysadminctl: no arguments prints usage" 457 + if $SYSADMINCTL 2>/dev/null; then 458 + fail "should have returned non-zero" 459 + else 460 + pass 461 + fi 462 + 463 + run_test "sysadminctl: unknown option fails" 464 + if $SYSADMINCTL -frobnicate 2>/dev/null; then 465 + fail "should have failed for unknown option" 466 + else 467 + pass 468 + fi 469 + 470 + 471 + # ═══════════════════════════════════════════════════════════════════════════ 472 + # dscl tests 473 + # ═══════════════════════════════════════════════════════════════════════════ 474 + 475 + echo "" 476 + echo "── dscl ─────────────────────────────────────────────────────" 477 + 478 + # --- -read /Users --- 479 + 480 + run_test "dscl: read user UniqueID" 481 + output=$($DSCL . -read /Users/root UniqueID 2>/dev/null) 482 + if echo "$output" | grep -q "UniqueID: 0"; then 483 + pass 484 + else 485 + fail "expected 'UniqueID: 0' (got: '$output')" 486 + fi 487 + 488 + run_test "dscl: read user PrimaryGroupID" 489 + output=$($DSCL . -read /Users/_nixbld1 PrimaryGroupID 2>/dev/null) 490 + if echo "$output" | grep -q "PrimaryGroupID: 30000"; then 491 + pass 492 + else 493 + fail "expected 'PrimaryGroupID: 30000' (got: '$output')" 494 + fi 495 + 496 + run_test "dscl: read user NFSHomeDirectory" 497 + output=$($DSCL . -read /Users/_nixbld1 NFSHomeDirectory 2>/dev/null) 498 + if echo "$output" | grep -q "NFSHomeDirectory: /var/empty"; then 499 + pass 500 + else 501 + fail "expected home '/var/empty' (got: '$output')" 502 + fi 503 + 504 + run_test "dscl: read user UserShell" 505 + output=$($DSCL . -read /Users/_nixbld1 UserShell 2>/dev/null) 506 + if echo "$output" | grep -q "UserShell: /usr/bin/false"; then 507 + pass 508 + else 509 + fail "expected shell '/usr/bin/false' (got: '$output')" 510 + fi 511 + 512 + run_test "dscl: read all user keys (no specific key)" 513 + output=$($DSCL . -read /Users/root 2>/dev/null) 514 + if echo "$output" | grep -q "RecordName: root" && \ 515 + echo "$output" | grep -q "UniqueID: 0"; then 516 + pass 517 + else 518 + fail "expected full record output" 519 + fi 520 + 521 + run_test "dscl: read nonexistent user fails" 522 + if $DSCL . -read /Users/nosuchuser 2>/dev/null; then 523 + fail "should have failed for nonexistent user" 524 + else 525 + pass 526 + fi 527 + 528 + # --- -read /Groups --- 529 + 530 + run_test "dscl: read group PrimaryGroupID" 531 + output=$($DSCL . -read /Groups/nixbld PrimaryGroupID 2>/dev/null) 532 + if echo "$output" | grep -q "PrimaryGroupID: 30000"; then 533 + pass 534 + else 535 + fail "expected 'PrimaryGroupID: 30000' (got: '$output')" 536 + fi 537 + 538 + run_test "dscl: read group GroupMembership" 539 + output=$($DSCL . -read /Groups/nixbld GroupMembership 2>/dev/null) 540 + if echo "$output" | grep -q "_nixbld1"; then 541 + pass 542 + else 543 + fail "expected _nixbld1 in GroupMembership (got: '$output')" 544 + fi 545 + 546 + run_test "dscl: read nonexistent group fails" 547 + if $DSCL . -read /Groups/nosuchgroup 2>/dev/null; then 548 + fail "should have failed for nonexistent group" 549 + else 550 + pass 551 + fi 552 + 553 + # --- -list --- 554 + 555 + run_test "dscl: list /Users (names only)" 556 + output=$($DSCL . -list /Users 2>/dev/null) 557 + if echo "$output" | grep -q "^root$" && echo "$output" | grep -q "^_nixbld1$"; then 558 + pass 559 + else 560 + fail "expected root and _nixbld1 in user list" 561 + fi 562 + 563 + run_test "dscl: list /Users UniqueID" 564 + output=$($DSCL . -list /Users UniqueID 2>/dev/null) 565 + if echo "$output" | grep -q "root.*0"; then 566 + pass 567 + else 568 + fail "expected root with UID 0" 569 + fi 570 + 571 + run_test "dscl: list /Groups (names only)" 572 + output=$($DSCL . -list /Groups 2>/dev/null) 573 + if echo "$output" | grep -q "^nixbld$" && echo "$output" | grep -q "^wheel$"; then 574 + pass 575 + else 576 + fail "expected nixbld and wheel in group list" 577 + fi 578 + 579 + run_test "dscl: list /Groups PrimaryGroupID" 580 + output=$($DSCL . -list /Groups PrimaryGroupID 2>/dev/null) 581 + if echo "$output" | grep -q "nixbld.*30000"; then 582 + pass 583 + else 584 + fail "expected nixbld with GID 30000" 585 + fi 586 + 587 + # --- -create --- 588 + 589 + run_test "dscl: create user record" 590 + if $DSCL . -create /Users/_testuser 2>/dev/null; then 591 + if grep -q "^_testuser:" "$PASSWD_FILE"; then 592 + pass 593 + else 594 + fail "user not created" 595 + fi 596 + else 597 + fail "command returned non-zero" 598 + fi 599 + 600 + run_test "dscl: create user and set UniqueID" 601 + if $DSCL . -create /Users/_testuser UniqueID 500 2>/dev/null; then 602 + uid=$(awk -F: '$1 == "_testuser" { print $3 }' "$PASSWD_FILE") 603 + if [ "$uid" = "500" ]; then 604 + pass 605 + else 606 + fail "UID mismatch (got: '$uid')" 607 + fi 608 + else 609 + fail "command returned non-zero" 610 + fi 611 + 612 + run_test "dscl: create user and set NFSHomeDirectory" 613 + if $DSCL . -create /Users/_testuser NFSHomeDirectory /Users/_testuser 2>/dev/null; then 614 + home=$(awk -F: '$1 == "_testuser" { print $6 }' "$PASSWD_FILE") 615 + if [ "$home" = "/Users/_testuser" ]; then 616 + pass 617 + else 618 + fail "home mismatch (got: '$home')" 619 + fi 620 + else 621 + fail "command returned non-zero" 622 + fi 623 + 624 + run_test "dscl: create user and set UserShell" 625 + if $DSCL . -create /Users/_testuser UserShell /bin/bash 2>/dev/null; then 626 + shell=$(awk -F: '$1 == "_testuser" { print $7 }' "$PASSWD_FILE") 627 + if [ "$shell" = "/bin/bash" ]; then 628 + pass 629 + else 630 + fail "shell mismatch (got: '$shell')" 631 + fi 632 + else 633 + fail "command returned non-zero" 634 + fi 635 + 636 + run_test "dscl: create user and set RealName" 637 + if $DSCL . -create /Users/_testuser RealName "Test User" 2>/dev/null; then 638 + gecos=$(awk -F: '$1 == "_testuser" { print $5 }' "$PASSWD_FILE") 639 + if [ "$gecos" = "Test User" ]; then 640 + pass 641 + else 642 + fail "GECOS mismatch (got: '$gecos')" 643 + fi 644 + else 645 + fail "command returned non-zero" 646 + fi 647 + 648 + run_test "dscl: create group record" 649 + if $DSCL . -create /Groups/testgrp 2>/dev/null; then 650 + if grep -q "^testgrp:" "$GROUP_FILE"; then 651 + pass 652 + else 653 + fail "group not created" 654 + fi 655 + else 656 + fail "command returned non-zero" 657 + fi 658 + 659 + run_test "dscl: create group and set PrimaryGroupID" 660 + if $DSCL . -create /Groups/testgrp PrimaryGroupID 50000 2>/dev/null; then 661 + gid=$(awk -F: '$1 == "testgrp" { print $3 }' "$GROUP_FILE") 662 + if [ "$gid" = "50000" ]; then 663 + pass 664 + else 665 + fail "GID mismatch (got: '$gid')" 666 + fi 667 + else 668 + fail "command returned non-zero" 669 + fi 670 + 671 + run_test "dscl: create ignores IsHidden key" 672 + if $DSCL . -create /Users/_testuser IsHidden 1 2>/dev/null; then 673 + pass 674 + else 675 + fail "should silently ignore IsHidden" 676 + fi 677 + 678 + # --- -delete --- 679 + 680 + run_test "dscl: delete user record" 681 + if $DSCL . -delete /Users/_testuser 2>/dev/null; then 682 + if grep -q "^_testuser:" "$PASSWD_FILE"; then 683 + fail "user still exists after delete" 684 + else 685 + pass 686 + fi 687 + else 688 + fail "command returned non-zero" 689 + fi 690 + 691 + run_test "dscl: delete nonexistent user is idempotent" 692 + if $DSCL . -delete /Users/_testuser 2>/dev/null; then 693 + pass 694 + else 695 + fail "should succeed silently for nonexistent user" 696 + fi 697 + 698 + run_test "dscl: delete group record" 699 + if $DSCL . -delete /Groups/testgrp 2>/dev/null; then 700 + if grep -q "^testgrp:" "$GROUP_FILE"; then 701 + fail "group still exists after delete" 702 + else 703 + pass 704 + fi 705 + else 706 + fail "command returned non-zero" 707 + fi 708 + 709 + # --- -append --- 710 + 711 + run_test "dscl: append GroupMembership" 712 + if $DSCL . -append /Groups/nixbld GroupMembership _nixbld10 2>/dev/null; then 713 + members=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE") 714 + case ",$members," in 715 + *,_nixbld10,*) pass ;; 716 + *) fail "_nixbld10 not in member list (got: '$members')" ;; 717 + esac 718 + else 719 + fail "command returned non-zero" 720 + fi 721 + 722 + run_test "dscl: append GroupMembership is idempotent" 723 + if $DSCL . -append /Groups/nixbld GroupMembership _nixbld10 2>/dev/null; then 724 + count=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE" | tr ',' '\n' | grep -c "^_nixbld10$") 725 + if [ "$count" -eq 1 ]; then 726 + pass 727 + else 728 + fail "_nixbld10 appears $count times" 729 + fi 730 + else 731 + fail "command returned non-zero" 732 + fi 733 + 734 + run_test "dscl: append to nonexistent group fails" 735 + if $DSCL . -append /Groups/nosuchgroup GroupMembership user1 2>/dev/null; then 736 + fail "should have failed for nonexistent group" 737 + else 738 + pass 739 + fi 740 + 741 + # --- -search --- 742 + 743 + run_test "dscl: search /Users by UniqueID" 744 + output=$($DSCL . -search /Users UniqueID 300 2>/dev/null) 745 + if echo "$output" | grep -q "_nixbld1"; then 746 + pass 747 + else 748 + fail "expected _nixbld1 in search results (got: '$output')" 749 + fi 750 + 751 + run_test "dscl: search /Users by UniqueID (no match)" 752 + output=$($DSCL . -search /Users UniqueID 99999 2>/dev/null) 753 + if [ -z "$output" ]; then 754 + pass 755 + else 756 + fail "expected empty results (got: '$output')" 757 + fi 758 + 759 + run_test "dscl: search /Groups by PrimaryGroupID" 760 + output=$($DSCL . -search /Groups PrimaryGroupID 30000 2>/dev/null) 761 + if echo "$output" | grep -q "nixbld"; then 762 + pass 763 + else 764 + fail "expected nixbld in search results (got: '$output')" 765 + fi 766 + 767 + run_test "dscl: search /Users by RecordName" 768 + output=$($DSCL . -search /Users RecordName root 2>/dev/null) 769 + if echo "$output" | grep -q "root"; then 770 + pass 771 + else 772 + fail "expected root in search results (got: '$output')" 773 + fi 774 + 775 + # --- datasource handling --- 776 + 777 + run_test "dscl: accepts /Local/Default as datasource" 778 + output=$($DSCL /Local/Default -list /Users 2>/dev/null) 779 + if echo "$output" | grep -q "^root$"; then 780 + pass 781 + else 782 + fail "expected user list with /Local/Default datasource" 783 + fi 784 + 785 + run_test "dscl: rejects unsupported datasource" 786 + if $DSCL /LDAPv3/ldap.example.com -list /Users 2>/dev/null; then 787 + fail "should have rejected unsupported datasource" 788 + else 789 + pass 790 + fi 791 + 792 + # --- error handling --- 793 + 794 + run_test "dscl: no arguments prints usage" 795 + if $DSCL 2>/dev/null; then 796 + fail "should have returned non-zero" 797 + else 798 + pass 799 + fi 800 + 801 + run_test "dscl: unknown command fails" 802 + if $DSCL . -frobnicate /Users 2>/dev/null; then 803 + fail "should have failed for unknown command" 804 + else 805 + pass 806 + fi 807 + 808 + 809 + # ═══════════════════════════════════════════════════════════════════════════ 810 + # Integration: Nix installer simulation 811 + # ═══════════════════════════════════════════════════════════════════════════ 812 + 813 + echo "" 814 + echo "── Integration: Nix installer simulation ────────────────────" 815 + 816 + # Reset the files for a clean integration test 817 + cat > "$PASSWD_FILE" <<'EOF' 818 + root:x:0:0:System Administrator:/var/root:/bin/sh 819 + nobody:x:-2:-2:Unprivileged User:/var/empty:/usr/bin/false 820 + EOF 821 + 822 + cat > "$GROUP_FILE" <<'EOF' 823 + wheel:x:0:root 824 + nobody:x:-2: 825 + staff:x:20:root 826 + EOF 827 + 828 + run_test "integration: create nixbld group (dseditgroup)" 829 + $DSEDITGROUP -o create -q -i 30000 nixbld 2>/dev/null 830 + if grep -q "^nixbld:x:30000:" "$GROUP_FILE"; then 831 + pass 832 + else 833 + fail "nixbld group not created" 834 + fi 835 + 836 + run_test "integration: create 5 build users (sysadminctl)" 837 + ALL_OK=1 838 + for i in 1 2 3 4 5; do 839 + uid=$((299 + i)) 840 + if ! $SYSADMINCTL -addUser "_nixbld${i}" -UID "$uid" -GID 30000 \ 841 + -home /var/empty -shell /usr/bin/false \ 842 + -fullName "Nix Build User ${i}" 2>/dev/null; then 843 + ALL_OK=0 844 + break 845 + fi 846 + done 847 + if [ "$ALL_OK" -eq 1 ]; then 848 + count=$(grep -c "^_nixbld" "$PASSWD_FILE") 849 + if [ "$count" -eq 5 ]; then 850 + pass 851 + else 852 + fail "expected 5 build users, got $count" 853 + fi 854 + else 855 + fail "sysadminctl failed during user creation" 856 + fi 857 + 858 + run_test "integration: add all build users to nixbld group (dseditgroup)" 859 + ALL_OK=1 860 + for i in 1 2 3 4 5; do 861 + if ! $DSEDITGROUP -o edit -a "_nixbld${i}" -t user nixbld 2>/dev/null; then 862 + ALL_OK=0 863 + break 864 + fi 865 + done 866 + if [ "$ALL_OK" -eq 1 ]; then 867 + members=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE") 868 + # Verify all 5 are present 869 + member_count=$(echo "$members" | tr ',' '\n' | grep -c "^_nixbld") 870 + if [ "$member_count" -eq 5 ]; then 871 + pass 872 + else 873 + fail "expected 5 members, got $member_count (members: '$members')" 874 + fi 875 + else 876 + fail "dseditgroup edit failed" 877 + fi 878 + 879 + run_test "integration: verify via dscl -read" 880 + output=$($DSCL . -read /Groups/nixbld GroupMembership 2>/dev/null) 881 + if echo "$output" | grep -q "_nixbld1" && \ 882 + echo "$output" | grep -q "_nixbld5"; then 883 + pass 884 + else 885 + fail "dscl read didn't show expected members" 886 + fi 887 + 888 + run_test "integration: verify UIDs via dscl -search" 889 + output=$($DSCL . -search /Users UniqueID 300 2>/dev/null) 890 + if echo "$output" | grep -q "_nixbld1"; then 891 + pass 892 + else 893 + fail "dscl search didn't find _nixbld1 with UID 300" 894 + fi 895 + 896 + run_test "integration: verify via dscl -list" 897 + output=$($DSCL . -list /Users UniqueID 2>/dev/null) 898 + if echo "$output" | grep -q "_nixbld1.*300" && \ 899 + echo "$output" | grep -q "_nixbld5.*304"; then 900 + pass 901 + else 902 + fail "dscl list didn't show expected users with UIDs" 903 + fi 904 + 905 + run_test "integration: checkmember for all build users" 906 + ALL_OK=1 907 + for i in 1 2 3 4 5; do 908 + if ! $DSEDITGROUP -o checkmember -m "_nixbld${i}" nixbld >/dev/null 2>&1; then 909 + ALL_OK=0 910 + break 911 + fi 912 + done 913 + if [ "$ALL_OK" -eq 1 ]; then 914 + pass 915 + else 916 + fail "checkmember failed for _nixbld${i}" 917 + fi 918 + 919 + run_test "integration: re-running create is idempotent (full sequence)" 920 + $DSEDITGROUP -o create -q -i 30000 nixbld 2>/dev/null 921 + for i in 1 2 3 4 5; do 922 + uid=$((299 + i)) 923 + $SYSADMINCTL -addUser "_nixbld${i}" -UID "$uid" -GID 30000 \ 924 + -home /var/empty -shell /usr/bin/false 2>/dev/null 925 + $DSEDITGROUP -o edit -a "_nixbld${i}" -t user nixbld 2>/dev/null 926 + done 927 + # Verify no duplicates 928 + group_count=$(grep -c "^nixbld:" "$GROUP_FILE") 929 + user_count=$(grep -c "^_nixbld1:" "$PASSWD_FILE") 930 + member_count=$(awk -F: '$1 == "nixbld" { print $4 }' "$GROUP_FILE" | tr ',' '\n' | grep -c "^_nixbld1$") 931 + if [ "$group_count" -eq 1 ] && [ "$user_count" -eq 1 ] && [ "$member_count" -eq 1 ]; then 932 + pass 933 + else 934 + fail "duplicates found: groups=$group_count, users=$user_count, memberships=$member_count" 935 + fi 936 + 937 + run_test "integration: cleanup — delete all build users" 938 + ALL_OK=1 939 + for i in 1 2 3 4 5; do 940 + if ! $SYSADMINCTL -deleteUser "_nixbld${i}" 2>/dev/null; then 941 + ALL_OK=0 942 + break 943 + fi 944 + done 945 + if [ "$ALL_OK" -eq 1 ]; then 946 + remaining=$(grep -c "^_nixbld" "$PASSWD_FILE" || true) 947 + if [ "$remaining" -eq 0 ]; then 948 + pass 949 + else 950 + fail "$remaining build users remain" 951 + fi 952 + else 953 + fail "deleteUser failed" 954 + fi 955 + 956 + run_test "integration: cleanup — delete nixbld group" 957 + if $DSEDITGROUP -o delete nixbld 2>/dev/null; then 958 + if grep -q "^nixbld:" "$GROUP_FILE"; then 959 + fail "group still exists" 960 + else 961 + pass 962 + fi 963 + else 964 + fail "delete failed" 965 + fi 966 + 967 + 968 + # ═══════════════════════════════════════════════════════════════════════════ 969 + # Summary 970 + # ═══════════════════════════════════════════════════════════════════════════ 971 + 972 + echo "" 973 + echo "═══════════════════════════════════════════════════════════" 974 + echo " Results: $TESTS_PASSED/$TESTS_RUN passed, $TESTS_FAILED failed" 975 + echo "═══════════════════════════════════════════════════════════" 976 + echo "" 977 + 978 + if [ "$TESTS_FAILED" -gt 0 ]; then 979 + echo "FAILED" 980 + exit 1 981 + else 982 + echo "ALL TESTS PASSED" 983 + exit 0 984 + fi
+342
tests/nix-in-darling.nix
··· 1 + # NixOS VM integration test: Nix-in-Darling 2 + # 3 + # This test exercises the full Nix-inside-Darling pipeline end-to-end 4 + # in a NixOS VM, which provides the kernel namespace support Darling needs. 5 + # 6 + # Test stages: 7 + # 1. Darling boots and `darling shell` is functional 8 + # 2. sandbox-exec stub is present and works 9 + # 3. Directory Services stubs are present 10 + # 4. Nix installs successfully inside the Darling prefix 11 + # 5. Core Nix commands work (version, eval, store) 12 + # 6. builtins.currentSystem reports x86_64-darwin 13 + # 7. Trivial derivation builds successfully 14 + # 15 + # Usage: 16 + # nix build .#checks.x86_64-linux.nix-in-darling -L 17 + # 18 + # See: plan/08-phase6-ci.md (Task 6.1) 19 + { pkgs, darling, ... }: 20 + 21 + let 22 + nixos-lib = import (pkgs.path + "/nixos/lib") { }; 23 + 24 + # Write derivation expressions to files so we avoid nested quoting 25 + # nightmares inside the Nix ''..'' / Python f-string / shell layers. 26 + trivialDrvFile = pkgs.writeText "trivial-drv.nix" '' 27 + derivation { 28 + name = "darling-test"; 29 + builder = "/bin/bash"; 30 + args = [ "-c" "echo ok > $out" ]; 31 + system = "x86_64-darwin"; 32 + } 33 + ''; 34 + 35 + multistepDrvFile = pkgs.writeText "multistep-drv.nix" '' 36 + derivation { 37 + name = "darling-multistep"; 38 + builder = "/bin/bash"; 39 + args = [ "-c" "mkdir -p $out/bin; echo hello > $out/bin/greeting; chmod 755 $out/bin" ]; 40 + system = "x86_64-darwin"; 41 + } 42 + ''; 43 + 44 + # Helper script that sources the Nix profile and runs a command. 45 + # Installed into the VM so tests can just call "nix-run <cmd>". 46 + nixRunHelper = pkgs.writeShellScript "nix-run" '' 47 + # Source whichever Nix profile script exists 48 + for p in \ 49 + /root/.nix-profile/etc/profile.d/nix.sh \ 50 + /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh \ 51 + /nix/var/nix/profiles/default/etc/profile.d/nix.sh; do 52 + if [ -e "$p" ]; then 53 + . "$p" 54 + break 55 + fi 56 + done 57 + exec "$@" 58 + ''; 59 + in 60 + nixos-lib.runTest { 61 + name = "nix-in-darling"; 62 + 63 + hostPkgs = pkgs; 64 + 65 + # The test runs inside a NixOS VM that has Darling installed. 66 + nodes.machine = 67 + { config, pkgs, lib, ... }: 68 + { 69 + # Give the VM enough resources — Darling + Nix store are disk/RAM hungry. 70 + virtualisation = { 71 + memorySize = 4096; 72 + diskSize = 20480; # 20 GB for Darling prefix + Nix store + builds 73 + cores = 4; 74 + writableStore = true; 75 + }; 76 + 77 + # Install Darling and basic tools 78 + environment.systemPackages = [ 79 + darling 80 + pkgs.curl 81 + pkgs.jq 82 + pkgs.sqlite 83 + ]; 84 + 85 + # Darling needs unprivileged user namespaces and overlayfs 86 + boot.kernel.sysctl = { 87 + "kernel.unprivileged_userns_clone" = 1; 88 + }; 89 + 90 + # FUSE support for darling-dmg 91 + environment.etc."fuse.conf".text = '' 92 + user_allow_other 93 + ''; 94 + 95 + # Generous timeout — Darling operations are slow 96 + systemd.services."getty@tty1".enable = false; 97 + }; 98 + 99 + testScript = '' 100 + import re 101 + 102 + machine.start() 103 + machine.wait_for_unit("default.target") 104 + 105 + # ── Stage 1: Darling boots and shell is functional ───────────────── 106 + 107 + with machine.nested("Stage 1: Darling boots and shell is functional"): 108 + # Verify darling binary is present 109 + machine.succeed("command -v darling") 110 + 111 + # Initialize the prefix (first run is slow) 112 + machine.succeed("darling shell true", timeout=120) 113 + 114 + # Basic shell functionality 115 + result = machine.succeed("darling shell echo 'Hello from Darling'") 116 + assert "Hello" in result, f"Expected 'Hello' in output, got: {result}" 117 + 118 + # Verify macOS version detection 119 + result = machine.succeed("darling shell sw_vers -productVersion") 120 + assert re.search(r"\d+\.\d+", result), f"Expected version string, got: {result}" 121 + machine.log(f"macOS version: {result.strip()}") 122 + 123 + # Verify uname reports Darwin 124 + result = machine.succeed("darling shell uname -s") 125 + assert "Darwin" in result, f"Expected 'Darwin', got: {result}" 126 + 127 + # ── Stage 2: sandbox-exec stub is present and works ──────────────── 128 + 129 + with machine.nested("Stage 2: sandbox-exec stub is present and works"): 130 + # Check the binary exists 131 + machine.succeed("darling shell test -x /usr/bin/sandbox-exec") 132 + 133 + # Run a command through sandbox-exec (should pass through) 134 + result = machine.succeed( 135 + "darling shell /usr/bin/sandbox-exec -f /dev/null /bin/echo sandbox-ok" 136 + ) 137 + assert "sandbox-ok" in result, f"Expected 'sandbox-ok', got: {result}" 138 + 139 + # Test with -D parameters (like Nix uses) 140 + result = machine.succeed( 141 + "darling shell /usr/bin/sandbox-exec " 142 + "-f /dev/null " 143 + "-D _GLOBAL_TMP_DIR=/tmp " 144 + "-D IMPORT_DIR=/tmp " 145 + "/bin/echo nix-pattern-ok" 146 + ) 147 + assert "nix-pattern-ok" in result, f"sandbox-exec with -D failed: {result}" 148 + 149 + # ── Stage 3: Directory Services stubs are present ────────────────── 150 + 151 + with machine.nested("Stage 3: Directory Services stubs are present"): 152 + # Verify the stubs exist and are executable 153 + for tool in ["dseditgroup", "sysadminctl", "dscl"]: 154 + machine.succeed(f"darling shell test -x /usr/sbin/{tool}") 155 + 156 + # Quick smoke test: create a group and user, then verify 157 + machine.succeed( 158 + "darling shell /usr/sbin/dseditgroup -o create -q -i 30000 nixbld" 159 + ) 160 + result = machine.succeed("darling shell grep nixbld /etc/group") 161 + assert "30000" in result, f"Expected GID 30000 in group entry, got: {result}" 162 + 163 + machine.succeed( 164 + "darling shell /usr/sbin/sysadminctl -addUser _nixbld1 " 165 + "-UID 300 -GID 30000 -home /var/empty -shell /usr/bin/false" 166 + ) 167 + result = machine.succeed("darling shell grep _nixbld1 /etc/passwd") 168 + assert "300" in result, f"Expected UID 300 in passwd entry, got: {result}" 169 + 170 + # Add user to group 171 + machine.succeed( 172 + "darling shell /usr/sbin/dseditgroup -o edit -a _nixbld1 -t user nixbld" 173 + ) 174 + 175 + # Verify with dscl 176 + result = machine.succeed( 177 + "darling shell /usr/sbin/dscl . -read /Groups/nixbld GroupMembership" 178 + ) 179 + assert "_nixbld1" in result, f"Expected _nixbld1 in membership, got: {result}" 180 + 181 + # ── Stage 4: Pre-configure and install Nix ───────────────────────── 182 + 183 + with machine.nested("Stage 4: Pre-configure and install Nix"): 184 + # Write nix.conf directly via the prefix filesystem (avoids shell quoting) 185 + machine.succeed("mkdir -p ~/.darling/etc/nix") 186 + machine.succeed( 187 + "cat > ~/.darling/etc/nix/nix.conf << 'CONF'\n" 188 + "build-users-group =\n" 189 + "sandbox = false\n" 190 + "experimental-features = nix-command flakes\n" 191 + "substitute = false\n" 192 + "CONF" 193 + ) 194 + 195 + # Create /nix directory 196 + machine.succeed("darling shell mkdir -p /nix") 197 + 198 + # Download the Nix installer on the host side and copy it in. 199 + # We use a known-good version to keep the test deterministic. 200 + nix_version = "2.24.12" 201 + installer_name = f"nix-{nix_version}-x86_64-darwin" 202 + installer_url = ( 203 + f"https://releases.nixos.org/nix/nix-{nix_version}/{installer_name}.tar.xz" 204 + ) 205 + 206 + machine.succeed( 207 + f"curl -fsSL -o /tmp/nix-installer.tar.xz {installer_url}", 208 + timeout=300, 209 + ) 210 + machine.succeed( 211 + "mkdir -p /tmp/nix-install && " 212 + "tar -xf /tmp/nix-installer.tar.xz -C /tmp/nix-install" 213 + ) 214 + 215 + # Find the extracted directory and patch the installer 216 + installer_dir = machine.succeed( 217 + "find /tmp/nix-install -maxdepth 1 -type d -name 'nix-*' | head -1" 218 + ).strip() 219 + assert installer_dir, "Could not find extracted installer directory" 220 + 221 + # Patch: force single-user mode 222 + machine.succeed( 223 + f"sed -i 's/INSTALL_MODE=daemon/INSTALL_MODE=no-daemon/g' " 224 + f"{installer_dir}/install" 225 + ) 226 + 227 + # Copy installer into the Darling prefix 228 + machine.succeed("mkdir -p ~/.darling/private/tmp/nix-installer") 229 + machine.succeed( 230 + f"cp -a {installer_dir}/* ~/.darling/private/tmp/nix-installer/" 231 + ) 232 + machine.succeed("chmod +x ~/.darling/private/tmp/nix-installer/install") 233 + 234 + # Copy the nix-run helper into the prefix 235 + machine.succeed( 236 + "cp ${nixRunHelper} ~/.darling/private/tmp/nix-run && " 237 + "chmod +x ~/.darling/private/tmp/nix-run" 238 + ) 239 + 240 + # Run the patched installer inside Darling 241 + machine.succeed( 242 + "darling shell env NIX_INSTALLER_NO_MODIFY_PROFILE=0 " 243 + "bash -x /tmp/nix-installer/install --no-daemon", 244 + timeout=600, 245 + ) 246 + 247 + # ── Stage 5: Core Nix commands work ──────────────────────────────── 248 + 249 + with machine.nested("Stage 5: Core Nix commands work"): 250 + nix_run = "/tmp/nix-run" 251 + 252 + # nix --version 253 + result = machine.succeed( 254 + f"darling shell {nix_run} nix --version" 255 + ) 256 + assert "nix" in result.lower(), ( 257 + f"Expected 'nix' in version output, got: {result}" 258 + ) 259 + machine.log(f"Nix version: {result.strip()}") 260 + 261 + # nix-env --version 262 + result = machine.succeed( 263 + f"darling shell {nix_run} nix-env --version" 264 + ) 265 + assert "nix-env" in result.lower(), ( 266 + f"Expected 'nix-env' in output, got: {result}" 267 + ) 268 + 269 + # nix-instantiate --eval 270 + result = machine.succeed( 271 + f"darling shell {nix_run} nix-instantiate --eval -E '1 + 1'" 272 + ) 273 + assert "2" in result, f"Expected '2', got: {result}" 274 + 275 + # nix eval (flake-style) 276 + result = machine.succeed( 277 + f"darling shell {nix_run} nix eval --expr '1 + 1'" 278 + ) 279 + assert "2" in result, f"Expected '2', got: {result}" 280 + 281 + # Verify store database is accessible 282 + machine.succeed( 283 + f"darling shell {nix_run} nix-store --verify --no-build", 284 + timeout=120, 285 + ) 286 + 287 + # ── Stage 6: builtins.currentSystem reports x86_64-darwin ────────── 288 + 289 + with machine.nested("Stage 6: builtins.currentSystem reports x86_64-darwin"): 290 + nix_run = "/tmp/nix-run" 291 + 292 + result = machine.succeed( 293 + f"darling shell {nix_run} nix eval --raw --expr builtins.currentSystem" 294 + ) 295 + assert result.strip() == "x86_64-darwin", ( 296 + f"Expected 'x86_64-darwin', got: '{result.strip()}'" 297 + ) 298 + machine.log("builtins.currentSystem = x86_64-darwin OK") 299 + 300 + # ── Stage 7: Trivial derivation builds ───────────────────────────── 301 + 302 + with machine.nested("Stage 7: Trivial derivation builds"): 303 + nix_run = "/tmp/nix-run" 304 + 305 + # Copy derivation expression files into the prefix to avoid 306 + # nested quoting nightmares (Nix string -> Python -> shell -> Nix). 307 + machine.succeed( 308 + "cp ${trivialDrvFile} ~/.darling/private/tmp/trivial-drv.nix" 309 + ) 310 + machine.succeed( 311 + "cp ${multistepDrvFile} ~/.darling/private/tmp/multistep-drv.nix" 312 + ) 313 + 314 + # Level 1: minimal derivation — echo to $out 315 + out_path = machine.succeed( 316 + f"darling shell {nix_run} nix-build --no-out-link /tmp/trivial-drv.nix", 317 + timeout=300, 318 + ).strip() 319 + assert out_path.startswith("/nix/store/"), ( 320 + f"Expected /nix/store/... path, got: '{out_path}'" 321 + ) 322 + machine.log(f"Built trivial derivation at: {out_path}") 323 + 324 + # Verify the output content 325 + result = machine.succeed(f"darling shell cat {out_path}") 326 + assert "ok" in result, f"Expected 'ok' in build output, got: {result}" 327 + 328 + # Level 2: multi-step builder 329 + out_path2 = machine.succeed( 330 + f"darling shell {nix_run} nix-build --no-out-link /tmp/multistep-drv.nix", 331 + timeout=300, 332 + ).strip() 333 + assert out_path2.startswith("/nix/store/"), ( 334 + f"Expected /nix/store/... path, got: '{out_path2}'" 335 + ) 336 + result = machine.succeed(f"darling shell cat {out_path2}/bin/greeting") 337 + assert "hello" in result, f"Expected 'hello', got: {result}" 338 + machine.log(f"Built multistep derivation at: {out_path2}") 339 + 340 + machine.log("All Nix-in-Darling integration tests passed! OK") 341 + ''; 342 + }