"""Real signed manifests, CA reissuance, HTTPS RRDP and rsync across CLI restarts.""" from __future__ import annotations import argparse, base64, functools, hashlib, http.server, json, shutil, ssl, subprocess, sys, tempfile, threading from datetime import timedelta from pathlib import Path from cryptography import x509 from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.serialization import Encoding import generate_repository as g from test_public_config import free_port, make_rsync_wrapper, read_json, run_checked, write_config def create_cases(fixture: Path, port: int) -> list[dict]: g.STRICT_NAMES = True g.RRDP_HOST = '127.0.0.1'; g.RRDP_PORT = port g.RRDP_URI = f'https://127.0.0.1:{port}/rrdp/notification.xml' g.ROOT_REPO_URI = 'rsync://127.0.0.1:873/custom/root/' g.CHILD_REPO_URI = 'rsync://127.0.0.1:873/custom/child/' root_key = serialization.load_pem_private_key((fixture/'keys/root.key').read_bytes(), None) child_key = serialization.load_pem_private_key((fixture/'keys/child.key').read_bytes(), None) original = fixture/'cases/baseline-v1/repository' root = x509.load_der_x509_certificate((original/'ta.cer').read_bytes()) child = x509.load_der_x509_certificate((original/'root/child.cer').read_bytes()) cases = [] # Rejections are followed by valid retries against the same durable history. specs = [ ('initial', 'a.mft', 2**159-1, 100, ['a.mft'], None, 1, ''), ('renamed', 'b.mft', 1, 101, ['b.mft'], None, 1, 'manifest filename changed'), ('stale-rename', 'c.mft', 0, 100, ['c.mft'], None, 0, 'thisUpdate not more recent'), ('failed-file', 'c.mft', 0, 102, ['c.mft'], None, 0, 'manifest file hash mismatch'), ('retry', 'c.mft', 0, 102, ['c.mft'], None, 1, 'manifest filename changed'), ('replay-a', 'a.mft', 2**159-1, 100, ['a.mft'], None, 0, 'thisUpdate not more recent'), ('back-a', 'a.mft', 0, 103, ['a.mft'], None, 1, 'manifest filename changed'), ('multi-sia', 'b.mft', 0, 104, ['b.mft', 'a.mft'], None, 0, 'manifestNumber not higher'), ('multi-sia-new', 'b.mft', 1, 104, ['b.mft', 'a.mft'], None, 1, ''), ('location-mismatch', 'b.mft', 2, 105, ['b.mft'], ['moved.mft'], 0, 'ManifestSiaUriMismatch'), ('multiple-ee', 'b.mft', 2, 105, ['b.mft'], ['other.mft', 'b.mft', 'b.mft'], 1, ''), ('same-number', 'b.mft', 2, 106, ['b.mft'], None, 0, 'manifestNumber not higher'), ('same-time', 'b.mft', 3, 105, ['b.mft'], None, 0, 'thisUpdate not more recent'), ('final', 'b.mft', 3, 107, ['b.mft'], None, 1, ''), ] work = fixture/'history-work'; work.mkdir() def manifest(path, number, stamp, files, issuer, signing_key, repo, ca_uri, ee_names): key = rsa.generate_private_key(public_exponent=65537, key_size=2048) ee = g.sign_cert(x509.Name([g.common_name('History Manifest EE')]), issuer, key.public_key(), signing_key, x509.random_serial_number(), False, key, signing_key, ip_ext=g.ip_inherit(), as_ext=g.as_resources(None), signed_object_uris=[repo+name for name in ee_names], crl_uri=repo+('root.crl' if repo==g.ROOT_REPO_URI else 'child.crl'), ca_issuer_uri=ca_uri) content = g.seq(g.integer(number), g.generalized_time(g.VALID_FROM+timedelta(days=30,seconds=stamp)), g.generalized_time(g.VALID_TO-timedelta(days=30)), g.oid(g.OID_SHA256), g.seq(*(g.seq(g.tlv(0x16,name.encode('ascii')), g.bit_string(hashlib.sha256(data).digest())) for name,data in files))) g.write_cms(content,g.OID_MANIFEST_ECONTENT,ee,key,path,work) for index, (name, filename, number, stamp, ca_names, ee_names, vrps, error) in enumerate(specs, 1): case = fixture/'history-cases'/name; repo=case/'repository' shutil.copytree(original,repo) for old in (repo/'child').glob('*.mft'): old.unlink() new_child = g.sign_cert(child.subject,root.subject,child_key.public_key(),root_key,1000+index,True,child_key,root_key, ip_ext=g.ip_resources([g.CHILD_PREFIX]),as_ext=g.as_resources([g.TEST_ASN]), ca_repository=g.CHILD_REPO_URI, manifest_uris=[g.CHILD_REPO_URI+n for n in ca_names], notify_uri=g.RRDP_URI, crl_uri=g.ROOT_REPO_URI+'root.crl', ca_issuer_uri=g.ROOT_REPO_URI+'child.cer') (repo/'root/child.cer').write_bytes(new_child.public_bytes(Encoding.DER)) # Keep the child's AIA-bound copy consistent too. (repo/'child/child.cer').write_bytes(new_child.public_bytes(Encoding.DER)) manifest(repo/'root/root.mft', index+10, index+200, [(n,(repo/'root'/n).read_bytes()) for n in ('root.crl','child.cer')],root.subject,root_key,g.ROOT_REPO_URI,g.ROOT_REPO_URI+'ta.cer',['root.mft']) manifest(repo/'child'/filename, number, stamp, [(n,(repo/'child'/n).read_bytes()) for n in ('child.crl','valid.roa','valid.asa')],child.subject,child_key,g.CHILD_REPO_URI,g.CHILD_REPO_URI+'child.cer',ee_names or [filename]) if name=='failed-file': (repo/'child/valid.roa').write_bytes(b'corrupted after signing manifest') # Each new publication uses an independent session and a complete snapshot. session = f'{index:08d}-2222-4333-8444-555555555555' paths = [(g.ROOT_REPO_URI+n,repo/'root'/n) for n in ('root.crl','child.cer','root.mft')] paths += [(g.CHILD_REPO_URI+n,repo/'child'/n) for n in ('child.crl','valid.roa','valid.asa',filename)] snap = (f'\n' +''.join(g.publish(uri,path) for uri,path in paths)+'\n').encode() http=case/'http/rrdp';http.mkdir(parents=True) (http/'snapshot.xml').write_bytes(snap) (http/'notification.xml').write_text(f'') cases.append(dict(name=name,root=case,vrps=vrps,error=error,renamed=name in {'renamed','stale-rename','failed-file','retry','replay-a','back-a'})) return cases def run(binary: str, evidence: Path|None=None) -> dict: proof={'transports':[]} with tempfile.TemporaryDirectory(prefix='panda-manifest-history-') as tmp: base=Path(tmp);fixture=base/'fixture';port=free_port() run_checked([sys.executable,str(Path(__file__).with_name('generate_repository.py')),'--output',str(fixture),'--rrdp-host','127.0.0.1','--rrdp-port',str(port),'--rsync-host','127.0.0.1','--strict-names']) cases=create_cases(fixture,port) active=base/'active';active.symlink_to(cases[0]['root']/'http',target_is_directory=True) requests=[] class Handler(http.server.SimpleHTTPRequestHandler): def do_GET(self): requests.append(self.path);super().do_GET() def log_message(self,*args):pass server=http.server.ThreadingHTTPServer(('127.0.0.1',port),functools.partial(Handler,directory=str(active))) tls=ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER);tls.load_cert_chain(fixture/'certs/rrdp-server.pem',fixture/'certs/rrdp-server.key');server.socket=tls.wrap_socket(server.socket,server_side=True) thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start() try: for transport in ('rrdp-then-rsync','rsync-only'): transport_root=base/transport;transport_root.mkdir();state=transport_root/'state' wrapper=transport_root/'rsync.py';wrapper_log=transport_root/'rsync.log' make_rsync_wrapper(wrapper,fixture,wrapper_log) text=wrapper.read_text().replace(str(fixture/'cases/baseline-v1/repository'),str(base/'repository')) wrapper.write_text(text);wrapper.chmod(0o755) runs=[];initial_csv=None for case in [cases[0],dict(cases[0],name='restart-identical'),*cases[1:],dict(cases[-1],name='restart-final')]: if active.is_symlink():active.unlink() active.symlink_to(case['root']/'http',target_is_directory=True) repository=base/'repository' if repository.is_symlink():repository.unlink() repository.symlink_to(case['root']/'repository',target_is_directory=True) out=transport_root/case['name'];config=transport_root/'validate.toml' write_config(config,fixture=fixture,output=out,state=state,ccr=out/'result.ccr',wrapper=wrapper,mirror=transport_root/'mirror',sync_preference=transport,rrdp_mode='auto',validation_time='2026-06-01T00:00:00Z',resource_mode='rfc6487',scope='publication-point') config_text=config.read_text() # Exercise CCR-off and one object worker, then the worker pool path. if case['name']=='restart-identical': config_text='\n'.join(line for line in config_text.splitlines() if not line.startswith('ccr-out ='))+'\n' config_text=config_text.replace('parallel-phase2-object-workers = 2','parallel-phase2-object-workers = 1') if not case['vrps']:config_text=config_text.replace('require-complete = true','require-complete = false') config.write_text(config_text) start=len(requests) result=subprocess.run([binary,'validate','--config',str(config)],text=True,capture_output=True,timeout=120) summary=read_json(out/'summary.json') report=(out/'report.json').read_text() if (out/'report.json').exists() else '' combined=result.stdout+result.stderr+report assert summary['vrps']==case['vrps'],(transport,case['name'],summary,combined[-5000:]) assert result.returncode==0,(case['name'],result.returncode,combined[-5000:]) if case['error']:assert case['error'] in combined,(case['name'],case['error'],combined[-5000:]) if case['renamed']:assert 'manifest filename changed' in combined,(case['name'],combined[-5000:]) if case['name']=='multi-sia-new':assert 'manifest filename changed' not in combined if case['vrps']: current=(out/'vrps.csv').read_bytes() if initial_csv is None:initial_csv=current assert current==initial_csv,(case['name'],'legal output changed') assert summary['resultStatus']=='success',summary else:assert summary['resultStatus']=='partial',summary seen=requests[start:] if transport=='rsync-only':assert not seen,seen else:assert not wrapper_log.exists(),'unexpected rsync fallback' runs.append(dict(name=case['name'],exit_code=result.returncode,status=summary['resultStatus'],vrps=summary['vrps'],requests=seen,expected_error=case['error'])) proof['transports'].append(dict(transport=transport,runs=runs)) finally:server.shutdown();server.server_close();thread.join(timeout=5) if evidence:evidence.write_text(json.dumps(proof,indent=2)+'\n') return proof if __name__=='__main__': p=argparse.ArgumentParser();p.add_argument('binary');p.add_argument('--evidence',type=Path);a=p.parse_args() result=run(a.binary,a.evidence);print(json.dumps(result))