feat(probe): prepare reviewed single-output Mosaic session trial
This commit is contained in:
parent
7adf77a701
commit
10c9b49bf8
6 changed files with 752 additions and 0 deletions
|
|
@ -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.
|
||||
|
|
|
|||
180
docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md
Normal file
180
docs/design/MOSAIC_FIRST_PHYSICAL_SESSION.md
Normal 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.
|
||||
68
tooling/probes/mosaic-first-session/README.md
Normal file
68
tooling/probes/mosaic-first-session/README.md
Normal 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.
|
||||
197
tooling/probes/mosaic-first-session/prepare.py
Normal file
197
tooling/probes/mosaic-first-session/prepare.py
Normal 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()
|
||||
118
tooling/probes/mosaic-first-session/repair.py
Normal file
118
tooling/probes/mosaic-first-session/repair.py
Normal 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()
|
||||
184
tooling/probes/mosaic-first-session/test_prepare.py
Normal file
184
tooling/probes/mosaic-first-session/test_prepare.py
Normal 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)
|
||||
Loading…
Reference in a new issue