feat(probe): prepare reviewed single-output Mosaic session trial

This commit is contained in:
fy59 2026-10-17 09:27:16 +02:00
parent 7adf77a701
commit 10c9b49bf8
6 changed files with 752 additions and 0 deletions

View file

@ -1,5 +1,10 @@
# Documentation # 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) [Isolated EGL/FX probe](design/STEP20C_HEADLESS_EGL_FX_PROBE.md)
records the single 2026-10-17 namespace failure: EXPERIMENTAL / records the single 2026-10-17 namespace failure: EXPERIMENTAL /
EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED. EGL_FX_PROBE_BLOCKED_BEFORE_HELPER / EGL_NOT_RUN / ALLOCATOR_NOT_ATTEMPTED.

View file

@ -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 -- <explicit-entry>` 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.

View file

@ -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.

View file

@ -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()

View file

@ -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()

View file

@ -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, '<audited-repair>', '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)