import pathlib,subprocess,json,sqlite3,os,shlex,time import argparse,shutil parser=argparse.ArgumentParser(description='Actual static-release CLI against a new synthetic SQLite workspace') parser.add_argument('--workspace',type=pathlib.Path,required=True) parser.add_argument('--binary',type=pathlib.Path,required=True) args=parser.parse_args() w=args.workspace.resolve() assert w.is_relative_to('/tmp') and not w.exists() w.mkdir();(w/'home').mkdir() shutil.copyfile(args.binary,w/'forgejo');(w/'forgejo').chmod(0o755) fixtures=w/'fixtures'; fixtures.mkdir(exist_ok=True) A='AbCdEf012345678-'*4;B='ZyXwVu_987654321'*4 # synthetic exact64 assert len(A)==64 and len(B)==64 for name,data,mode in [('good',A,0o400),('replacement',B,0o440),('newline',A+'\n',0o400),('short',A[:-1],0o400),('long',A+'A',0o400),('badbyte',A[:-1]+'=',0o400),('world',A,0o444),('writable',A,0o600),('executable',A,0o500),('empty','',0o400),('CLIENT_SECRET','',0o400)]: p=fixtures/name if p.exists():p.chmod(0o600) p.write_text(data);p.chmod(mode) for name in ['symlink']: p=fixtures/name if not p.exists():p.symlink_to('good') if not (fixtures/'fifo').exists():os.mkfifo(fixtures/'fifo',0o400) (fixtures/'directory').mkdir(exist_ok=True) (fixtures/'badparent').mkdir(exist_ok=True);(fixtures/'badparent').chmod(0o777) if (fixtures/'badparent/value').exists():(fixtures/'badparent/value').chmod(0o600) (fixtures/'badparent/value').write_text(A);(fixtures/'badparent/value').chmod(0o400) if not (fixtures/'parentlink').exists():(fixtures/'parentlink').symlink_to('directory',target_is_directory=True) (w/'runtime').mkdir(exist_ok=True) dbfile=w/'runtime/test.db' if dbfile.exists():dbfile.unlink() # isolated synthetic test database only ini=w/'runtime/app.ini';ini.write_text(f'''APP_NAME = synthetic-private-input-test RUN_USER = worsch WORK_PATH = {w}/runtime [security] INSTALL_LOCK = true SECRET_KEY = SYNTHETIC-TEST-KEY [database] DB_TYPE = sqlite3 SLOW_QUERY_THRESHOLD = 1ns PATH = {w}/runtime/test.db [server] APP_DATA_PATH = {w}/runtime/data OFFLINE_MODE = true [repository] ROOT = {w}/runtime/repos [log] MODE = console LEVEL = Error ''') env={'PATH':'/usr/bin:/bin','HOME':str(w/'home'),'GIT_CONFIG_NOSYSTEM':'1','GIT_CONFIG_GLOBAL':str(w/'home/test.gitconfig'),'GIT_TERMINAL_PROMPT':'0','USER':'worsch','GITEA_WORK_DIR':str(w/'runtime')} base=['bwrap','--unshare-pid','--unshare-net','--ro-bind','/usr','/usr','--ro-bind','/lib','/lib','--ro-bind','/lib64','/lib64','--symlink','usr/bin','/bin','--proc','/proc','--dev','/dev','--tmpfs','/tmp','--bind',str(w),str(w),'--dir','/run','--ro-bind',str(fixtures),'/run/forgejo-oidc','--ro-bind',str(fixtures/'good'),'/run/forgejo-oidc/CLIENT_SECRET','--bind',str(fixtures),'/run/writable-input','--chdir',str(w/'runtime'),'--','env','-i']+[k+'='+v for k,v in env.items()]+[str(w/'forgejo'),'--config',str(ini)] results=[] def run(args,ok=True,reason=None): p=subprocess.run(base+args,capture_output=True,text=True,timeout=60); output=p.stdout+p.stderr assert A not in output and B not in output, 'synthetic input leaked in output' assert all(A not in arg and B not in arg for arg in base+args), 'synthetic input in argv' assert all(A not in v and B not in v for v in env.values()), 'synthetic input in env' results.append({'operation':args,'returncode':p.returncode,'stdout_stderr_secret_absent':True}) (w/'cli-attempts.json').write_text(json.dumps(results,indent=2)) if (p.returncode==0)!=ok:raise AssertionError('CLI unexpected status: '+repr(args)+'\n'+output[-5000:]) if reason:assert reason in output,output[-2000:] return p run(['migrate']) clean_ini=ini.read_text() ini.write_text(clean_ini.replace('DB_TYPE = sqlite3', 'DB_TYPE = sqlite3\nLOG_SQL = true')) run(['admin','auth','add-oauth','--name','refused-debug-logging','--provider','github','--secret-file','/run/forgejo-oidc/CLIENT_SECRET'],False,'OAuth configuration requires SQL logging disabled') ini.write_text(clean_ini) def rows(): with sqlite3.connect(w/'runtime/test.db') as db:return db.execute('select id,name,cfg from login_source order by id').fetchall() def cfg():return json.loads(rows()[0][2]) run(['admin','auth','add-oauth','--name','synthetic-oauth','--provider','github','--key','synthetic-public-id','--secret-file','/run/forgejo-oidc/CLIENT_SECRET','--group-claim-name','groups','--admin-group','unchanged-admin','--scopes','read:user,user:email']) assert len(rows())==1 and cfg()['ClientSecret']==A id=str(rows()[0][0]); before=cfg() run(['admin','auth','update-oauth','--id',id,'--icon-url','https://synthetic.invalid/icon']) assert cfg()['ClientSecret']==A preserved=cfg() run(['admin','auth','update-oauth','--id',id,'--secret-file','/run/forgejo-oidc/replacement']) after=cfg();assert after['ClientSecret']==B assert {k:v for k,v in after.items() if k!='ClientSecret'}=={k:v for k,v in preserved.items() if k!='ClientSecret'},'secret replacement changed unrelated config' # Guarded inactive source exercises actual update omission and strict list semantics # without discovery/network or claiming a browser login. with sqlite3.connect(w/'runtime/test.db') as db: guarded=after|{'Provider':'openidConnect','OpenIDConnectAutoDiscoveryURL':'https://synthetic.invalid/.well-known/openid-configuration','RequiredClaimName':'groups','RequiredClaimValues':['forge-admins','forge-coulomb-owners','forge-coulomb-readers']} db.execute('update login_source set is_active=0,cfg=? where id=?',(json.dumps(guarded),id)) run(['admin','auth','update-oauth','--id',id,'--name','synthetic-guarded']) assert cfg()['RequiredClaimValues']==guarded['RequiredClaimValues'] and cfg()['ClientSecret']==B run(['admin','auth','update-oauth','--id',id,'--secret-file','/run/forgejo-oidc/good']) assert cfg()['ClientSecret']==A and cfg()['RequiredClaimValues']==guarded['RequiredClaimValues'] for path in ['/run/forgejo-oidc/'+n for n in ['newline','short','long','badbyte','world','writable','executable','empty','symlink','fifo','directory','missing','badparent/value','parentlink/value']]+['relative','','/run/forgejo-oidc/../forgejo-oidc/good','/run/writable-input/good']: before=rows() for cmd in [['admin','auth','add-oauth','--name','refused','--provider','github'],['admin','auth','update-oauth','--id',id,'--name','refused']]: run(cmd+['--secret-file',path],False,'secret-file input refused') assert rows()==before,'failure changed DB' for action in [['add-oauth','--name','refused'],['update-oauth','--id',id]]: before=rows();run(['admin','auth']+action+['--secret','','--secret-file','/run/forgejo-oidc/good'],False,'mutually exclusive');assert rows()==before # Failure to clear/ambiguously replace gate keeps stored application bytes unchanged. before=rows();run(['admin','auth','update-oauth','--id',id,'--required-claim-values',''],False);assert rows()==before # Actual XORM error hook under a failing SQLite trigger must not disclose cfg. before=rows() with sqlite3.connect(w/'runtime/test.db') as database: database.execute("create trigger synthetic_private_error before update on login_source begin select raise(abort, 'synthetic-db-failure'); end") run(['admin','auth','update-oauth','--id',id,'--secret-file','/run/forgejo-oidc/replacement'],False,'OAuth source update failed') assert rows()==before with sqlite3.connect(w/'runtime/test.db') as database:database.execute('drop trigger synthetic_private_error') # Legacy explicit secret remains supported; synthetic legacy string is not private input. run(['admin','auth','update-oauth','--id',id,'--secret','synthetic-legacy-literal']) assert cfg()['ClientSecret']=='synthetic-legacy-literal' and cfg()['RequiredClaimValues']==guarded['RequiredClaimValues'] (w/'cli-results.json').write_text(json.dumps({'passed':True,'subprocesses':len(results),'real_backend_cli_and_sqlite':True,'network':'disabled by bwrap','secret_inputs':'synthetic only','private_file_values_never_in_argv_env_output':True,'actual_browser_login':False,'actual_readonly_file_bind':True,'replacement_preserves_other_config':True,'unsafe_SQL_logging_refused':True,'actual_error_and_slow_hooks_no_leak':True},indent=2));print('CLI integrations passed:',len(results))