mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-23 21:43:28 +00:00
159 lines
13 KiB
Python
159 lines
13 KiB
Python
"""Two production RNS directory processes, interrupted file, offline source, browser."""
|
|
import hashlib
|
|
import base64
|
|
from contextlib import closing
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import socket
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
import sysconfig
|
|
import tempfile
|
|
import time
|
|
import unittest
|
|
import RNS
|
|
from tools.tracks.blob_store import BlobStore
|
|
from tools.tracks.directory_index import DirectoryIndex
|
|
from tools.tracks.directory_service import load_identity
|
|
from tools.tracks.protocol import packed,unpacked,verify_manifest
|
|
from tools.tracks.attachment_index import encode_index
|
|
|
|
ROOT=Path(__file__).resolve().parents[2]
|
|
|
|
|
|
def free_port():
|
|
with socket.socket() as listener:listener.bind(('127.0.0.1',0));return listener.getsockname()[1]
|
|
|
|
|
|
class ReplicationNetworkTests(unittest.TestCase):
|
|
def test_native_restart_copy_and_fresh_browser_after_source_offline(self):
|
|
retained=ROOT/'.codex-build/hostlink-codec/track-replication-network'
|
|
retained.mkdir(parents=True,exist_ok=True)
|
|
(retained/'NOTES.txt').write_text('Two local native track services, interrupted copy restart, source stopped and new browser reading archived replica. Current production sources. Isolated synthetic author only; no physical device, RF or public publication. Retain compact result/logs until acceptance.')
|
|
children=[];flags=getattr(subprocess,'CREATE_NO_WINDOW',0)
|
|
# Windows venv python.exe is a redirector: terminating that PID can
|
|
# leave the real interpreter serving sockets. Own the actual process.
|
|
service_python=sys._base_executable if os.name=='nt' else sys.executable
|
|
service_environment={**os.environ,'TRAIL_MATE_REPLICATION_DEBUG':'1'}
|
|
if os.name=='nt':service_environment['PYTHONPATH']=sysconfig.get_path('purelib')
|
|
with tempfile.TemporaryDirectory() as work:
|
|
root=Path(work);source_state=root/'source';source_state.mkdir()
|
|
source_identity=load_identity(source_state)
|
|
data=b'<kml xmlns="http://www.opengis.net/kml/2.2"><Placemark><Point><coordinates>0,0</coordinates></Point></Placemark></kml>'+b'\n'*16384
|
|
digest=hashlib.sha256(data).digest();author=RNS.Identity();blobs=BlobStore(source_state/'track-data');index=DirectoryIndex(source_state/'track-data',blobs)
|
|
upload=blobs.begin(author.hash,digest,len(data))
|
|
for offset in range(0,len(data),1024):blobs.append(author.hash,upload['token'],offset,data[offset:offset+1024])
|
|
blobs.finish(author.hash,upload['token']);versions=[]
|
|
record=[1,author.get_public_key(),os.urandom(16),0,1,None,0,100,100,'Native replica track','',[0,0,0,0],[0,None,None,1],[digest,len(data),'application/vnd.google-earth.kml+xml',1]]
|
|
for revision in range(1,4):
|
|
record[4]=revision;record[5]=verify_manifest(versions[-1])['revision_hash'] if versions else None;record[8]=99+revision;record[6]=int(revision==3)
|
|
raw=packed(record);signed=[raw,author.sign(b'TrailMate.Track.Manifest.v1\0'+raw)];index.publish(author.hash,signed,record[5]);versions.append(signed)
|
|
# A separate v2 object exercises the signed index and real PNG in
|
|
# addition to the v1 history above; it shares the immutable KML.
|
|
png=base64.b64decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a7GoAAAAASUVORK5CYII=')
|
|
def put_extra(content):
|
|
file_hash=hashlib.sha256(content).digest();upload=blobs.begin(author.hash,file_hash,len(content))
|
|
if upload['token']:
|
|
for offset in range(0,len(content),1024):blobs.append(author.hash,upload['token'],offset,content[offset:offset+1024])
|
|
blobs.finish(author.hash,upload['token'])
|
|
return file_hash
|
|
png_hash=put_extra(png)
|
|
raw_index=encode_index([1,digest,len(data),[['icon',png_hash,len(png),'image/png','icon.png',0,1]]])
|
|
index_hash=put_extra(raw_index)
|
|
record=[2,author.get_public_key(),os.urandom(16),0,1,None,0,100,100,'Native replica package','',[0,0,0,0],[0,None,None,1],[digest,len(data),'application/vnd.google-earth.kml+xml',1],[0,[],0,0,0,0,0,None],[index_hash,len(raw_index),1,1]]
|
|
package_versions=[]
|
|
for revision in range(1,4):
|
|
record[4]=revision;record[5]=verify_manifest(package_versions[-1])['revision_hash'] if package_versions else None;record[8]=99+revision;record[6]=int(revision==3)
|
|
raw=packed(record);signed=[raw,author.sign(b'TrailMate.Track.Manifest.v2\0'+raw)];index.publish(author.hash,signed,record[5]);package_versions.append(signed)
|
|
fork_record=[2,author.get_public_key(),os.urandom(16),0,1,None,0,100,100,'Fork root','',[0,0,0,0],[0,None,None,1],[digest,len(data),'application/vnd.google-earth.kml+xml',1],[0,[],0,0,0,0,0,None],[index_hash,len(raw_index),1,1]]
|
|
raw=packed(fork_record);fork_root=[raw,author.sign(b'TrailMate.Track.Manifest.v2\0'+raw)];index.import_replica(author.hash,fork_root)
|
|
fork_id=verify_manifest(fork_root)['object_id']
|
|
for title in ('Fork left','Fork right'):
|
|
fork_record[4]=2;fork_record[5]=verify_manifest(fork_root)['revision_hash'];fork_record[8]=101;fork_record[9]=title
|
|
raw=packed(fork_record);index.import_replica(author.hash,[raw,author.sign(b'TrailMate.Track.Manifest.v2\0'+raw)])
|
|
index.close();blobs.close();object_id=verify_manifest(package_versions[0])['object_id'];latest=verify_manifest(package_versions[-1])['revision_hash']
|
|
source_port,replica_port=free_port(),free_port();source_config=root/'source-rns';source_config.mkdir();replica_config=root/'replica-rns';replica_config.mkdir()
|
|
base='[reticulum]\nshare_instance = No\nenable_transport = Yes\n[interfaces]\n'
|
|
(source_config/'config').write_text(base+f'[[Source]]\nenabled = Yes\ntype = TCPServerInterface\nlisten_ip = 127.0.0.1\nlisten_port = {source_port}\n')
|
|
(replica_config/'config').write_text(base+f'[[Source uplink]]\nenabled = Yes\ntype = TCPClientInterface\ntarget_host = 127.0.0.1\ntarget_port = {source_port}\n[[Readers]]\nenabled = Yes\ntype = TCPServerInterface\nlisten_ip = 127.0.0.1\nlisten_port = {replica_port}\n')
|
|
|
|
def start(label,args):
|
|
log=retained/(label+'.log');handle=log.open('w',encoding='utf-8');child=subprocess.Popen([service_python,*args],cwd=ROOT,stdout=handle,stderr=subprocess.STDOUT,creationflags=flags,env=service_environment);children.append((child,handle))
|
|
until=time.monotonic()+25
|
|
while child.poll()is None and time.monotonic()<until:
|
|
for line in log.read_text(encoding='utf-8').splitlines():
|
|
try:event=json.loads(line)
|
|
except ValueError:continue
|
|
if event.get('event')in('ready','bridge_ready'):return child,event
|
|
time.sleep(.1)
|
|
self.fail(label+' not ready: '+log.read_text(encoding='utf-8'))
|
|
|
|
def read_sql(path,statement):
|
|
if not path.is_file():return None
|
|
try:
|
|
with closing(sqlite3.connect('file:'+path.as_posix()+'?mode=ro',uri=True,timeout=1)) as db:return db.execute(statement).fetchone()
|
|
except sqlite3.Error:return None
|
|
|
|
try:
|
|
source,source_ready=start('source',['-m','tools.tracks.directory_service','--state',str(source_state),'--rns-config',str(source_config),'--run-seconds','300'])
|
|
replica_state=root/'replica';args=['-m','tools.tracks.directory_service','--state',str(replica_state),'--rns-config',str(replica_config),'--replica-seed',source_ready['discovery'],'--run-seconds','300']
|
|
replica,replica_ready=start('replica-first',args)
|
|
uploads=replica_state/'track-data'/'blobs.sqlite'
|
|
until=time.monotonic()+70;prefix=None
|
|
while replica.poll()is None and time.monotonic()<until:
|
|
prefix=read_sql(uploads,'SELECT offset FROM uploads LIMIT 1')
|
|
if prefix and 0<prefix[0]<len(data):break
|
|
time.sleep(.05)
|
|
self.assertTrue(prefix and 0<prefix[0]<len(data),'No partial copy: '+(retained/'replica-first.log').read_text())
|
|
replica.terminate();replica.wait(timeout=10)
|
|
replica,restarted=start('replica-restarted',args)
|
|
for field in ('delivery','discovery','epoch'):self.assertEqual(restarted[field],replica_ready[field])
|
|
database=replica_state/'track-data'/'tracks.sqlite';until=time.monotonic()+140;head=None
|
|
while replica.poll()is None and time.monotonic()<until:
|
|
head=read_sql(database,'SELECT count(*) FROM versions')
|
|
if head and head[0]==9:break
|
|
time.sleep(.1)
|
|
peer_diagnostic=read_sql(database,'SELECT state,due,scheduled FROM track_replica_peers LIMIT 1')
|
|
if peer_diagnostic:
|
|
decoded=__import__('msgpack').unpackb(peer_diagnostic[0],raw=False)
|
|
pending=unpacked(decoded['pending']) if decoded['pending'] else None
|
|
diagnostic={key:decoded[key] for key in ('after','high','item','deadline','caps_until','file','health','failures','diagnostic')}
|
|
diagnostic.update(due=peer_diagnostic[1],scheduled=peer_diagnostic[2],wallNow=int(time.time()),pendingOperation=pending[2] if pending else None,pendingRequest=pending[3].hex() if pending else None)
|
|
(retained/'peer-diagnostic.json').write_text(json.dumps(diagnostic,indent=2))
|
|
self.assertEqual(head,(9,),(retained/'replica-restarted.log').read_text())
|
|
peer=read_sql(database,'SELECT state FROM track_replica_peers LIMIT 1')
|
|
self.assertIsNotNone(peer)
|
|
# Import may finish one service tick before page checkpoint.
|
|
until=time.monotonic()+5
|
|
while time.monotonic()<until:
|
|
peer=read_sql(database,'SELECT state FROM track_replica_peers LIMIT 1')
|
|
state=__import__('msgpack').unpackb(peer[0],raw=False)
|
|
if state['after']==9:break
|
|
time.sleep(.1)
|
|
self.assertEqual(state['after'],9)
|
|
multi_port=free_port()
|
|
multi_bridge,multi_connection=start('multi-bridge',['tools/geocaching/packet_bridge.py','--tcp-port',str(replica_port),'--port','0','--origin',f'http://127.0.0.1:{multi_port}','--run-seconds','90'])
|
|
multi=subprocess.run(['node','tests/site/track-multidirectory-network.browser.mjs',f'ws://127.0.0.1:{multi_connection["port"]}/',replica_ready['discovery'],source_ready['discovery'],object_id.hex(),replica_ready['delivery'],source_ready['delivery']],cwd=ROOT,env={**os.environ,'TRAIL_MATE_MULTI_PAGE_PORT':str(multi_port)},capture_output=True,text=True,encoding='utf-8',timeout=75,creationflags=flags)
|
|
(retained/'multidirectory-browser.log').write_text(multi.stdout+multi.stderr,encoding='utf-8')
|
|
self.assertEqual(multi.returncode,0,multi.stdout+multi.stderr)
|
|
multi_bridge.terminate();multi_bridge.wait(timeout=10)
|
|
source.terminate();source.wait(timeout=10)
|
|
# Source TCP is gone before this fresh offline-reader browser connects.
|
|
with socket.socket() as socket_test:self.assertNotEqual(socket_test.connect_ex(('127.0.0.1',source_port)),0)
|
|
reader_port=free_port()
|
|
bridge,connection=start('bridge',['tools/geocaching/packet_bridge.py','--tcp-port',str(replica_port),'--port','0','--origin',f'http://127.0.0.1:{reader_port}','--run-seconds','90'])
|
|
reader_env={**os.environ,'TRAIL_MATE_REPLICA_PAGE_PORT':str(reader_port)}
|
|
browser=subprocess.run(['node','tests/site/track-replica-reader.browser.mjs',f'ws://127.0.0.1:{connection["port"]}/',replica_ready['discovery'],object_id.hex(),latest.hex(),digest.hex(),str(len(data)),fork_id.hex()],cwd=ROOT,env=reader_env,capture_output=True,text=True,encoding='utf-8',timeout=75,creationflags=flags)
|
|
(retained/'browser.log').write_text(browser.stdout+browser.stderr,encoding='utf-8')
|
|
self.assertEqual(browser.returncode,0,browser.stdout+browser.stderr)
|
|
(retained/'result.json').write_text(json.dumps({'passed':True,'nativeReplication':True,'interruptedPrefix':prefix[0],'sourceOffline':True,'checkpoint':9,'versions':9,'manifestVersions':[1,2],'requiredPackage':True,'authorForkCopied':True,'forkObjectId':fork_id.hex(),'pngHash':png_hash.hex(),'indexHash':index_hash.hex(),'fileHash':digest.hex(),'fileBytes':len(data),'author':author.get_public_key().hex(),'physicalUsb':False,'publicNetwork':False},indent=2))
|
|
finally:
|
|
for child,handle in reversed(children):
|
|
if child.poll()is None:child.terminate()
|
|
child.wait(timeout=10);handle.close()
|
|
|
|
|
|
if __name__=='__main__':unittest.main()
|