Dotfiles_ArchAP/tests/step15a/clipboard.py

83 lines
5 KiB
Python

#!/usr/bin/env python3
"""Synthetic clipboard on a private Wayland server; never log payloads.
Sensitive sentinels exist only in memory and must not reach the test DB.
Only aggregate counts and clipboard state labels are retained as evidence.
"""
import json
import os
from pathlib import Path
import secrets
import signal
import subprocess
import sys
import time
REPO=Path(__file__).resolve().parents[2]
sys.path.insert(0,str(REPO/'tests/session_v2/fixtures'))
from headless import Lab
ART=Path(os.environ['STEP15A_ARTIFACTS']);ART.mkdir(mode=0o700,parents=True,exist_ok=True)
with Lab() as lab:
counter=lab.directory/'counts'
spy=lab.directory/'spy.py'
spy.write_text('import os,sys\nsys.stdin.buffer.read()\nwith open(os.environ["TEST_COUNTS"],"a") as f:f.write(sys.argv[1]+" "+os.environ.get("CLIPBOARD_STATE","")+"\\n")\n')
lab.env['TEST_COUNTS']=str(counter)
def copy(payload,kind='text/plain',sensitive=False):
p=subprocess.Popen(['wl-copy','--foreground','--type',kind,*(['--sensitive'] if sensitive else [])],env=lab.env,stdin=subprocess.PIPE,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
lab.children.append(p);p.stdin.write(payload);p.stdin.close();time.sleep(.2)
def stop(procs):
for p in procs:
if p.poll() is None:
os.killpg(p.pid,signal.SIGTERM);p.wait(timeout=3)
png=subprocess.run(['magick','-size','2x2','xc:blue','png:-'],capture_output=True,env=lab.env,check=True).stdout
phases=[]
for mode in ['legacy','disjoint']:
before=len(counter.read_text().splitlines()) if counter.exists() else 0
watchers=[lab.start(['wl-paste',*(['--type','text'] if mode=='disjoint' else []),'--watch','python3',str(spy),'general']),lab.start(['wl-paste','--type','image/png','--watch','python3',str(spy),'image'])]
time.sleep(.2)
# Ignore the already-owned previous selection replayed to new watchers.
base=len(counter.read_text().splitlines()) if counter.exists() else 0
copy(png,'image/png');time.sleep(.2)
entries=counter.read_text().splitlines()[base:]
counts={name:sum(e.startswith(name+' ') for e in entries) for name in ['general','image']}
phases.append(dict(mode=mode,offer='PNG-only',callbacks=counts))
assert counts=={'general':0,'image':1},counts
# A data-control owner can offer both MIME types without focus/input.
protocol='/usr/share/qt6/wayland/protocols/wlr-data-control/wlr-data-control-unstable-v1.xml'
header=lab.directory/'data-control.h';code=lab.directory/'data-control.c'
subprocess.run(['wayland-scanner','client-header',protocol,str(header)],check=True)
subprocess.run(['wayland-scanner','private-code',protocol,str(code)],check=True)
provider=lab.directory/'mime-provider'
subprocess.run(['cc','-std=c17','-Wall','-Wextra','-I',str(lab.directory),str(REPO/'tests/step15a/mime-provider.c'),str(code),'-lwayland-client','-o',str(provider)],check=True)
base=len(counter.read_text().splitlines())
owner=lab.start([str(provider)]);time.sleep(.4)
entries=counter.read_text().splitlines()[base:]
counts={name:sum(e==name+' data' for e in entries) for name in ['general','image']}
assert counts=={'general':1,'image':1},counts
phases.append(dict(mode=mode,offer='text-and-PNG',callbacks=counts))
stop([owner])
stop(watchers)
subprocess.run(['wl-copy','--clear'],env=lab.env,check=True)
db=lab.directory/'db'
watchers=[lab.start(['wl-paste','--type','text','--watch','cliphist','-db-path',str(db),'store'])]
time.sleep(.15)
def stored_count():
p=subprocess.run(['cliphist','-db-path',str(db),'list'],env=lab.env,capture_output=True)
return len(p.stdout.splitlines()) if p.returncode==0 else 0
copy(b'TEST_ONLY ordinary');time.sleep(.2);assert stored_count()==1
# Random synthetic sensitive sentinel is never printed or written by tests.
sensitive=b'TEST_ONLY-'+secrets.token_bytes(32).hex().encode()
copy(sensitive,sensitive=True);time.sleep(.25);assert stored_count()==1
stop(watchers)
watchers=[lab.start(['wl-paste','--type','image/png','--watch','cliphist','-db-path',str(db),'store'])]
time.sleep(.15)
copy(png,'image/png');time.sleep(.2);assert stored_count()==2
copy(png,'image/png',sensitive=True);time.sleep(.2);assert stored_count()==2
stop(watchers)
# Also exercise the backend marker directly, independent of Wayland timing.
absent=lab.directory/'sensitive-only-db'
subprocess.run(['cliphist','-db-path',str(absent),'store'],input=sensitive,env=dict(lab.env,CLIPBOARD_STATE='sensitive'),check=True,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
assert not absent.exists()
result=dict(overlap=phases,sensitive_wayland_text_retained=False,sensitive_wayland_png_retained=False,sensitive_direct_db_created=False,
real_clipboard_access=False,lab=str(lab.directory))
(ART/'clipboard-metrics.json').write_text(json.dumps(result,indent=2));print(json.dumps(result))