From 10c9b49bf81726f6cea31db2540029e0d7954eb7 Mon Sep 17 00:00:00 2001 From: fy59 Date: Sat, 17 Oct 2026 09:27:16 +0200 Subject: [PATCH] feat(probe): prepare reviewed single-output Mosaic session trial --- docs/README.md | 5 + docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md | 180 ++++++++++++++++ tooling/probes/mosaic-first-session/README.md | 68 ++++++ .../probes/mosaic-first-session/prepare.py | 197 ++++++++++++++++++ tooling/probes/mosaic-first-session/repair.py | 118 +++++++++++ .../mosaic-first-session/test_prepare.py | 184 ++++++++++++++++ 6 files changed, 752 insertions(+) create mode 100644 docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md create mode 100644 tooling/probes/mosaic-first-session/README.md create mode 100644 tooling/probes/mosaic-first-session/prepare.py create mode 100644 tooling/probes/mosaic-first-session/repair.py create mode 100644 tooling/probes/mosaic-first-session/test_prepare.py diff --git a/docs/README.md b/docs/README.md index c145fbd..65e646c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,5 +1,10 @@ # Documentation +[First physical Mosaic session](design/MOSAIC_FIRST_PHYSICAL_SESSION.md) +defines the bounded experimental eDP-1 trial and manual TTY return to Stable. +Preparation is source-only; PHYSICAL_NOT_RUN. No greeter/default selection or +production contract change is authorized. + [Isolated EGL/FX probe](design/STEP20C_HEADLESS_EGL_FX_PROBE.md) records the single 2026-10-17 namespace failure: EXPERIMENTAL / EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED. diff --git a/docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md b/docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md new file mode 100644 index 0000000..291e38c --- /dev/null +++ b/docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md @@ -0,0 +1,180 @@ +# First physical Mosaic session — experimental contract + +Verdict: **MOSAIC_PHYSICAL_SESSION_READY_FOR_EXPLICIT_APPROVAL**. +State: **PHYSICAL_NOT_RUN / REPAIR_NOT_INSTALLED**. This is a short, +voluntary, single-output trial, not production qualification or STEP20C closure. +STEP20A DONE; STEP20B EXPERIMENTAL; STEP20C NEXT; STEP20D–G PLANNED. +Production multi-output remains NOT_YET_SUPPORTED. + +## Accepted scope + +READY_FOR_APPROVAL means the retained CORE-C V2 identity, installed dedicated +session entry, staged guard correction, service isolation, offline refusals +and manual TTY procedure have been checked. It includes the explicit future +two-file deployment decision below; it does not claim deployment is complete. +It does not prove GPU, seat0, DRM, eDP-1, input, +physical VT switching or actual recovery. Those remain PHYSICAL_NOT_RUN. + +Use the existing experimental session and binary. Preserve Stable, the greeter, +all personal configuration and `default-id = sway.desktop`. No compositor +execution, including `--version`, config validation or headless startup, is +authorized during this preparation. Keep copies, receipts and logs in a new +private mode0700 LAB; no private paths, personal identifiers or raw logs in Git. + +## Guard repair prepared before approval + +A read-only check exposed a real pre-launch refusal in the installed guard: +systemd 262 quotes and escapes an autostart unit in its `Conflicts` property; +plain whitespace splitting does not recover the unit name. The required +exclusion is present, but the old predicate reports it missing. This is an +offline preparation defect, not evidence of a physical compositor failure. + +The smallest correction uses `shlex.split()` to decode the quoted word list +without executing a shell; all required exclusions must still be present. +The [source-only tool](../../tooling/probes/mosaic-first-session/README.md) +prepares it and the updated integrity manifest privately. +Before approval, do not install either file. The exact local diff must be +reviewable; the requested future write set is limited to the dedicated +`session.py` and `manifest.json` in the existing Mosaic test configuration +directory. No entry, config, unit, greeter or preference change is needed. +Do not attempt the trial with the uncorrected guard. Approval must cover these +two file replacements as well as the physical trial; retain checked originals +for rollback. The normal session keeps using the retained original CORE-C V2 +binary; the new LAB's binary copy is a non-executable archive, not a deployment. + +## Shortest accepted login path + +After separate conversational approval, installation/check of the reviewed +guard repair and voluntary logout from Stable, use authenticated TTY2 on +seat0. Cancel any personal login compositor menu to reach a shell (the retained +menu replay uses Tab, Tab, Enter for Cancel); then `exec /usr/bin/zsh -f`. +Before starting, open and verify a second authenticated recovery shell on TTY3, +then return to TTY2. Cancel if that console cannot be reached. Keep both logins +alive. Check `default-id` still says `sway.desktop` and inspect: + +```sh +/usr/bin/systemctl --user list-units 'wayland-wm@*.service' --all --plain --no-pager +``` + +With every WM inactive (none active, activating or deactivating), run: + +```sh +/usr/bin/uwsm start -- swayfx-mosaic-test.desktop +``` + +Installed UWSM supports an explicit desktop entry ID; only its special `default` +and `select` paths resolve or save the preference. Keep the login alive while +the session runs. Do not use the existing `arm` helper. The current greeter +starts `uwsm start default` and has no Stable/Mosaic selector. + +## Planned trial, three to five minutes + +On eDP-1 only, verify French keyboard and pointer. In the recovery Kitty, run +the existing dedicated `inspect-session.py` first: it verifies the exact +process/binary/provider and socket owner before any explicit Mosaic IPC. On +success only, set standard layout with `swaymsg -s "$SWAYSOCK" layout splith`. +Start in standard `splith`, +open at least five light Kitty windows if necessary for horizontal overflow, +toggle with Super+Alt+L, focus with Super+arrows and scroll with +Super+Ctrl+Left/Right. Check basic floating, close a window and toggle back to +standard. Basic floating uses `swaymsg -s "$SWAYSOCK" floating toggle` twice +on the focused window, after that same identity check; no floating shortcut +is installed. Super+Return opens Kitty; Super+Shift+Q closes a client. +The existing Super+Shift+E binding invokes native `exit`; prefer the ordered +unit stop below for normal trial exit, as recommended by installed UWSM. +Super alone never moves windows. + +## Manual independent recovery + +If there is no image, Kitty or IPC socket, switch to the already verified TTY3. +Inspect UWSM units with the list command above. If Stable or another WM is +active, activating or deactivating, do not stop anything or start another WM. +If only Mosaic remains, stop only its exact instance: + +```sh +/usr/bin/systemctl --user stop 'wayland-wm@swayfx\x2dmosaic\x2dtest.desktop.service' +``` + +Wait for completion and inspect units again. Only when every WM is inactive, +target Stable explicitly (also works after failure before any socket exists): + +```sh +/usr/bin/uwsm start -- sway.desktop +``` + +Read the saved preference before restart; it must still be `sway.desktop`. +If it differs, cancel and investigate; this mission never arms Mosaic. The +existing manual Stable preference helper is independent of the binary/LAB but +writes the preference, so reserve it for a separately acknowledged anomalous +selection recovery. Explicit Stable start bypasses the preference entirely. +Never kill the user manager or stop a wildcard target. A failed context check, +lingering stop, unavailable recovery console or identity mismatch is grounds +to cancel. Actual VT/PAM and physical crash recovery remain NOT TESTED. + +## Out of scope + +Multi-output, complete clipping/damage/lifetime guarantees, GPU G4, advanced +fullscreen, greeter V3B, production installation, QuickShell integration and +personal bindings deployment are excluded. The headless UDMABUF/namespace +failure is separate evidence, not a proven failure of the physical DRM path. + +## Validation and handoff + +The retained binary is CORE-C V2 SHA256 +`91f48aae2675331e0a2c60ec2fedf983745c521c75232c704a6516a4140801fb`, +Build ID `d5d304533f2c8db9d738365e9bd7f2c96779ef8d`. It has no SceneFX +DT_NEEDED or RPATH/RUNPATH. The exact wlroots 0.20.2 provider SHA256 is +`7edca9a7d65855addda68e29ba5db94d57c803eb5fa7d0f86db3136b90ca9837`. +Local versions: UWSM 0.27.1-1, SwayFX 0.6-1, SceneFX 0.5-1, Kitty 0.49.2-1, +systemd 262-1. These facts do not qualify a different dependency build. + +The prepared guard SHA256 is +`34198274977204e365f4d7b092e9dc96bdc18d5b8027f6677ae47b1d3fe02799`; +its manifest SHA256 is +`2fdecc1a1be0bc1a009ed8854d116ce22b99c71596729e746ad8f0920e35bcc2`. +Only that guard's digest changes inside the manifest. Both installed files +remain unchanged; their existing mode0600 must be retained on future deployment. +Private receipts preserve original bytes, exact diff, old/new SHA and rollback. +An interrupted two-file deployment must be repaired or rolled back before +launch; there is no claim of an atomic transaction spanning both files. + +Independently rerun targeted evidence: 24 inert tests pass against actual guard code, +including repaired launch with every effect intercepted, missing exclusions, +malformed quotes, fake seat, wrong cgroup, inherited IPC, concurrent WM, +missing UWSM and changed binary. Eighteen retained guard/selection tests and +two real UWSM/whiptail PTY replays pass in a shadow environment with all starts +intercepted. The selection test writes only its shadow preference. Thirty-two +audited input files and the selection symlink remain unchanged. `session.py +check`, ELF relocations and private copy identity pass. Current static config +inspection verifies eDP-1, bindings, no include, Xwayland/effects disabled; +native config parsing is NOT_RUN. No full grammar validation is claimed. +Both installed UWSM `start -n -- ` simulations pass without +unit writes/start/reload, reporting `Units unchanged`; the active Stable PID +and activation timestamp remain unchanged. AST, local documentation links +and `git diff --check` pass. Independent bounded delta review is ACCEPTED. + +Validator invocation errors are retained: the imperative PTY replay has no +unittest cases (runner exit5); its proper script entry point passes both cases. +An initial relocation command used an incorrect guessed path; the manifest's +actual binary path passes `ldd -r`. Neither failure is a native compositor run. +The parent's first final unittest invocation omitted the mandatory +`MOSAIC_INSTALLED_GUARD` input and failed setup without running tests; the +corrected explicit-input command passes all 24 tests. +The original quoted-Conflicts predicate FAIL remains documented above; only +the privately staged correction passes its corresponding offline predicate. + +Independent final audit is PASS / ACCEPTED with no blocking finding. +The final local handoff is `APPROVAL-AND-TRIAL.txt`, superseding earlier sealed +manuals, and includes the precise two-file deployment/rollback plan. These +tests require the audited installed guard and local inputs; this repository +alone is not a self-contained physical session kit. Private LAB location, +exact local commands, final check receipts and publication refs belong to the +local handoff, not public logs. No physical session is authorized by READY; +actual login/input/return and all visual trial cells are NOT TESTED. + +After a future failure, collect at most 80 journal entries for the exact Mosaic +unit into the private LAB, plus its bounded exit receipt; do not publish raw +logs. Main risks are untested physical DRM/input/VT recovery, temporary LAB or +dependency identity drift, and remaining experimental Mosaic rendering/input +limitations. Cancel on a black screen, failed input or identity/context refusal; +use the independent recovery console rather than weakening a guard. diff --git a/tooling/probes/mosaic-first-session/README.md b/tooling/probes/mosaic-first-session/README.md new file mode 100644 index 0000000..cbd218c --- /dev/null +++ b/tooling/probes/mosaic-first-session/README.md @@ -0,0 +1,68 @@ +# First physical session: passive preparation + +EXPERIMENTAL / PHYSICAL_NOT_RUN. These tools create private evidence only. +They do not execute a compositor, use IPC, start/stop/reload a service, select a +session, deploy files, or change default-id. Production STEP20C remains NEXT. +The installed guard is trusted audited Python source, imported without `main`; +this is not a sandbox for arbitrary Python supplied as a guard. + +From this directory, pass the actual installed paths as local arguments: + +```sh +PYTHONDONTWRITEBYTECODE=1 MOSAIC_INSTALLED_GUARD="$GUARD" \ + python -B -m unittest -v test_prepare.py +python -B prepare.py --guard "$GUARD" --source-kit "$SOURCE_KIT" \ + --repo "$REPOSITORY" --protect "$HOME/.zprofile" +``` + +`GUARD`, `SOURCE_KIT` and `REPOSITORY` must be set explicitly to audited local +inputs. Missing UWSM, a changed binary/provider/manifest, unsafe files or a +non-Stable preference cause refusal. No launch command is exposed by the CLI. +Repeated preparation creates a new mode0700 directory under `/var/tmp`. + +The private LAB contains mode0400 snapshots of every installed manifest file, +the manifest itself, original binary, library provider, canonical default-id, +source fixtures and optional protected files. Symlink text and owner, canonical +file owner, mode, size and SHA256 are recorded; the existing selection symlink +is validated by the original guard. Before/after receipts compare those values. +Only optional `--protect` files beyond the installed manifest are covered; +this is not a snapshot of all personal configuration or filesystem metadata. +Timestamps and inode identity are not a concurrent filesystem snapshot claim. +No file is installed from this archival copy; the desktop entry still uses the +original helper and binary. Provenance and diff against local `origin/main` +remain private. Untracked source is preserved through separately copied source +fixtures; `git diff` alone does not include untracked files. + +`MANUAL.txt` contains the exact explicit UWSM launch and independent TTY recovery +commands for later separate approval. It is text, not an executable script. +Recovery requires Stable and other WM instances inactive before stopping only +the exact Mosaic instance, then all WM instances inactive before explicit Stable +start. No Kitty, IPC socket, preference rewrite or global process kill is needed. +Physical VT/PAM, DRM, eDP-1, seat0, input and actual recovery remain NOT TESTED. + +Tests use actual installed context/file validation with synthetic arguments. +The repaired launch tests call the actual `launch` with filesystem observations, +context, chdir and exec intercepted; they never execute a compositor. They do +not call `arm`, `stable`, `main` or IPC. Scratch files +live in fresh private temporary directories. A valid simulated context proves +only the guard predicate, never actual logind or hardware admission. + + +The audited installed guard has a confirmed quoted-Conflicts decoding defect. +`repair.py --guard "$GUARD" --lab "$LAB"` stages only its exact SHA256 preimage +repair plus the corresponding manifest hash in a fresh exclusive subdirectory +of an existing owned mode0700 LAB. It retains rollback originals, exact private +diff, old/new hashes and original deployment modes. Candidates are mode0400; +there is no installation function. Changed preimages, missing manifest anchors +and repeated staging into the same subdirectory are refused. The manifest +changes only the session.py hash. Binary/provider identities remain unchanged. +One final conversational approval must explicitly cover installing both +session.py and manifest.json and the subsequent bounded physical trial. +Verify the approved hashes and loaded isolation after deployment and before +starting that approved trial. In the completed local preparation, +APPROVAL-AND-TRIAL.txt supersedes the earlier sealed manuals, including +MANUAL-final.txt, and includes the exact two-file deployment/rollback plan. +Until deployment, the private module's unmocked +verify_files authenticates the installed original, not the staged candidate. +The exact installed guard and its local inputs are required to reproduce these +tests; the repository alone is not a self-contained physical session kit. diff --git a/tooling/probes/mosaic-first-session/prepare.py b/tooling/probes/mosaic-first-session/prepare.py new file mode 100644 index 0000000..7efb882 --- /dev/null +++ b/tooling/probes/mosaic-first-session/prepare.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +"""Read-only preparation; never start a compositor, IPC client or session unit.""" + +import argparse +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile + +sys.dont_write_bytecode = True + + +def load_guard(path): + """Import the audited installed guard without dispatching its CLI.""" + spec = importlib.util.spec_from_file_location('installed_mosaic_guard', path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def digest(data): + return hashlib.sha256(data).hexdigest() + + +def require_uwsm(guard, path): + """Check a trusted executable as data; do not execute it, even for version.""" + path = Path(path).resolve(strict=True) + guard.read_regular(path, 1024 * 1024, owner=0) + if not path.stat().st_mode & 0o111: + raise RuntimeError('UWSM is not executable') + return path + + +def inventory(guard, paths): + """Validate canonical regular files and retain link identity separately.""" + records = {} + for path in sorted(set(map(Path, paths))): + info = path.lstat() + resolved = path.resolve(strict=True) + if path.is_symlink() and info.st_uid not in (0, os.getuid()): + raise RuntimeError('Untrusted symlink owner') + fd = guard.secure_directory(path.parent) + os.close(fd) + data = guard.read_regular(resolved, 64 * 1024 * 1024, + owner=resolved.stat().st_uid) + target = resolved.stat() + if target.st_uid not in (0, os.getuid()): + raise RuntimeError('Untrusted file owner') + records[str(path)] = { + 'canonical': str(resolved), 'sha256': digest(data), + 'size': len(data), 'uid': target.st_uid, + 'mode': stat.S_IMODE(target.st_mode), + 'link': os.readlink(path) if path.is_symlink() else None, + 'link_uid': info.st_uid if path.is_symlink() else None, + } + return records + + +def write_private(path, data): + """Create a fresh, non-executable receipt or snapshot without replacement.""" + with path.open('xb') as stream: + stream.write(data) + path.chmod(0o400) + + +def json_bytes(value): + return (json.dumps(value, indent=2, sort_keys=True) + '\n').encode() + + +def git_read(repo, *arguments): + """Only fixed read-only Git operations are used by preparation.""" + return subprocess.run(['git', '-C', str(repo), *arguments], check=True, + capture_output=True).stdout + + +def instructions(guard): + """Return text only: no launch or recovery execution entry point exists.""" + return f'''EXPERIMENTAL / PHYSICAL_NOT_RUN +Binary snapshot is archival, not deployed. Existing entry uses original binary. +No preference changes. Do not run arm/stable helpers. + +First require one explicit conversational approval covering the reviewed +session.py and manifest.json repair and the subsequent bounded physical trial. +Install the repair only after that approval; verify its approved hashes and +loaded isolation again before the approved trial. +The original installed guard has a confirmed quoted-Conflicts refusal. +Only after that approved repair, verification and voluntary Stable logout: +Use authenticated local seat0 TTY; cancel the login menu to reach a shell. +Before launch, open and verify a second independent authenticated TTY recovery +shell; cancel if unavailable. Use TTY2 for launch and TTY3 for recovery. +After any menu cancellation, `exec /usr/bin/zsh -f` provides a clean shell. +Keep both logins alive. Inspect units (activating/deactivating must be absent): + /usr/bin/systemctl --user list-units 'wayland-wm@*.service' --all --plain --no-pager +When all compositor WM units are inactive and inherited graphical variables +are absent, launch explicitly, leaving default-id unchanged: + /usr/bin/uwsm start -- swayfx-mosaic-test.desktop + +Normal exit or recovery without Kitty, image or IPC: +Use another authenticated TTY; cancel its login menu. Inspect the list above. +If Stable or any other WM is active/activating/deactivating, cancel recovery. +If only Mosaic remains, stop precisely this one WM instance: + /usr/bin/systemctl --user stop '{guard.WM_UNIT}' +Inspect again and wait until ALL compositor WM units are inactive, including +Mosaic. A failed stop or lingering unit means cancel; do not force anything. +Then explicitly start Stable: + /usr/bin/uwsm start -- sway.desktop +Read canonical default-id from manifest; it must remain sway.desktop. +No global kill, wildcard stop, uwsm stop, selection or default helper. +Actual VT/PAM/DRM/eDP-1/recovery: NOT TESTED. +'''.encode() + + +def prepare(guard_path, source_kit, repo, uwsm, extra): + """Snapshot audited inputs into a fresh private LAB; source files stay intact.""" + guard_path = Path(guard_path).absolute() + guard = load_guard(guard_path) + guard.verify_files() + require_uwsm(guard, uwsm) + default = guard.Selection().validate() + if default.strip() != b'sway.desktop': + raise RuntimeError('Stable default must remain sway.desktop') + installed = json.loads(guard.read_regular(guard.CONFIG / 'manifest.json')) + fixtures = list(Path(source_kit).iterdir()) + if any(not p.is_file() or p.is_symlink() for p in fixtures): + raise RuntimeError('Source-kit must contain only regular source fixtures') + paths = [Path(p) for p in installed['files']] + paths += [guard.CONFIG / 'manifest.json', guard.BINARY, guard.WLROOTS, + guard.SELECTION_DIR / 'default-id', Path(uwsm), *fixtures, + *map(Path, extra)] + before = inventory(guard, paths) + # Validate the selection alias itself using the guard's exact Stow contract. + alias = {'path': str(guard.SELECTION_ALIAS), + 'target': os.readlink(guard.SELECTION_ALIAS), + 'uid': guard.SELECTION_ALIAS.lstat().st_uid} + lab = Path(tempfile.mkdtemp(prefix='mosaic-first-session-', dir='/var/tmp')) + lab.chmod(0o700) + write_private(lab / 'before.json', json_bytes({'files': before, 'alias': alias})) + snapshots = lab / 'snapshots' + snapshots.mkdir(mode=0o700) + for number, (name, record) in enumerate(before.items()): + data = guard.read_regular(Path(record['canonical']), 64 * 1024 * 1024, + owner=record['uid']) + if digest(data) != record['sha256']: + raise RuntimeError('Input changed during snapshot') + destination = f'{number:03d}-{Path(name).name}' + write_private(snapshots / destination, data) + record['snapshot'] = destination + write_private(lab / 'manifest.json', json_bytes({ + 'status': 'EXPERIMENTAL / PHYSICAL_NOT_RUN', 'files': before, + 'default': default.decode(), 'original_binary': str(guard.BINARY), + 'snapshot_deployed': False, 'guard': str(guard_path)})) + write_private(lab / 'MANUAL.txt', instructions(guard)) + provenance = {key: git_read(repo, *args).decode() for key, args in { + 'head': ('rev-parse', 'HEAD'), + 'origin_main': ('rev-parse', 'origin/main'), + 'status': ('status', '--short', '--branch'), + }.items()} + write_private(lab / 'provenance.json', json_bytes(provenance)) + write_private(lab / 'diff-to-origin.patch', + git_read(repo, 'diff', 'origin/main', '--')) + guard.verify_files() + after = inventory(guard, paths) + comparable = {key: {k: v for k, v in value.items() if k != 'snapshot'} + for key, value in before.items()} + alias_after = {'path': str(guard.SELECTION_ALIAS), + 'target': os.readlink(guard.SELECTION_ALIAS), + 'uid': guard.SELECTION_ALIAS.lstat().st_uid} + unchanged = (comparable == after and alias == alias_after + and guard.Selection().validate() == default) + write_private(lab / 'after.json', json_bytes({ + 'files': after, 'alias': alias_after, 'unchanged': unchanged})) + if not unchanged: + raise RuntimeError(f'Inputs changed; inspect private receipts: {lab}') + return lab, len(after) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--guard', required=True, type=Path) + parser.add_argument('--source-kit', required=True, type=Path) + parser.add_argument('--repo', required=True, type=Path) + parser.add_argument('--uwsm', type=Path, default=Path('/usr/bin/uwsm')) + parser.add_argument('--protect', action='append', default=[], type=Path) + args = parser.parse_args() + lab, count = prepare(args.guard, args.source_kit, args.repo, + args.uwsm, args.protect) + print(json.dumps({'lab': str(lab), 'files_unchanged': count, + 'status': 'PHYSICAL_NOT_RUN'})) + + +if __name__ == '__main__': + main() diff --git a/tooling/probes/mosaic-first-session/repair.py b/tooling/probes/mosaic-first-session/repair.py new file mode 100644 index 0000000..cfab0cd --- /dev/null +++ b/tooling/probes/mosaic-first-session/repair.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Stage the one audited Conflicts decoding repair; never install or launch.""" + +import argparse +import difflib +import json +import os +from pathlib import Path +import stat + +from prepare import digest, json_bytes, write_private + +PREIMAGE_SHA = '556b490587af166730db686d973a63e586824b87b488e7364471228515848522' +OLD = """ conflicts = run(['/usr/bin/systemctl', '--user', 'show', SESSION_UNIT, + '-p', 'Conflicts', '--value']).split() +""" +NEW = """ # systemctl quotes unit names containing literal backslashes. Decode its + # quoted word list without shell execution; preserve the unit's \\x escapes. + conflicts = shlex.split(run([ + '/usr/bin/systemctl', '--user', 'show', SESSION_UNIT, + '-p', 'Conflicts', '--value'])) +""" + + +def corrected(data): + """Refuse every source except the exact audited preimage.""" + if digest(data) != PREIMAGE_SHA: + raise RuntimeError('Audited guard preimage changed') + source = data.decode() + if source.count(OLD) != 1 or source.count('import stat\n') != 1: + raise RuntimeError('Audited repair anchors changed') + return source.replace('import stat\n', 'import shlex\nimport stat\n').replace( + OLD, NEW).encode() + + +def stage(guard_path, lab): + """Create an exclusive repair directory in an existing private LAB. + + The installed guard authenticates all original inputs. Only the session.py + digest changes in the manifest; no installation function is provided. + """ + guard_path, lab = Path(guard_path).absolute(), Path(lab).absolute() + # Authenticate bytes before importing trusted Python; do not execute an + # arbitrary replacement supplied via the CLI. + source_fd = os.open(guard_path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + try: + source_info = os.fstat(source_fd) + if (not stat.S_ISREG(source_info.st_mode) or source_info.st_nlink != 1 + or source_info.st_uid != os.getuid() + or source_info.st_mode & 0o022 or source_info.st_size > 131072): + raise RuntimeError('Unsafe guard preimage') + with os.fdopen(source_fd, 'rb', closefd=False) as stream: + authenticated = stream.read(131073) + finally: + os.close(source_fd) + corrected(authenticated) + # Compile the authenticated bytes rather than reopening a mutable source. + import types + guard = types.ModuleType('authenticated_installed_guard') + exec(compile(authenticated, str(guard_path), 'exec'), guard.__dict__) + fd = guard.secure_directory(lab) + info = os.fstat(fd) + os.close(fd) + if info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: + raise RuntimeError('Existing LAB must be owned and mode0700') + original = guard.read_regular(guard_path, 131072) + candidate = corrected(original) + guard.verify_files() + manifest_path = guard_path.parent / 'manifest.json' + manifest_data = guard.read_regular(manifest_path) + manifest = json.loads(manifest_data) + key = str(guard_path) + if manifest['files'].get(key) != PREIMAGE_SHA: + raise RuntimeError('Manifest does not authenticate audited guard') + manifest['files'][key] = digest(candidate) + # Preserve the complete manifest formatting and all other bytes. + if manifest_data.count(PREIMAGE_SHA.encode()) != 1: + raise RuntimeError('Manifest hash anchor is ambiguous') + candidate_manifest = manifest_data.replace(PREIMAGE_SHA.encode(), + digest(candidate).encode()) + if json.loads(candidate_manifest) != manifest: + raise RuntimeError('Unexpected manifest change') + destination = lab / 'guard-conflicts-repair' + destination.mkdir(mode=0o700) # Exclusive; never overwrite an earlier receipt. + records = {} + patch = '' + for path, old, new in ((guard_path, original, candidate), + (manifest_path, manifest_data, candidate_manifest)): + mode = stat.S_IMODE(path.stat().st_mode) + write_private(destination / (path.name + '.original'), old) + write_private(destination / path.name, new) + records[str(path)] = {'old_sha256': digest(old), 'new_sha256': digest(new), + 'old_mode': mode, 'deployment_mode': mode, + 'staged_mode': 0o400} + patch += ''.join(difflib.unified_diff( + old.decode().splitlines(True), new.decode().splitlines(True), + fromfile=str(path), tofile=str(path))) + write_private(destination / 'exact.patch', patch.encode()) + write_private(destination / 'receipt.json', json_bytes({ + 'status': 'SOURCE_ONLY / NOT_INSTALLED / PHYSICAL_NOT_RUN', + 'files': records, 'installation_authorized': False})) + guard.verify_files() + if (guard.read_regular(guard_path, 131072) != original + or guard.read_regular(manifest_path) != manifest_data): + raise RuntimeError('Installed inputs changed during staging') + return destination + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--guard', type=Path, required=True) + parser.add_argument('--lab', type=Path, required=True) + args = parser.parse_args() + print(stage(args.guard, args.lab)) + + +if __name__ == '__main__': + main() diff --git a/tooling/probes/mosaic-first-session/test_prepare.py b/tooling/probes/mosaic-first-session/test_prepare.py new file mode 100644 index 0000000..03fe906 --- /dev/null +++ b/tooling/probes/mosaic-first-session/test_prepare.py @@ -0,0 +1,184 @@ +"""Independent offline tests of the actual installed guard, never its launcher.""" + +import importlib.util +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import prepare + + +class GuardTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + path = os.environ.get('MOSAIC_INSTALLED_GUARD') + if not path: + raise RuntimeError('Set MOSAIC_INSTALLED_GUARD to the audited session.py') + cls.guard = prepare.load_guard(Path(path)) + + def setUp(self): + self.guard = type(self).guard + self.env = {'XDG_SEAT': 'seat0', 'XDG_VTNR': '2', + 'INVOCATION_ID': 'offline', 'NOTIFY_SOCKET': '/offline'} + self.login = {'User': str(os.getuid()), 'Class': 'user', 'Seat': 'seat0', + 'VTNr': '2', 'Active': 'yes', 'Remote': 'no', 'Type': 'tty'} + self.group = '0::/user.slice/session.slice/' + self.guard.WM_UNIT + self.units = [(self.guard.WM_UNIT, 'activating')] + + def validate(self): + self.guard.validate_context(self.env, self.group, self.login, + 'tty2', self.units) + + def test_valid_simulated_context(self): + self.validate() + + def test_fake_seat_rejected(self): + self.login['Seat'] = 'seat1' + with self.assertRaises(RuntimeError): + self.validate() + + def test_wrong_service_cgroup_rejected(self): + self.group = '0::/session.scope' + with self.assertRaises(RuntimeError): + self.validate() + + def test_concurrent_stable_including_shutdown_rejected(self): + for state in ('active', 'activating', 'deactivating'): + with self.subTest(state=state): + self.units = [('wayland-wm@sway.desktop.service', state)] + with self.assertRaises(RuntimeError): + self.validate() + + def test_inherited_ipc_and_display_rejected(self): + for name in ('SWAYSOCK', 'I3SOCK', 'WAYLAND_DISPLAY', 'WAYLAND_SOCKET', + 'DISPLAY', 'WLR_BACKENDS'): + with self.subTest(name=name): + self.env[name] = '/unrelated' + with self.assertRaises(RuntimeError): + self.validate() + del self.env[name] + + def test_missing_uwsm_rejected(self): + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + with self.assertRaises(FileNotFoundError): + prepare.require_uwsm(self.guard, Path(directory) / 'missing') + + def test_altered_binary_rejected_by_actual_guard(self): + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + binary = Path(directory) / 'altered' + binary.write_bytes(b'not the audited binary') + binary.chmod(0o500) + with patch.object(self.guard, 'BINARY', binary): + with self.assertRaisesRegex(RuntimeError, 'CORE-C V2'): + self.guard.verify_files() + + def test_symlink_and_writable_file_refused(self): + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + target = Path(directory) / 'target' + target.write_bytes(b'unchanged') + link = Path(directory) / 'link' + link.symlink_to(target) + with self.assertRaises(OSError): + self.guard.read_regular(link) + target.chmod(0o666) + with self.assertRaises(RuntimeError): + self.guard.read_regular(target) + self.assertEqual(target.read_bytes(), b'unchanged') + + def test_import_does_not_dispatch(self): + with patch('os.execve', side_effect=AssertionError('exec forbidden')), \ + patch('subprocess.run', side_effect=AssertionError('run forbidden')): + prepare.load_guard(Path(os.environ['MOSAIC_INSTALLED_GUARD'])) + + def test_receipt_is_exclusive_and_readonly(self): + with tempfile.TemporaryDirectory(dir='/var/tmp') as directory: + path = Path(directory) / 'receipt' + prepare.write_private(path, b'first') + self.assertEqual(path.stat().st_mode & 0o777, 0o400) + with self.assertRaises(FileExistsError): + prepare.write_private(path, b'replacement') + self.assertEqual(path.read_bytes(), b'first') + + +class RepairedLaunchTests(GuardTests): + """Exercise actual launch with every external effect intercepted.""" + + @classmethod + def setUpClass(cls): + import repair + path = Path(os.environ['MOSAIC_INSTALLED_GUARD']) + source = repair.corrected(path.read_bytes()) + import types + cls.guard = types.ModuleType('offline_repaired_guard') + exec(compile(source, '', 'exec'), cls.guard.__dict__) + + def launch_mocked(self, conflicts, accepted): + from types import SimpleNamespace + from contextlib import ExitStack + env = dict(self.env, XDG_RUNTIME_DIR=f'/run/user/{os.getuid()}', + XDG_SESSION_ID='offline') + + def run(argv): + if 'list-units' in argv: + return f'{self.guard.WM_UNIT} loaded activating start' + self.assertIn('Conflicts', argv) + return conflicts + + def read_text(path, *args, **kwargs): + if str(path) == '/proc/self/cgroup': + return self.group + self.assertEqual(str(path), '/sys/class/tty/tty0/active') + return 'tty2\n' + + with ExitStack() as stack: + stack.enter_context(patch.dict(os.environ, env, clear=True)) + for name, value in ( + ('verify_files', lambda: None), + ('secure_directory', lambda path: 123), + ('properties', lambda argv: self.login), ('run', run)): + stack.enter_context(patch.object(self.guard, name, value)) + stack.enter_context(patch('os.fstat', return_value=SimpleNamespace( + st_uid=os.getuid(), st_mode=0o40700))) + stack.enter_context(patch('os.close')) + stack.enter_context(patch.object(Path, 'read_text', read_text)) + chdir = stack.enter_context(patch('os.chdir')) + execute = stack.enter_context(patch('os.execve')) + if accepted: + self.guard.launch() + execute.assert_called_once() + self.assertEqual(execute.call_args.args[0], str(self.guard.BINARY)) + else: + with self.assertRaisesRegex(RuntimeError, 'Isolation'): + self.guard.launch() + execute.assert_not_called() + chdir.assert_not_called() + + def quoted_conflicts(self): + units = list(self.guard.EXCLUDED) + return ' '.join(units[:-1]) + ' "' + units[-1].replace('\\', '\\\\') + '"\n' + + def test_actual_launch_accepts_systemd_quoted_conflicts(self): + self.launch_mocked(self.quoted_conflicts(), True) + + def test_actual_launch_refuses_each_missing_exclusion(self): + import shlex + for missing in self.guard.EXCLUDED: + with self.subTest(missing=missing): + units = shlex.split(self.quoted_conflicts()) + units.remove(missing) + self.launch_mocked(shlex.join(units), False) + + def test_actual_launch_refuses_malformed_quotes(self): + with self.assertRaises(ValueError): + self.launch_mocked('"unfinished', True) + + def test_changed_preimage_refused(self): + import repair + with self.assertRaises(RuntimeError): + repair.corrected(b'changed guard') + + +if __name__ == '__main__': + unittest.main(verbosity=2)