test(sessions): run POSIX cross-user tests on macOS in CI - #285
test(sessions): run POSIX cross-user tests on macOS in CI#285andychoquette wants to merge 1 commit into
Conversation
b130ece to
6e62119
Compare
6e62119 to
753b1e0
Compare
753b1e0 to
0bea961
Compare
leongdl
left a comment
There was a problem hiding this comment.
I'll start by exploring the repo structure and the key files.Found the empty cfg(target_os = "linux") table already. Now hunting the specific patterns.I'll start by locating the relevant test files.I'll start by reading the diff and the existing CI workflow.## Answer
Yes — with one exception, and the exception is the interesting part.
I read the PR (3 files, CI + docs only, no code) and then audited the cfg(unix) production paths against macOS. The process-control half of the PR's thesis is correct and it is correct structurally, not by luck. The temp-directory half is not: there is a real macOS divergence in production code, and the PR handles it with a doc note plus a CI env override rather than code — which also means CI no longer covers it.
Line numbers below are from this checkout's HEAD (rfc008-wrap-fixes); PR 285 touches no code, so they still apply. I did not run the cross-user suite on macOS.
Where the abstraction genuinely is clean
Every mechanism the PR description names, I checked and it holds:
- Gating in
openjd-sessionsiscfg(unix)/cfg(windows)only — zerocfg(target_os = ...)in production code. No procfs reads, nopgrep/ps/pkill, nosetsid(1)binary (only thesetsid()syscall atsubprocess.rs:155), nopidfd/prctl/signalfd/clone. process_group(0)(helper/src/runner.rs:39) plussetsid()make every child a group leader, sopid == pgidand all signalling iskillpg(subprocess.rs:104,113).ESRCHfrom a reaped leader is discarded at every call site, so the post-wait()sweeps are harmless on both platforms.pre_execis fork-safe:libc::dup2andlibc::setsidonly, no allocation, nogetpwnam, no logging. This is the class that aborts on macOS, and the code avoids it.- The nix surface used (
killpg,chown,mkdir,Group::from_name,User::from_uid,dup,poll) is fully available on macOS. Notably there is nosetgroups/initgroups/getgrouplist, so macOS'sNGROUPS_MAX == 16trap is unreachable. - Cross-user signalling never crosses a uid boundary — the host only kills its own
sudochild; job-processkillpghappens inside the helper, already running as the job user. So macOS's kill-permission rules are never exercised differently. - The helper's write-then-exec is safe on macOS: per-session UUID filename sidesteps the stale-codesign-cache-by-path failure,
fs::writeapplies no quarantine xattr, nothing rewrites the path after exec.
The embedded-helper design being immune to pgid discovery across the sudo boundary is a real architectural property, and it is the reason the Python fixes don't apply. That part of the description is accurate.
The exception: std::env::temp_dir() is the one Linux-shaped line
tempdir.rs:70, in default_openjd_dir():
#[cfg(unix)]
{
std::env::temp_dir().join("OpenJD")
}The docstring says "typically /tmp/OpenJD". Verified on this macOS host: TMPDIR=/var/folders/x0/…/T, mode 0700, owner-only. So on any macOS process that inherits a launchd per-user TMPDIR, the default session root is not traversable by the job user — the helper can't be exec'd, the working dir can't be entered, and sudo -i rm -rf at session.rs:1040 can't reach the files. session.rs:572 and tempdir.rs:159 both take this default when session_root_directory is None.
Three consequences worth deciding on before merge:
- The claim needs a qualifier. "The full POSIX cross-user suite passes on macOS with no code changes" is true only with a non-default
TMPDIR. The spec says this three lines down in a table row and again in the deployment note; it should be in the sentence itself. - The guard checks the opposite direction.
find_missing_sticky_bit(tempdir.rs:95) flags world-writable-without-sticky — a security property. A 0700 ancestor sails throughStickyBitPolicy::Strictand the session then fails at first exec with"Helper process closed stdout unexpectedly"(cross_user_helper.rs:615). There is noaccess(X_OK)or ancestoro+x/g+xcheck anywhere on the POSIX side (I grepped). On Linux/tmpsatisfies both properties, which is why this never surfaced. - The CI job removes the coverage. The job sets
TMPDIR=/private/tmp/openjd-testsat 1777 job-wide, and the threeTempDir::new(None, …)tests are exactly the ones that would have exercised the default. So the job tests a temp-root shape no default macOS login-session process has, and a regression in 0700 handling would not be caught.
My suggestion is small: one #[ignore]d POSIX test that builds a 0700 parent, points a session root under it, and asserts the resulting error. Pinning today's behaviour is enough — it makes both a regression and a future improvement visible, and it gives the deployment note something falsifiable behind it. A pre-flight traversability check with an actionable error would be better, but that's a separate change.
Worth noting the nuance for the deployment note: whether this bites depends on launch context. launchd sets the private TMPDIR for per-user domain services and login sessions; system LaunchDaemons typically get /tmp. So the same binary works as a daemon and fails when a developer runs it from a terminal.
Test-integrity findings (these affect what "validated on macOS" means)
test_cross_user_tempdir_disjoint_fails(test_cross_user.rs:503-524) asserts nothing on macOS. The body isif let Ok(td) = result { … }with "Err is also acceptable". Its comment claims chown failure is silent (let _ = chown), buttempdir.rs:179-186now returnsSessionError::PathPermissions— I read both. So the result is alwaysErr, theif letbody is dead, and zero assertions run. The PR names this as the disjoint-permission test. It pins nothing on any platform, and the stale comment is what makes it look like it does.test_cross_user_subprocess_terminate(:138-162) asserts onlystate == Timeout || state == Failedwith no stdout check.Failedis also what exec-failure or permission-denied produces, so a macOS inability to run the script as the target user is indistinguishable from a successful timeout-kill. Its siblings add astdout.contains(...)check, which rescues them.test_cross_user_subprocess_notifynever asserts"Trapped"appears, so a straight SIGKILL passes identically — the SIGTERM trap path the description credits is not actually pinned. Same forterminate_tree: no pid/group check distinguishes "killed via killpg" from "orphaned, pipe closed". On macOS, process-group kill is the single behaviour most worth proving.- The job runs a bare filter. libtest exits 0 when a filter matches nothing, so any future rename or cfg narrowing turns this job silently green. The Linux jobs are safe here only because they run unfiltered.
No silent-skip risk in the suite as a whole: require_target_user() panics on unset env vars, and the "Verify provisioning" step is genuinely good — it hard-fails on every provisioning assumption including sudo -u … -i /usr/bin/true. Impersonation is really asserted by test_cross_user_runner_uid.
CI job, smaller items
- No
needs: build-testand its own read/write cache in debug, whilecross-user-windowsdeliberately doesneeds:+cache/restore+--releasewith a comment explaining why. The macOS cache key also drops the rustc hash and.cargo/config.tomlhash thatbuild-testdocuments as necessary, and there's no equivalent of the "discard stale target/" step. - No
timeout-minutes(family-wide gap, but this is the leaked-process-group workload) and noif: always()teardown, which the Windows job has. set -euxo pipefailechoes-password "OpenJD-ci-test-1!"into a public log. The value is already in source so nothing new leaks, but generating it (as Windows does) is free — nothing needs it, since sudo is NOPASSWD.dseditgroup -o edit -d … staff || truelooks inert:sysadminctlputs users in staff viaPrimaryGroupID, not as a member record, and the precedingPrimaryGroupIDreassignment has already moved them. Either drop it or drop the|| trueso a wrong assumption fails loudly.TMPDIRonly needs to be step-scoped; making it job-wide is what forces the pre-checkout "Create temp root" step.runneris hardcoded in two places;createhomedirruns before thePrimaryGroupIDchange, leaving the disjoint user's home group asstaff.
Docs
"Open Directory integration is exercised implicitly because nix::unistd::User::from_name() resolves through Directory Services" overstates it — these are local DS accounts, the same path as any local macOS user, not the networked-directory coverage the LDAP variant provides. Suggest saying there is no networked-directory variant on macOS.
Two pre-existing items, not this PR's
PosixSessionUser::new(user, None)defaults the group to the process's effective group (session_user.rs:36-42) — on macOS that'sstafffor every ordinary local account, and downstream0o770/0o750chmods then widen the working dir, embedded files, and helper binary to all local users. I checked the reference implementation: Python does exactly the same (_session_user.py:109,grp.getgrgid(os.getegid())). So this is inherited parity, not a Rust defect, and out of scope here — but it's a macOS-specific sharp edge worth a line in the spec.Cargo.toml:62[target.'cfg(target_os = "linux")'.dependencies]is an empty table with a comment describing what thecfg(unix)build-dependencies below actually do. Dead; removing it is a no-op. Ironically it's the strongest evidence for the PR's thesis: there is no functional Linux gating anywhere in the crate.build.rs:49:is_unix = target.contains("linux") || target.contains("unix") || cfg!(unix). Darwin triples match neithercontains, so correctness rests on the hostcfg!(unix). Fine for native macOS; a cross-build to darwin from a Windows host would take the Windows branch and panic.target.contains("unix")matches no real triple.
Not verified
I did not run the suite on macOS, so the loose-assertion findings are "these tests could pass for the wrong reason", not "they do". I could not confirm from runner-images docs that GitHub's macos-latest really sets a 0700 /var/folders TMPDIR — I confirmed the shape on this host only. One stat -f %Lp "$TMPDIR" echo before the override would keep that honest and make the override self-documenting.
0bea961 to
2e93b3f
Compare
| // chown to that group must fail. TempDir::new chowns before chmod and | ||
| // propagates the failure (SessionError::PathPermissions) rather than | ||
| // silently creating a directory with the wrong group — so this must be an | ||
| // Err, and the directory must not be left behind. |
There was a problem hiding this comment.
The comment claims the directory "must not be left behind" on chown failure, but that is not what happens. TempDir::new (tempdir.rs:167-198) does create_dir(&path) first, then nix::unistd::chown(...).map_err(...)?. On chown failure the ? returns Err before the TempDir struct is ever constructed, so its Drop-based best-effort cleanup never runs — the randomly-named subdirectory is left orphaned under the parent (here /tmp/OpenJD).
The test also does not assert the no-leftover property, so the comment is describing behavior that is neither guaranteed by the code nor checked here. Either drop the "and the directory must not be left behind" clause, or (better) have the code remove the just-created directory when chown fails and assert its absence.
|
Addressed feedback from @leongdl: The one real production divergence — Test integrity — all correct, all fixed:
CI: empty-filter guard (counts matched tests via Docs: softened the Open Directory claim (local DS accounts, same local-node path as any local user — not networked-directory coverage), and added a spec note on the Follow-up issue for the two library-behavior items (pre-flight traversability check; whether the default-group behavior should change on macOS): . I did not re-run the suite claim-by-claim on macOS before your review, but I have now — all 17 cross-user tests pass on a real Mac, including the new SIGTERM one. |
Validates cross-user Sessions on macOS (issue OpenJobDescription#263). No code changes were needed: the crate's mechanism (process_group(0) at spawn, killpg for signalling, no setsid(1) binary, no pgrep, no procfs) is pure POSIX syscalls, all present on macOS. All 16 cross-user integration tests pass on a macOS host once the environment is provisioned correctly. Adds a cross-user-macos CI job: macOS runners have no Docker but do have passwordless sudo, so the same user/group layout as the Linux test containers is provisioned directly on the host with Directory Services (sysadminctl/dseditgroup). Two macOS-specific provisioning requirements, documented in specs/sessions/cross-user-testing.md: - The default per-user TMPDIR (/var/folders/<hash>/T, mode 0700) is not traversable by the target user, so sudo cannot reach the extracted helper binary ("Helper process closed stdout unexpectedly"). The job sets a world-traversable TMPDIR. This constraint applies to real deployments' session roots as well. - macOS does not create a self-named group per user the way useradd does; the tempdir cleanup test chowns to user:user, so the group is created explicitly. Signed-off-by: andychoquette <78888816+andychoquette@users.noreply.github.com>
2e93b3f to
7c783e8
Compare
| // a real kill from a failure-to-launch, which also yields Failed). The | ||
| // SIGTERM/notify path is covered by test_cross_user_notify_delivers_sigterm. | ||
| assert!( | ||
| r.stdout.contains("Log from test 0"), |
There was a problem hiding this comment.
The docstring on this test (lines 101–102) is now stale and contradicts the new NOTE just below it. It still says "the trap handler should fire", but the added NOTE correctly explains that run_subprocess's timeout uses CancelMethod::Terminate (immediate SIGKILL, per runner/mod.rs:44), so the SIGTERM trap is intentionally not expected to fire here — which is exactly why the contains("Trapped") assertion was removed. Consider updating the doc comment to match, so the contract of this test (kill mid-flight, no trap) is unambiguous.
|
|
||
| /// Run long_running.sh with timeout — SIGKILL cannot be trapped. | ||
| /// Run long_running_ignore.sh with timeout — the workload traps SIGTERM but does | ||
| /// not exit, so the runner must escalate to SIGKILL (which cannot be trapped). |
There was a problem hiding this comment.
This revised docstring is misleading. It says the workload "traps SIGTERM but does not exit, so the runner must escalate to SIGKILL" — but the timeout path in run_subprocess does not escalate through SIGTERM at all. It uses CancelMethod::Terminate, and on timeout subprocess.rs sends SIGKILL directly ("Action timed out, sending SIGKILL to process group", subprocess.rs:656). The SIGTERM trap in long_running_ignore.sh never fires here.
As a consequence there is no observable behavioral difference between using long_running_ignore.sh and long_running.sh for this timeout test — both are killed by a single SIGKILL regardless of their TERM trap. If the intent is to exercise a genuine SIGTERM→SIGKILL escalation, this test does not do that (only the notify test with a grace period does); the docstring should be corrected to describe a direct SIGKILL-on-timeout kill.
Fixes: #263
What was the problem/requirement? (What/Why)
We need macOS support for cross-user Sessions (running Session actions as a
jobRunAsUser). The reference Python implementation (openjd-sessions-for-python) needed three code fixes for macOS (OpenJobDescription/openjd-sessions-for-python#335); issue #263 tracks confirming whether the Rust crate needs the same treatment, and validating it on real macOS either way. Until now, nothing built, ran, or CI-tested the cross-user path on macOS.What was the solution? (How)
No production code changes were needed — the issue's thesis held. The crate's mechanism (
process_group(0)at spawn,killpgfor signalling, nosetsid(1)binary, nopgrep, no procfs) is pure POSIX syscalls, all present on macOS. The embedded-helper design also never needs to discover a pgid across the sudo boundary (the helper reports it over stdout), so it is structurally immune to both bugs the Python implementation had.The change is test infrastructure + docs:
cross-user-macosCI job in ci.yml. macOS runners have no Docker but do have passwordless sudo, so the same user/group layout as the Linux test containers is provisioned directly on the host with Directory Services (sysadminctl/dseditgroup), then the existingtest_cross_user::suite runs with--include-ignored.specs/sessions/cross-user-testing.mddocumenting the two macOS-specific provisioning requirements discovered:TMPDIR(/var/folders/<hash>/T, mode 0700) is not traversable by the target user, sosudo -u <user> -i <helper_path>cannot reach the extracted helper binary (fails as "Helper process closed stdout unexpectedly"). The job sets a world-traversableTMPDIR. This constraint applies to real deployments' session roots as well.useradddoes; the tempdir cleanup test chowns touser:user, so provisioning creates that group explicitly.What is the impact of this change?
macOS cross-user Sessions are now validated and continuously regression-tested in CI, alongside the existing Linux (localuser + LDAP) and Windows cross-user jobs. Deployers get a documented requirement for session-root traversability on macOS.
How was this change tested?
test_cross_user::integration tests pass on a real macOS host (includingterminate_treeprocess-tree reaping, SIGTERM notify traps, uid/env isolation, cancel handles, and the disjoint-user permission rejection), first on a local machine and then in the new CI job, which passed on its first run.Was this change documented?
Yes —
specs/sessions/cross-user-testing.md(new macOS section with a Linux-vs-macOS provisioning table and a deployment note) and a line inDEVELOPMENT.md. No docstring changes needed (no code changes).Is this a breaking change?
No. CI + docs only.
Does this change impact security?
No production code changes. The CI job provisions throwaway test users with passwordless sudo scoped to those users only, on ephemeral GitHub-hosted runners. The validation itself exercises the existing security boundary (cross-user isolation, disjoint-group rejection) rather than modifying it.
By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.