197 lines
8.6 KiB
Python
197 lines
8.6 KiB
Python
#!/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()
|