mirror of
https://github.com/rightup/pyMC_Repeater.git
synced 2026-05-05 21:22:14 +02:00
Extend test to: serialization/deserialization with multi-byte paths
- Functionality of Packet.apply_path_hash_mode and get_path_hashes - Engine flood_forward and direct_forward with real multi-byte encoded packets - PacketBuilder.create_trace payload structure and TraceHandler parsing - Enforcement of max-hop boundaries per hash size
This commit is contained in:
@@ -1003,8 +1003,15 @@ class RepeaterHandler(BaseHandler):
|
||||
# Get neighbors from database
|
||||
neighbors = self.storage.get_neighbors() if self.storage else {}
|
||||
|
||||
# Format local_hash respecting path_hash_mode
|
||||
phm = self.config.get("mesh", {}).get("path_hash_mode", 0)
|
||||
_bc = {0: 1, 1: 2, 2: 3}.get(phm, 1)
|
||||
_hc = _bc * 2
|
||||
_val = int.from_bytes(bytes(self.local_hash_bytes[:_bc]), "big")
|
||||
local_hash_str = f"0x{_val:0{_hc}x}"
|
||||
|
||||
stats = {
|
||||
"local_hash": f"0x{self.local_hash:02x}",
|
||||
"local_hash": local_hash_str,
|
||||
"duplicate_cache_size": len(self.seen_packets),
|
||||
"cache_ttl": self.cache_ttl,
|
||||
"rx_count": self.rx_count,
|
||||
|
||||
@@ -241,6 +241,19 @@ class APIEndpoints:
|
||||
cherrypy.response.headers["Allow"] = "POST"
|
||||
raise cherrypy.HTTPError(405, "Method not allowed. This endpoint requires POST.")
|
||||
|
||||
def _fmt_hash(self, pubkey: bytes) -> str:
|
||||
"""Format a node hash as a hex string respecting the configured path_hash_mode.
|
||||
|
||||
path_hash_mode 0 (default) → 1-byte "0x19"
|
||||
path_hash_mode 1 → 2-byte "0x1927"
|
||||
path_hash_mode 2 → 3-byte "0x192722"
|
||||
"""
|
||||
mode = self.config.get("mesh", {}).get("path_hash_mode", 0)
|
||||
byte_count = {0: 1, 1: 2, 2: 3}.get(mode, 1)
|
||||
hex_chars = byte_count * 2
|
||||
value = int.from_bytes(bytes(pubkey[:byte_count]), "big")
|
||||
return f"0x{value:0{hex_chars}X}"
|
||||
|
||||
def _get_time_range(self, hours):
|
||||
end_time = int(time.time())
|
||||
return end_time - (hours * 3600), end_time
|
||||
@@ -2354,7 +2367,7 @@ class APIEndpoints:
|
||||
if runtime_info:
|
||||
identity_obj, config, identity_type = runtime_info
|
||||
identity_config["runtime"] = {
|
||||
"hash": f"0x{identity_obj.get_public_key()[0]:02X}",
|
||||
"hash": self._fmt_hash(identity_obj.get_public_key()),
|
||||
"address": identity_obj.get_address_bytes().hex(),
|
||||
"type": identity_type,
|
||||
"registered": True,
|
||||
@@ -3090,7 +3103,7 @@ class APIEndpoints:
|
||||
{
|
||||
"name": "repeater",
|
||||
"type": "repeater",
|
||||
"hash": f"0x{repeater_hash:02X}",
|
||||
"hash": self._fmt_hash(self.daemon_instance.local_identity.get_public_key()),
|
||||
"max_clients": repeater_acl.max_clients,
|
||||
"authenticated_clients": repeater_acl.get_num_clients(),
|
||||
"has_admin_password": bool(repeater_acl.admin_password),
|
||||
@@ -3109,7 +3122,7 @@ class APIEndpoints:
|
||||
{
|
||||
"name": name,
|
||||
"type": "room_server",
|
||||
"hash": f"0x{hash_byte:02X}",
|
||||
"hash": self._fmt_hash(identity.get_public_key()),
|
||||
"max_clients": acl.max_clients,
|
||||
"authenticated_clients": acl.get_num_clients(),
|
||||
"has_admin_password": bool(acl.admin_password),
|
||||
@@ -3173,7 +3186,7 @@ class APIEndpoints:
|
||||
identity_map[repeater_hash] = {
|
||||
"name": "repeater",
|
||||
"type": "repeater",
|
||||
"hash": f"0x{repeater_hash:02X}",
|
||||
"hash": self._fmt_hash(self.daemon_instance.local_identity.get_public_key()),
|
||||
}
|
||||
|
||||
# Add room servers
|
||||
@@ -3182,7 +3195,7 @@ class APIEndpoints:
|
||||
identity_map[hash_byte] = {
|
||||
"name": name,
|
||||
"type": "room_server",
|
||||
"hash": f"0x{hash_byte:02X}",
|
||||
"hash": self._fmt_hash(identity.get_public_key()),
|
||||
}
|
||||
|
||||
# Filter by identity if requested
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-DPDXVCFJ.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
|
||||
import{a as p,b as n,g as m,e as t,s as g,t as s,j as d,p as l}from"./index-oQ7o_f66.js";const f={class:"flex items-center justify-between mb-4"},w={class:"text-xl font-semibold text-content-primary dark:text-content-primary"},v={class:"mb-6"},h={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},y={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},C={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},B={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},j={class:"flex gap-3"},_=p({__name:"ConfirmDialog",props:{show:{type:Boolean},title:{default:"Confirm Action"},message:{},confirmText:{default:"Confirm"},cancelText:{default:"Cancel"},variant:{default:"warning"}},emits:["close","confirm"],setup(c,{emit:b}){const o=c,r=b,u=i=>{i.target===i.currentTarget&&r("close")},k={danger:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",warning:"bg-yellow-100 dark:bg-yellow-500/20 border-yellow-500/30 text-yellow-600 dark:text-yellow-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},x={danger:"bg-red-500 hover:bg-red-600",warning:"bg-yellow-500 hover:bg-yellow-600",info:"bg-blue-500 hover:bg-blue-600"};return(i,e)=>o.show?(l(),n("div",{key:0,onClick:u,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[t("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[3]||(e[3]=g(()=>{},["stop"]))},[t("div",f,[t("h3",w,s(o.title),1),t("button",{onClick:e[0]||(e[0]=a=>r("close")),class:"text-content-secondary dark:text-content-muted hover:text-content-primary dark:hover:text-content-primary transition-colors"},e[4]||(e[4]=[t("svg",{class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"})],-1)]))]),t("div",v,[t("div",{class:d(["inline-flex p-3 rounded-xl mb-4",k[o.variant]])},[o.variant==="danger"?(l(),n("svg",h,e[5]||(e[5]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):o.variant==="warning"?(l(),n("svg",y,e[6]||(e[6]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"},null,-1)]))):(l(),n("svg",C,e[7]||(e[7]=[t("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),t("p",B,s(o.message),1)]),t("div",j,[t("button",{onClick:e[1]||(e[1]=a=>r("close")),class:"flex-1 px-4 py-3 rounded-xl bg-background-mute dark:bg-white/5 hover:bg-stroke-subtle dark:hover:bg-white/10 text-content-primary dark:text-content-primary transition-all duration-200 border border-stroke-subtle dark:border-stroke/10"},s(o.cancelText),1),t("button",{onClick:e[2]||(e[2]=a=>r("confirm")),class:d(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",x[o.variant]])},s(o.confirmText),3)])])])):m("",!0)}});export{_};
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{a as e,b as r,i as o,p as n}from"./index-DPDXVCFJ.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help & Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
|
||||
import{a as e,b as r,i as o,p as n}from"./index-oQ7o_f66.js";const d=e({name:"HelpView",__name:"Help",setup(a){return(i,t)=>(n(),r("div",null,t[0]||(t[0]=[o('<div class="glass-card backdrop-blur border border-stroke-subtle dark:border-white/10 rounded-[15px] p-8"><h1 class="text-content-primary dark:text-content-primary text-2xl font-semibold mb-6">Help & Documentation</h1><div class="text-center py-12"><div class="text-primary mb-6"><svg class="w-20 h-20 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"></path></svg></div><h2 class="text-content-primary dark:text-content-primary text-xl font-medium mb-3">pyMC Repeater Wiki</h2><p class="text-content-secondary dark:text-content-muted mb-8 max-w-md mx-auto"> Access documentation, setup guides, troubleshooting tips, and community resources on our official wiki. </p><a href="https://github.com/rightup/pyMC_Repeater/wiki" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 bg-primary hover:bg-primary/80 text-white dark:text-background font-medium py-3 px-6 rounded-xl transition-colors duration-200"><svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"></path></svg> Visit Wiki Documentation </a><div class="mt-8 text-xs text-content-muted dark:text-content-muted"> Opens in a new tab </div></div></div>',1)])))}});export{d as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{a as k,b as o,g,e as r,j as a,t as p,s as x,p as s}from"./index-DPDXVCFJ.js";const f={class:"mb-6"},m={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},v={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},h={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},w={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},C={class:"flex"},B=k({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(i,{emit:d}){const t=i,l=d,c=n=>{n.target===n.currentTarget&&l("close")},b={success:"bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400",error:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},u={success:"bg-green-500 hover:bg-green-600",error:"bg-red-500 hover:bg-red-600",info:"bg-blue-500 hover:bg-blue-600"};return(n,e)=>t.show?(s(),o("div",{key:0,onClick:c,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[r("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[1]||(e[1]=x(()=>{},["stop"]))},[r("div",f,[r("div",{class:a(["inline-flex p-3 rounded-xl mb-4",b[t.variant]])},[t.variant==="success"?(s(),o("svg",m,e[2]||(e[2]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):t.variant==="error"?(s(),o("svg",v,e[3]||(e[3]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(s(),o("svg",h,e[4]||(e[4]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),r("p",w,p(t.message),1)]),r("div",C,[r("button",{onClick:e[0]||(e[0]=y=>l("close")),class:a(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",u[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _};
|
||||
import{a as k,b as o,g,e as r,j as a,t as p,s as x,p as s}from"./index-oQ7o_f66.js";const f={class:"mb-6"},m={key:0,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},v={key:1,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},h={key:2,class:"w-6 h-6",fill:"none",stroke:"currentColor",viewBox:"0 0 24 24"},w={class:"text-content-secondary dark:text-content-primary/80 text-base leading-relaxed"},C={class:"flex"},B=k({__name:"MessageDialog",props:{show:{type:Boolean},message:{},variant:{default:"success"}},emits:["close"],setup(i,{emit:d}){const t=i,l=d,c=n=>{n.target===n.currentTarget&&l("close")},b={success:"bg-green-100 dark:bg-green-500/20 border-green-600/40 dark:border-green-500/30 text-green-600 dark:text-green-400",error:"bg-red-100 dark:bg-red-500/20 border-red-500/30 text-red-600 dark:text-red-400",info:"bg-blue-500/20 border-blue-500/30 text-blue-600 dark:text-blue-400"},u={success:"bg-green-500 hover:bg-green-600",error:"bg-red-500 hover:bg-red-600",info:"bg-blue-500 hover:bg-blue-600"};return(n,e)=>t.show?(s(),o("div",{key:0,onClick:c,class:"fixed inset-0 bg-black/40 backdrop-blur-lg z-[99999] flex items-center justify-center p-4",style:{"backdrop-filter":"blur(8px) saturate(180%)",position:"fixed",top:"0",left:"0",right:"0",bottom:"0"}},[r("div",{class:"bg-white dark:bg-surface-elevated backdrop-blur-xl rounded-[20px] p-6 w-full max-w-md border border-stroke-subtle dark:border-white/10",onClick:e[1]||(e[1]=x(()=>{},["stop"]))},[r("div",f,[r("div",{class:a(["inline-flex p-3 rounded-xl mb-4",b[t.variant]])},[t.variant==="success"?(s(),o("svg",m,e[2]||(e[2]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M5 13l4 4L19 7"},null,-1)]))):t.variant==="error"?(s(),o("svg",v,e[3]||(e[3]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M6 18L18 6M6 6l12 12"},null,-1)]))):(s(),o("svg",h,e[4]||(e[4]=[r("path",{"stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2",d:"M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"},null,-1)])))],2),r("p",w,p(t.message),1)]),r("div",C,[r("button",{onClick:e[0]||(e[0]=y=>l("close")),class:a(["flex-1 px-4 py-3 rounded-xl text-white transition-all duration-200",u[t.variant]])}," OK ",2)])])])):g("",!0)}});export{B as _};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,4 +1,4 @@
|
||||
import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-DPDXVCFJ.js";/**
|
||||
import{L as J,a as zl,r as ut,o as Hl,$ as Ul,P as Rn,D as ql,b as tt,e as Z,g as Yt,t as Is,w as Kl,v as Vl,X as Ji,j as Tn,s as jl,p as it,x as Gl}from"./index-oQ7o_f66.js";/**
|
||||
* Copyright (c) 2014-2024 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
import{M as x,c as s}from"./index-DPDXVCFJ.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
|
||||
import{M as x,c as s}from"./index-oQ7o_f66.js";const l={7:-7.5,8:-10,9:-12.5,10:-15,11:-17.5,12:-20},d=-116,i=8,u=5;function y(t,e){return t-e}function S(t){return l[t]??l[i]}function f(t,e){const r=e+u;if(t<=e){const o=t<=e-5?0:1;return{bars:o,color:"text-red-600 dark:text-red-400",snr:t,quality:o===0?"none":"poor"}}if(t<r){const n=(t-e)/u<.5?2:3;return{bars:n,color:n===2?"text-orange-600 dark:text-orange-400":"text-yellow-600 dark:text-yellow-400",snr:t,quality:"fair"}}const a=t-r>=10?5:4;return{bars:a,color:a===5?"text-green-600 dark:text-green-400":"text-green-600 dark:text-green-300",snr:t,quality:a===5?"excellent":"good"}}function N(){const t=x(),e=s(()=>t.noiseFloorDbm??d),r=s(()=>t.stats?.config?.radio?.spreading_factor??i),c=s(()=>S(r.value));return{getSignalQuality:o=>{if(!o||o>0||o<-120)return{bars:0,color:"text-gray-400 dark:text-gray-500",snr:-999,quality:"none"};const n=y(o,e.value),g=Math.max(-30,Math.min(20,n));return f(g,c.value)},noiseFloor:e,spreadingFactor:r,minSNR:c}}export{N as u};
|
||||
@@ -8,7 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<script type="module" crossorigin src="/assets/index-DPDXVCFJ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-oQ7o_f66.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CZAQFiLW.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Comprehensive tests for pyMC_Repeater engine.py — RepeaterHandler.
|
||||
tests for pyMC_Repeater engine.py — RepeaterHandler.
|
||||
|
||||
Covers: flood_forward, direct_forward, process_packet, duplicate detection,
|
||||
mark_seen, validate_packet, packet scoring, TX delay, cache management,
|
||||
|
||||
720
tests/test_flood_loop_dedup.py
Normal file
720
tests/test_flood_loop_dedup.py
Normal file
@@ -0,0 +1,720 @@
|
||||
"""
|
||||
Tests for flood packet loop detection and duplicate suppression.
|
||||
|
||||
Exercises the real RepeaterHandler engine with real pymc_core Packet/PathUtils
|
||||
objects to verify:
|
||||
- Duplicate packet suppression via calculate_packet_hash (SHA256-based)
|
||||
- Loop detection modes (off, minimal, moderate, strict) with real path bytes
|
||||
- Flood re-forwarding prevention (own hash already in path)
|
||||
- Multi-byte hash mode interaction with loop/dedup
|
||||
- Global flood policy enforcement
|
||||
- mark_seen / is_duplicate cache behaviour
|
||||
- do_not_retransmit flag handling
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pymc_core.protocol import Packet, PathUtils
|
||||
from pymc_core.protocol.constants import (
|
||||
MAX_PATH_SIZE,
|
||||
ROUTE_TYPE_FLOOD,
|
||||
ROUTE_TYPE_TRANSPORT_FLOOD,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOCAL_HASH_BYTES = bytes([0xAB, 0xCD, 0xEF])
|
||||
|
||||
|
||||
def _make_flood_packet(
|
||||
path_bytes: bytes = b"",
|
||||
hash_size: int = 1,
|
||||
hash_count: int = 0,
|
||||
payload: bytes = b"\x01\x02\x03\x04",
|
||||
) -> Packet:
|
||||
"""Create a real flood Packet with the given path encoding."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.path = bytearray(path_bytes)
|
||||
pkt.path_len = PathUtils.encode_path_len(hash_size, hash_count)
|
||||
pkt.payload = bytearray(payload)
|
||||
pkt.payload_len = len(payload)
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_handler(
|
||||
loop_detect="off",
|
||||
path_hash_mode=0,
|
||||
local_hash_bytes=None,
|
||||
global_flood_allow=True,
|
||||
):
|
||||
"""Create a RepeaterHandler with real engine logic, mocking only hardware."""
|
||||
lhb = local_hash_bytes or LOCAL_HASH_BYTES
|
||||
config = {
|
||||
"repeater": {
|
||||
"mode": "forward",
|
||||
"cache_ttl": 3600,
|
||||
"use_score_for_tx": False,
|
||||
"score_threshold": 0.3,
|
||||
"send_advert_interval_hours": 0,
|
||||
"node_name": "test-node",
|
||||
},
|
||||
"mesh": {
|
||||
"global_flood_allow": global_flood_allow,
|
||||
"loop_detect": loop_detect,
|
||||
"path_hash_mode": path_hash_mode,
|
||||
},
|
||||
"delays": {"tx_delay_factor": 1.0, "direct_tx_delay_factor": 0.5},
|
||||
"duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": True},
|
||||
"radio": {
|
||||
"spreading_factor": 8,
|
||||
"bandwidth": 125000,
|
||||
"coding_rate": 8,
|
||||
"preamble_length": 17,
|
||||
},
|
||||
}
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.radio = MagicMock(
|
||||
spreading_factor=8,
|
||||
bandwidth=125000,
|
||||
coding_rate=8,
|
||||
preamble_length=17,
|
||||
frequency=915000000,
|
||||
tx_power=14,
|
||||
)
|
||||
dispatcher.local_identity = MagicMock()
|
||||
with (
|
||||
patch("repeater.engine.StorageCollector"),
|
||||
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
|
||||
):
|
||||
from repeater.engine import RepeaterHandler
|
||||
|
||||
h = RepeaterHandler(config, dispatcher, lhb[0], local_hash_bytes=lhb)
|
||||
return h
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. Duplicate suppression — real packet hash (SHA256)
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDuplicateSuppression:
|
||||
"""Verify duplicate packets are detected via real calculate_packet_hash."""
|
||||
|
||||
def test_same_packet_forwarded_twice_is_duplicate(self):
|
||||
"""Forwarding the same packet a second time must be rejected as duplicate."""
|
||||
h = _make_handler()
|
||||
pkt1 = _make_flood_packet(payload=b"\xDE\xAD")
|
||||
result1 = h.flood_forward(pkt1)
|
||||
assert result1 is not None
|
||||
|
||||
# Same content in a fresh Packet object
|
||||
pkt2 = _make_flood_packet(payload=b"\xDE\xAD")
|
||||
result2 = h.flood_forward(pkt2)
|
||||
assert result2 is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
|
||||
def test_different_payload_not_duplicate(self):
|
||||
"""Packets with different payloads have different hashes."""
|
||||
h = _make_handler()
|
||||
pkt1 = _make_flood_packet(payload=b"\x01\x02")
|
||||
assert h.flood_forward(pkt1) is not None
|
||||
|
||||
pkt2 = _make_flood_packet(payload=b"\x03\x04")
|
||||
assert h.flood_forward(pkt2) is not None
|
||||
|
||||
def test_mark_seen_makes_is_duplicate_true(self):
|
||||
"""mark_seen records the hash; is_duplicate finds it."""
|
||||
h = _make_handler()
|
||||
pkt = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
assert not h.is_duplicate(pkt)
|
||||
h.mark_seen(pkt)
|
||||
assert h.is_duplicate(pkt)
|
||||
|
||||
def test_packet_hash_uses_real_sha256(self):
|
||||
"""Verify the hash comes from real Packet.calculate_packet_hash."""
|
||||
pkt = _make_flood_packet(payload=b"\x01\x02\x03")
|
||||
hash_bytes = pkt.calculate_packet_hash()
|
||||
assert isinstance(hash_bytes, bytes)
|
||||
assert len(hash_bytes) > 0
|
||||
# Same content → same hash
|
||||
pkt2 = _make_flood_packet(payload=b"\x01\x02\x03")
|
||||
assert pkt2.calculate_packet_hash() == hash_bytes
|
||||
|
||||
def test_different_path_same_payload_same_hash(self):
|
||||
"""
|
||||
Packet hash is based on payload_type + payload (not path),
|
||||
except for TRACE packets. Two flood packets with different paths
|
||||
but same payload have the same hash.
|
||||
"""
|
||||
pkt_a = _make_flood_packet(path_bytes=b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xFF")
|
||||
pkt_b = _make_flood_packet(path_bytes=b"\x22", hash_size=1, hash_count=1,
|
||||
payload=b"\xFF")
|
||||
assert pkt_a.calculate_packet_hash() == pkt_b.calculate_packet_hash()
|
||||
|
||||
def test_seen_cache_eviction(self):
|
||||
"""When cache exceeds max_cache_size, oldest entries are evicted."""
|
||||
h = _make_handler()
|
||||
h.max_cache_size = 3
|
||||
|
||||
packets = [_make_flood_packet(payload=bytes([i, i + 1])) for i in range(5)]
|
||||
for p in packets:
|
||||
h.mark_seen(p)
|
||||
|
||||
# Oldest entries (0, 1) should have been evicted
|
||||
assert not h.is_duplicate(packets[0])
|
||||
assert not h.is_duplicate(packets[1])
|
||||
# Recent entries still present
|
||||
assert h.is_duplicate(packets[2])
|
||||
assert h.is_duplicate(packets[3])
|
||||
assert h.is_duplicate(packets[4])
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. Loop detection modes — 1-byte hash
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestLoopDetection1Byte:
|
||||
"""Loop detection with 1-byte hash paths (mode 0)."""
|
||||
|
||||
def test_loop_detect_off_allows_own_hash(self):
|
||||
"""With loop_detect=off, packet with our hash in path is forwarded."""
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path contains our 1-byte hash (0xAB) once
|
||||
pkt = _make_flood_packet(b"\xAB", hash_size=1, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_loop_detect_strict_blocks_single_occurrence(self):
|
||||
"""strict mode (threshold=1): one occurrence of our hash → loop."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB", hash_size=1, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_loop_detect_moderate_allows_one_occurrence(self):
|
||||
"""moderate mode (threshold=2): one occurrence is fine."""
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\xAB", hash_size=1, hash_count=2)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_loop_detect_moderate_blocks_two_occurrences(self):
|
||||
"""moderate mode (threshold=2): two occurrences → loop."""
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB\x11\xAB", hash_size=1, hash_count=3)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_loop_detect_minimal_allows_three_occurrences(self):
|
||||
"""minimal mode (threshold=4): three occurrences still OK."""
|
||||
h = _make_handler(loop_detect="minimal",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB\x11\xAB\x22\xAB", hash_size=1, hash_count=5)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_loop_detect_minimal_blocks_four_occurrences(self):
|
||||
"""minimal mode (threshold=4): four occurrences → loop."""
|
||||
h = _make_handler(loop_detect="minimal",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(
|
||||
b"\xAB\x11\xAB\x22\xAB\x33\xAB", hash_size=1, hash_count=7
|
||||
)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_loop_detect_no_match_passes(self):
|
||||
"""Strict mode still passes if our hash is not in the path."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33", hash_size=1, hash_count=3)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. Own-hash re-forwarding prevention
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestOwnHashReForwarding:
|
||||
"""
|
||||
After a repeater flood_forwards a packet (appending its own hash),
|
||||
receiving that same packet back should be handled correctly.
|
||||
"""
|
||||
|
||||
def test_forward_then_receive_again_is_duplicate(self):
|
||||
"""
|
||||
After forwarding, the packet hash is in seen_packets.
|
||||
Receiving an identical packet is a duplicate.
|
||||
"""
|
||||
h = _make_handler(loop_detect="off")
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xAA\xBB")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
# The original packet's payload hash was marked seen
|
||||
|
||||
# Receiving same original packet again (before our hop was appended)
|
||||
pkt2 = _make_flood_packet(b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xAA\xBB")
|
||||
result2 = h.flood_forward(pkt2)
|
||||
assert result2 is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
|
||||
def test_strict_detects_own_hash_after_flood_chain(self):
|
||||
"""
|
||||
If the forwarded packet (with our hash appended) loops back to us,
|
||||
strict mode detects our hash in the path.
|
||||
"""
|
||||
our_hash = bytes([0xAB, 0xCD, 0xEF])
|
||||
h = _make_handler(loop_detect="strict", local_hash_bytes=our_hash)
|
||||
|
||||
# Original packet arrives, we forward (appending 0xAB)
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1,
|
||||
payload=b"\xDD\xEE")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
# Now path is [0x11, 0xAB], and this exact payload is in seen_packets
|
||||
|
||||
# Suppose another node re-forwards it with an additional hop,
|
||||
# so it's a new payload in the packet hash sense (different path iteration)
|
||||
# but path contains our hash 0xAB
|
||||
looped_pkt = _make_flood_packet(
|
||||
b"\x11\xAB\x22", hash_size=1, hash_count=3,
|
||||
payload=b"\xDD\xEE\xFF" # different payload → not a duplicate
|
||||
)
|
||||
result2 = h.flood_forward(looped_pkt)
|
||||
assert result2 is None
|
||||
assert "loop" in looped_pkt.drop_reason.lower()
|
||||
|
||||
def test_off_mode_still_catches_duplicate_after_own_forward(self):
|
||||
"""Even with loop_detect=off, duplicate suppression still works."""
|
||||
h = _make_handler(loop_detect="off")
|
||||
pkt = _make_flood_packet(payload=b"\x42\x43")
|
||||
assert h.flood_forward(pkt) is not None
|
||||
|
||||
pkt2 = _make_flood_packet(payload=b"\x42\x43")
|
||||
result = h.flood_forward(pkt2)
|
||||
assert result is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. Multi-byte hash + loop detection
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestLoopDetectionMultiByte:
|
||||
"""
|
||||
Loop detection currently counts byte-level matches against local_hash
|
||||
(single int). In multi-byte mode the per-hop hash is >1 byte, so
|
||||
individual bytes in the path may coincidentally match.
|
||||
These tests verify the actual engine behaviour.
|
||||
"""
|
||||
|
||||
def test_2_byte_mode_strict_byte_level_match(self):
|
||||
"""
|
||||
In 2-byte mode with strict, _is_flood_looped scans individual bytes.
|
||||
If local_hash (0xAB) appears as a byte anywhere in the 2-byte path
|
||||
entries, it counts as a match.
|
||||
"""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: 2-byte hop [0xAB, 0x11] — byte 0xAB appears once
|
||||
pkt = _make_flood_packet(b"\xAB\x11", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
# strict threshold=1, 0xAB appears once in raw bytes → loop detected
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_2_byte_mode_off_ignores_byte_match(self):
|
||||
"""With loop_detect=off, even byte-level 0xAB matches are ignored."""
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\xAB\x11", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_2_byte_no_local_hash_byte_passes_strict(self):
|
||||
"""If local_hash byte doesn't appear anywhere in the 2-byte path, strict passes."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0x11, 0x22] — no 0xAB byte
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_3_byte_mode_local_hash_byte_in_path(self):
|
||||
"""In 3-byte mode, the 0xAB byte anywhere triggers strict loop detection."""
|
||||
h = _make_handler(loop_detect="strict",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# 3-byte hop: [0x11, 0xAB, 0x33] — 0xAB in the middle
|
||||
pkt = _make_flood_packet(b"\x11\xAB\x33", hash_size=3, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
|
||||
def test_moderate_multi_byte_counts_all_byte_occurrences(self):
|
||||
"""
|
||||
moderate threshold=2. With 2-byte hops, each byte is counted
|
||||
independently, so two occurrences of 0xAB across different hops
|
||||
triggers the loop.
|
||||
"""
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Two 2-byte hops: [0xAB, 0x11, 0xAB, 0x22] — 0xAB appears twice
|
||||
pkt = _make_flood_packet(b"\xAB\x11\xAB\x22", hash_size=2, hash_count=2)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "loop" in pkt.drop_reason.lower()
|
||||
|
||||
def test_2_byte_flood_forward_appends_correctly(self):
|
||||
"""
|
||||
After flood_forward in 2-byte mode, verify the path contains
|
||||
only the expected bytes (no extra, no corruption).
|
||||
"""
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 2
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes == [b"\x11\x22", b"\xAB\xCD"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. Global flood policy
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestGlobalFloodPolicy:
|
||||
"""Test global_flood_allow=False blocks flood packets."""
|
||||
|
||||
def test_global_flood_disabled_drops_flood(self):
|
||||
h = _make_handler(global_flood_allow=False)
|
||||
pkt = _make_flood_packet(payload=b"\x01\x02")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert pkt.drop_reason is not None
|
||||
|
||||
def test_global_flood_enabled_allows_flood(self):
|
||||
h = _make_handler(global_flood_allow=True)
|
||||
pkt = _make_flood_packet(payload=b"\x01\x02")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
def test_transport_flood_without_codes_drops(self):
|
||||
"""ROUTE_TYPE_TRANSPORT_FLOOD with global_flood_allow=False and no valid codes."""
|
||||
h = _make_handler(global_flood_allow=False)
|
||||
# Nullify the storage to ensure transport code check fails
|
||||
h.storage = None
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_TRANSPORT_FLOOD
|
||||
pkt.path = bytearray()
|
||||
pkt.path_len = PathUtils.encode_path_len(1, 0)
|
||||
pkt.payload = bytearray(b"\x01\x02")
|
||||
pkt.payload_len = 2
|
||||
pkt.transport_codes = [0x1234, 0x5678]
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. do_not_retransmit flag
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDoNotRetransmit:
|
||||
"""Verify packets flagged do_not_retransmit are dropped."""
|
||||
|
||||
def test_flagged_packet_dropped(self):
|
||||
h = _make_handler()
|
||||
pkt = _make_flood_packet(payload=b"\x01\x02")
|
||||
pkt.mark_do_not_retransmit()
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "retransmit" in pkt.drop_reason.lower()
|
||||
|
||||
def test_unflagged_packet_passes(self):
|
||||
h = _make_handler()
|
||||
pkt = _make_flood_packet(payload=b"\x01\x02")
|
||||
assert not pkt.is_marked_do_not_retransmit()
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. validate_packet edge cases
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestValidatePacket:
|
||||
"""Test packet validation rules used by flood_forward."""
|
||||
|
||||
def test_empty_payload_rejected(self):
|
||||
h = _make_handler()
|
||||
pkt = _make_flood_packet(payload=b"")
|
||||
pkt.payload = bytearray()
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "Empty payload" in (pkt.drop_reason or "")
|
||||
|
||||
def test_max_path_size_rejected(self):
|
||||
"""Path at MAX_PATH_SIZE (64 bytes) is rejected before forwarding."""
|
||||
h = _make_handler()
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.path = bytearray(range(64))
|
||||
# Manually set path_len to bypass encode_path_len validation
|
||||
pkt.path_len = PathUtils.encode_path_len(1, 63)
|
||||
pkt.payload = bytearray(b"\x01\x02")
|
||||
pkt.payload_len = 2
|
||||
# validate_packet checks len(path) >= MAX_PATH_SIZE
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
|
||||
def test_none_payload_rejected(self):
|
||||
h = _make_handler()
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.payload = None
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
|
||||
def test_hop_count_63_rejected(self):
|
||||
"""At 63 hops (1-byte mode), appending would overflow 6-bit counter."""
|
||||
h = _make_handler()
|
||||
# 63 bytes of path with hash_count=63
|
||||
pkt = _make_flood_packet(bytes(63), hash_size=1, hash_count=63)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "maximum" in (pkt.drop_reason or "").lower() or \
|
||||
"exceed" in (pkt.drop_reason or "").lower()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 8. Serialization round-trip after dedup/loop decisions
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestSerializationAfterForward:
|
||||
"""
|
||||
Verify packets that survive loop/dedup checks can serialize
|
||||
and deserialize correctly, preserving the appended path.
|
||||
"""
|
||||
|
||||
def test_forwarded_1_byte_round_trips(self):
|
||||
h = _make_handler(loop_detect="moderate",
|
||||
local_hash_bytes=bytes([0x42, 0x00, 0x00]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=1, hash_count=2,
|
||||
payload=b"\xAA\xBB")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_count() == 3
|
||||
assert pkt2.get_path_hashes() == [b"\x11", b"\x22", b"\x42"]
|
||||
assert pkt2.get_payload() == b"\xAA\xBB"
|
||||
|
||||
def test_forwarded_2_byte_round_trips(self):
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAA, 0xBB, 0xCC]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1,
|
||||
payload=b"\xDE\xAD")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 2
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22", b"\xAA\xBB"]
|
||||
|
||||
def test_forwarded_3_byte_round_trips(self):
|
||||
h = _make_handler(loop_detect="off",
|
||||
local_hash_bytes=bytes([0xAA, 0xBB, 0xCC]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33", hash_size=3, hash_count=1,
|
||||
payload=b"\xBE\xEF")
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 3
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22\x33", b"\xAA\xBB\xCC"]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 9. Multi-repeater flood chain with loop detection
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestFloodChainLoopDetection:
|
||||
"""
|
||||
Simulate a flood packet traversing multiple repeaters and verify
|
||||
loop detection works across the chain.
|
||||
"""
|
||||
|
||||
def test_three_repeater_chain_no_loop(self):
|
||||
"""A→B→C with distinct hashes: no loop at any step (strict mode)."""
|
||||
hashes = [
|
||||
bytes([0x11, 0x00, 0x00]),
|
||||
bytes([0x22, 0x00, 0x00]),
|
||||
bytes([0x33, 0x00, 0x00]),
|
||||
]
|
||||
handlers = [_make_handler(loop_detect="strict", local_hash_bytes=h) for h in hashes]
|
||||
|
||||
pkt = _make_flood_packet(payload=b"\xFE\xED")
|
||||
for i, h in enumerate(handlers):
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None, f"repeater {i} unexpectedly dropped packet"
|
||||
# Use different payload to avoid dedup between handlers
|
||||
# (In real life they'd be on different nodes)
|
||||
pkt = _make_flood_packet(
|
||||
path_bytes=bytes(result.path),
|
||||
hash_size=1,
|
||||
hash_count=result.get_path_hash_count(),
|
||||
payload=b"\xFE\xED" + bytes([i + 1]),
|
||||
)
|
||||
|
||||
assert pkt.get_path_hash_count() == 3
|
||||
|
||||
def test_circular_topology_strict_blocks_loop(self):
|
||||
"""
|
||||
A→B→C→A: when the packet returns to A, strict mode detects
|
||||
A's hash (0x11) already in the path.
|
||||
"""
|
||||
hash_a = bytes([0x11, 0x00, 0x00])
|
||||
hash_b = bytes([0x22, 0x00, 0x00])
|
||||
hash_c = bytes([0x33, 0x00, 0x00])
|
||||
|
||||
h_a = _make_handler(loop_detect="strict", local_hash_bytes=hash_a)
|
||||
h_b = _make_handler(loop_detect="strict", local_hash_bytes=hash_b)
|
||||
h_c = _make_handler(loop_detect="strict", local_hash_bytes=hash_c)
|
||||
|
||||
# A originates
|
||||
pkt = _make_flood_packet(payload=b"\x01\x02\x03")
|
||||
pkt = h_a.flood_forward(pkt)
|
||||
assert pkt is not None # path: [0x11]
|
||||
|
||||
# B forwards (new payload to avoid dedup)
|
||||
pkt_b = _make_flood_packet(
|
||||
bytes(pkt.path), hash_size=1,
|
||||
hash_count=pkt.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04",
|
||||
)
|
||||
pkt_b = h_b.flood_forward(pkt_b)
|
||||
assert pkt_b is not None # path: [0x11, 0x22]
|
||||
|
||||
# C forwards
|
||||
pkt_c = _make_flood_packet(
|
||||
bytes(pkt_b.path), hash_size=1,
|
||||
hash_count=pkt_b.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04\x05",
|
||||
)
|
||||
pkt_c = h_c.flood_forward(pkt_c)
|
||||
assert pkt_c is not None # path: [0x11, 0x22, 0x33]
|
||||
|
||||
# Back to A — 0x11 is already in path → strict blocks it
|
||||
pkt_a2 = _make_flood_packet(
|
||||
bytes(pkt_c.path), hash_size=1,
|
||||
hash_count=pkt_c.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04\x05\x06",
|
||||
)
|
||||
result = h_a.flood_forward(pkt_a2)
|
||||
assert result is None
|
||||
assert "loop" in pkt_a2.drop_reason.lower()
|
||||
|
||||
def test_circular_topology_off_allows_loop_but_dedup_catches(self):
|
||||
"""
|
||||
With loop_detect=off and the exact same payload, duplicate
|
||||
suppression catches the re-visit even without loop detection.
|
||||
"""
|
||||
h = _make_handler(loop_detect="off", local_hash_bytes=bytes([0x11, 0x00, 0x00]))
|
||||
|
||||
pkt = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
assert h.flood_forward(pkt) is not None
|
||||
|
||||
# Same payload comes back
|
||||
pkt2 = _make_flood_packet(payload=b"\xAA\xBB")
|
||||
result = h.flood_forward(pkt2)
|
||||
assert result is None
|
||||
assert pkt2.drop_reason == "Duplicate"
|
||||
|
||||
def test_two_byte_chain_loop_detected(self):
|
||||
"""2-byte mode: circular path A→B→A detected via byte-level scan."""
|
||||
hash_a = bytes([0xAA, 0xBB, 0x00])
|
||||
hash_b = bytes([0xCC, 0xDD, 0x00])
|
||||
|
||||
h_a = _make_handler(loop_detect="strict", local_hash_bytes=hash_a)
|
||||
h_b = _make_handler(loop_detect="strict", local_hash_bytes=hash_b)
|
||||
|
||||
# A forwards (2-byte mode)
|
||||
pkt = _make_flood_packet(b"", hash_size=2, hash_count=0, payload=b"\x01\x02")
|
||||
pkt = h_a.flood_forward(pkt)
|
||||
assert pkt is not None # path: [0xAA, 0xBB]
|
||||
|
||||
# B forwards
|
||||
pkt_b = _make_flood_packet(
|
||||
bytes(pkt.path), hash_size=2,
|
||||
hash_count=pkt.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03",
|
||||
)
|
||||
pkt_b = h_b.flood_forward(pkt_b)
|
||||
assert pkt_b is not None # path: [0xAA, 0xBB, 0xCC, 0xDD]
|
||||
|
||||
# Back to A — byte 0xAA is in path → strict detects it
|
||||
pkt_a2 = _make_flood_packet(
|
||||
bytes(pkt_b.path), hash_size=2,
|
||||
hash_count=pkt_b.get_path_hash_count(),
|
||||
payload=b"\x01\x02\x03\x04",
|
||||
)
|
||||
result = h_a.flood_forward(pkt_a2)
|
||||
assert result is None
|
||||
assert "loop" in pkt_a2.drop_reason.lower()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 10. Normalize loop_detect_mode edge cases
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestNormalizeLoopDetectMode:
|
||||
"""Verify _normalize_loop_detect_mode handles edge cases."""
|
||||
|
||||
def test_uppercase_normalized(self):
|
||||
h = _make_handler(loop_detect="STRICT")
|
||||
assert h.loop_detect_mode == "strict"
|
||||
|
||||
def test_whitespace_stripped(self):
|
||||
h = _make_handler(loop_detect=" moderate ")
|
||||
assert h.loop_detect_mode == "moderate"
|
||||
|
||||
def test_invalid_defaults_to_off(self):
|
||||
h = _make_handler(loop_detect="invalid_value")
|
||||
assert h.loop_detect_mode == "off"
|
||||
|
||||
def test_numeric_defaults_to_off(self):
|
||||
h = _make_handler(loop_detect=42)
|
||||
assert h.loop_detect_mode == "off"
|
||||
|
||||
def test_none_defaults_to_off(self):
|
||||
h = _make_handler(loop_detect=None)
|
||||
assert h.loop_detect_mode == "off"
|
||||
780
tests/test_path_hash_protocol.py
Normal file
780
tests/test_path_hash_protocol.py
Normal file
@@ -0,0 +1,780 @@
|
||||
"""
|
||||
Integration tests for multi-byte path hash support using real pymc_core protocol objects.
|
||||
|
||||
Exercises actual Packet, PathUtils, PacketBuilder, and engine forwarding
|
||||
rather than mocking the protocol layer. Covers:
|
||||
- PathUtils encode/decode round-trips for all hash sizes
|
||||
- Packet serialization/deserialization with multi-byte paths
|
||||
- Packet.apply_path_hash_mode and get_path_hashes
|
||||
- Engine flood_forward with real multi-byte encoded packets
|
||||
- Engine direct_forward with real multi-byte encoded packets
|
||||
- PacketBuilder.create_trace payload structure + TraceHandler parsing
|
||||
- Max-hop boundary enforcement per hash size
|
||||
"""
|
||||
import struct
|
||||
from collections import OrderedDict
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pymc_core.protocol import Packet, PacketBuilder, PathUtils
|
||||
from pymc_core.protocol.constants import (
|
||||
MAX_PATH_SIZE,
|
||||
PATH_HASH_COUNT_MASK,
|
||||
PATH_HASH_SIZE_SHIFT,
|
||||
PAYLOAD_TYPE_TRACE,
|
||||
PH_TYPE_SHIFT,
|
||||
ROUTE_TYPE_DIRECT,
|
||||
ROUTE_TYPE_FLOOD,
|
||||
)
|
||||
from pymc_core.node.handlers.trace import TraceHandler
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
LOCAL_HASH_BYTES = bytes([0xAB, 0xCD, 0xEF])
|
||||
|
||||
|
||||
def _make_flood_packet(path_bytes: bytes, hash_size: int, hash_count: int,
|
||||
payload: bytes = b"\x01\x02\x03\x04") -> Packet:
|
||||
"""Create a real flood Packet with the given multi-byte path encoding."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.path = bytearray(path_bytes)
|
||||
pkt.path_len = PathUtils.encode_path_len(hash_size, hash_count)
|
||||
pkt.payload = bytearray(payload)
|
||||
pkt.payload_len = len(payload)
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_direct_packet(path_bytes: bytes, hash_size: int, hash_count: int,
|
||||
payload: bytes = b"\x01\x02\x03\x04") -> Packet:
|
||||
"""Create a real direct-routed Packet."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_DIRECT
|
||||
pkt.path = bytearray(path_bytes)
|
||||
pkt.path_len = PathUtils.encode_path_len(hash_size, hash_count)
|
||||
pkt.payload = bytearray(payload)
|
||||
pkt.payload_len = len(payload)
|
||||
return pkt
|
||||
|
||||
|
||||
def _make_handler(path_hash_mode=0, local_hash_bytes=None):
|
||||
"""Create a real RepeaterHandler with minimal mocking (only radio/storage)."""
|
||||
lhb = local_hash_bytes or LOCAL_HASH_BYTES
|
||||
config = {
|
||||
"repeater": {
|
||||
"mode": "forward",
|
||||
"cache_ttl": 3600,
|
||||
"use_score_for_tx": False,
|
||||
"score_threshold": 0.3,
|
||||
"send_advert_interval_hours": 0,
|
||||
"node_name": "test-node",
|
||||
},
|
||||
"mesh": {
|
||||
"global_flood_allow": True,
|
||||
"loop_detect": "off",
|
||||
"path_hash_mode": path_hash_mode,
|
||||
},
|
||||
"delays": {"tx_delay_factor": 1.0, "direct_tx_delay_factor": 0.5},
|
||||
"duty_cycle": {"max_airtime_per_minute": 3600, "enforcement_enabled": True},
|
||||
"radio": {
|
||||
"spreading_factor": 8,
|
||||
"bandwidth": 125000,
|
||||
"coding_rate": 8,
|
||||
"preamble_length": 17,
|
||||
},
|
||||
}
|
||||
dispatcher = MagicMock()
|
||||
dispatcher.radio = MagicMock(
|
||||
spreading_factor=8, bandwidth=125000, coding_rate=8,
|
||||
preamble_length=17, frequency=915000000, tx_power=14,
|
||||
)
|
||||
dispatcher.local_identity = MagicMock()
|
||||
with (
|
||||
patch("repeater.engine.StorageCollector"),
|
||||
patch("repeater.engine.RepeaterHandler._start_background_tasks"),
|
||||
):
|
||||
from repeater.engine import RepeaterHandler
|
||||
h = RepeaterHandler(config, dispatcher, lhb[0], local_hash_bytes=lhb)
|
||||
return h
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 1. PathUtils — encode/decode round-trips
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestPathUtilsRoundTrip:
|
||||
"""Verify PathUtils encode/decode for all valid hash sizes and hop counts."""
|
||||
|
||||
@pytest.mark.parametrize("hash_size", [1, 2, 3])
|
||||
def test_encode_decode_hash_size(self, hash_size):
|
||||
encoded = PathUtils.encode_path_len(hash_size, 0)
|
||||
assert PathUtils.get_path_hash_size(encoded) == hash_size
|
||||
assert PathUtils.get_path_hash_count(encoded) == 0
|
||||
|
||||
@pytest.mark.parametrize("hash_size,count", [
|
||||
(1, 1), (1, 10), (1, 63),
|
||||
(2, 1), (2, 15), (2, 32),
|
||||
(3, 1), (3, 10), (3, 21),
|
||||
])
|
||||
def test_encode_decode_round_trip(self, hash_size, count):
|
||||
encoded = PathUtils.encode_path_len(hash_size, count)
|
||||
assert PathUtils.get_path_hash_size(encoded) == hash_size
|
||||
assert PathUtils.get_path_hash_count(encoded) == count
|
||||
assert PathUtils.get_path_byte_len(encoded) == hash_size * count
|
||||
|
||||
@pytest.mark.parametrize("hash_size", [1, 2, 3])
|
||||
def test_encode_zero_hops(self, hash_size):
|
||||
encoded = PathUtils.encode_path_len(hash_size, 0)
|
||||
assert PathUtils.get_path_byte_len(encoded) == 0
|
||||
assert PathUtils.is_valid_path_len(encoded)
|
||||
|
||||
def test_encode_preserves_bit_layout(self):
|
||||
"""Verify the actual bit layout: bits 6-7 = (hash_size-1), bits 0-5 = count."""
|
||||
for hs in (1, 2, 3):
|
||||
for count in (0, 1, 30, 63):
|
||||
if count * hs > MAX_PATH_SIZE:
|
||||
continue
|
||||
encoded = PathUtils.encode_path_len(hs, count)
|
||||
assert (encoded >> PATH_HASH_SIZE_SHIFT) == hs - 1
|
||||
assert (encoded & PATH_HASH_COUNT_MASK) == count
|
||||
|
||||
def test_1_byte_backward_compatible(self):
|
||||
"""For hash_size=1, encoded byte == raw hop count (legacy compat)."""
|
||||
for count in range(64):
|
||||
encoded = PathUtils.encode_path_len(1, count)
|
||||
assert encoded == count
|
||||
|
||||
def test_encode_hop_count_overflow_raises(self):
|
||||
with pytest.raises(ValueError, match="hop count must be 0-63"):
|
||||
PathUtils.encode_path_len(1, 64)
|
||||
|
||||
@pytest.mark.parametrize("hash_size,max_hops", [(1, 63), (2, 32), (3, 21)])
|
||||
def test_max_hops_boundary(self, hash_size, max_hops):
|
||||
at_max = PathUtils.encode_path_len(hash_size, max_hops)
|
||||
assert PathUtils.is_path_at_max_hops(at_max)
|
||||
assert PathUtils.is_valid_path_len(at_max)
|
||||
|
||||
@pytest.mark.parametrize("hash_size,below_max", [(1, 62), (2, 31), (3, 20)])
|
||||
def test_below_max_hops_not_at_max(self, hash_size, below_max):
|
||||
encoded = PathUtils.encode_path_len(hash_size, below_max)
|
||||
assert not PathUtils.is_path_at_max_hops(encoded)
|
||||
|
||||
def test_zero_path_len_not_at_max(self):
|
||||
assert not PathUtils.is_path_at_max_hops(0)
|
||||
|
||||
def test_invalid_path_len_too_many_bytes(self):
|
||||
"""33 hops of 2 bytes = 66, exceeds MAX_PATH_SIZE=64."""
|
||||
encoded = PathUtils.encode_path_len(2, 33)
|
||||
assert not PathUtils.is_valid_path_len(encoded)
|
||||
|
||||
def test_hash_size_4_reserved_invalid(self):
|
||||
"""hash_size=4 (bits 6-7 = 0b11) is reserved and invalid."""
|
||||
# Manually construct: (3 << 6) | 1 = 0xC1
|
||||
raw = (3 << PATH_HASH_SIZE_SHIFT) | 1
|
||||
assert not PathUtils.is_valid_path_len(raw)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 2. Packet — multi-byte path serialization round-trip
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestPacketMultiBytePath:
|
||||
"""Verify Packet write_to/read_from preserves multi-byte path encoding."""
|
||||
|
||||
def test_1_byte_path_round_trip(self):
|
||||
pkt = _make_flood_packet(b"\xAA\xBB\xCC", hash_size=1, hash_count=3)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 1
|
||||
assert pkt2.get_path_hash_count() == 3
|
||||
assert bytes(pkt2.path) == b"\xAA\xBB\xCC"
|
||||
|
||||
def test_2_byte_path_round_trip(self):
|
||||
path = b"\xAA\xBB\xCC\xDD" # 2 hops of 2 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=2, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 2
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert bytes(pkt2.path) == path
|
||||
|
||||
def test_3_byte_path_round_trip(self):
|
||||
path = b"\xAA\xBB\xCC\xDD\xEE\xFF" # 2 hops of 3 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=3, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 3
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert bytes(pkt2.path) == path
|
||||
|
||||
def test_empty_path_2_byte_mode(self):
|
||||
pkt = _make_flood_packet(b"", hash_size=2, hash_count=0)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 2
|
||||
assert pkt2.get_path_hash_count() == 0
|
||||
assert bytes(pkt2.path) == b""
|
||||
|
||||
def test_payload_preserved_after_multibyte_path(self):
|
||||
"""Payload bytes after a multi-byte path are correctly sliced."""
|
||||
payload = b"\xDE\xAD\xBE\xEF"
|
||||
path = b"\x11\x22\x33\x44\x55\x66"
|
||||
pkt = _make_flood_packet(path, hash_size=3, hash_count=2, payload=payload)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_payload() == payload
|
||||
|
||||
def test_path_len_byte_on_wire(self):
|
||||
"""The encoded path_len byte on the wire has the correct bit pattern."""
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33\x44", hash_size=2, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
# Wire: header(1) + path_len(1) + path(4) + payload(4)
|
||||
# For ROUTE_TYPE_FLOOD (no transport codes), path_len is at index 1
|
||||
path_len_on_wire = wire[1]
|
||||
assert PathUtils.get_path_hash_size(path_len_on_wire) == 2
|
||||
assert PathUtils.get_path_hash_count(path_len_on_wire) == 2
|
||||
|
||||
|
||||
class TestPacketGetPathHashes:
|
||||
"""Verify Packet.get_path_hashes splits path into per-hop byte entries."""
|
||||
|
||||
def test_1_byte_hashes(self):
|
||||
pkt = _make_flood_packet(b"\xAA\xBB\xCC", hash_size=1, hash_count=3)
|
||||
hashes = pkt.get_path_hashes()
|
||||
assert hashes == [b"\xAA", b"\xBB", b"\xCC"]
|
||||
|
||||
def test_2_byte_hashes(self):
|
||||
pkt = _make_flood_packet(b"\xAA\xBB\xCC\xDD", hash_size=2, hash_count=2)
|
||||
hashes = pkt.get_path_hashes()
|
||||
assert hashes == [b"\xAA\xBB", b"\xCC\xDD"]
|
||||
|
||||
def test_3_byte_hashes(self):
|
||||
pkt = _make_flood_packet(
|
||||
b"\xAA\xBB\xCC\xDD\xEE\xFF", hash_size=3, hash_count=2
|
||||
)
|
||||
hashes = pkt.get_path_hashes()
|
||||
assert hashes == [b"\xAA\xBB\xCC", b"\xDD\xEE\xFF"]
|
||||
|
||||
def test_empty_path(self):
|
||||
pkt = _make_flood_packet(b"", hash_size=2, hash_count=0)
|
||||
assert pkt.get_path_hashes() == []
|
||||
|
||||
def test_hashes_hex_output(self):
|
||||
pkt = _make_flood_packet(b"\x0A\x0B\x0C\x0D", hash_size=2, hash_count=2)
|
||||
hex_hashes = pkt.get_path_hashes_hex()
|
||||
assert hex_hashes == ["0A0B", "0C0D"]
|
||||
|
||||
|
||||
class TestPacketApplyPathHashMode:
|
||||
"""Verify Packet.apply_path_hash_mode sets encoding for 0-hop packets."""
|
||||
|
||||
@pytest.mark.parametrize("mode,expected_hash_size", [(0, 1), (1, 2), (2, 3)])
|
||||
def test_apply_mode_sets_hash_size(self, mode, expected_hash_size):
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.payload = bytearray(b"\x01")
|
||||
pkt.payload_len = 1
|
||||
pkt.apply_path_hash_mode(mode)
|
||||
assert pkt.get_path_hash_size() == expected_hash_size
|
||||
assert pkt.get_path_hash_count() == 0
|
||||
|
||||
def test_apply_mode_skips_nonzero_hop_count(self):
|
||||
"""Mode should not be re-applied if path already has hops."""
|
||||
pkt = _make_flood_packet(b"\xAA\xBB", hash_size=2, hash_count=1)
|
||||
original_path_len = pkt.path_len
|
||||
pkt.apply_path_hash_mode(0) # try to override to 1-byte
|
||||
assert pkt.path_len == original_path_len # unchanged
|
||||
|
||||
def test_apply_mode_skips_trace_packets(self):
|
||||
"""Trace packets should never have path_hash_mode applied."""
|
||||
pkt = PacketBuilder.create_trace(tag=1, auth_code=2, flags=0)
|
||||
pkt.apply_path_hash_mode(2)
|
||||
# Trace packet path_len stays 0 (no routing path)
|
||||
assert pkt.path_len == 0
|
||||
|
||||
def test_apply_invalid_mode_raises(self):
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
with pytest.raises(ValueError, match="path_hash_mode must be 0, 1, or 2"):
|
||||
pkt.apply_path_hash_mode(3)
|
||||
|
||||
|
||||
class TestPacketSetPath:
|
||||
"""Verify Packet.set_path with explicit path_len_encoded."""
|
||||
|
||||
def test_set_path_with_encoded_len(self):
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
path = b"\xAA\xBB\xCC\xDD"
|
||||
encoded = PathUtils.encode_path_len(2, 2)
|
||||
pkt.set_path(path, path_len_encoded=encoded)
|
||||
assert pkt.get_path_hash_size() == 2
|
||||
assert pkt.get_path_hash_count() == 2
|
||||
assert bytes(pkt.path) == path
|
||||
|
||||
def test_set_path_without_encoded_defaults_1_byte(self):
|
||||
"""Without explicit path_len_encoded, defaults to 1-byte hash_size."""
|
||||
pkt = Packet()
|
||||
pkt.header = ROUTE_TYPE_FLOOD
|
||||
pkt.set_path(b"\xAA\xBB\xCC")
|
||||
assert pkt.get_path_hash_size() == 1
|
||||
assert pkt.get_path_hash_count() == 3
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 3. Engine flood_forward — real Packet objects
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestFloodForwardMultiByte:
|
||||
"""Test RepeaterHandler.flood_forward with real multi-byte Packet objects."""
|
||||
|
||||
def test_1_byte_mode_appends_single_byte(self):
|
||||
h = _make_handler(path_hash_mode=0, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11", hash_size=1, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 2
|
||||
assert result.get_path_hash_size() == 1
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\x11"
|
||||
assert hashes[1] == b"\xAB" # first byte of local_hash_bytes
|
||||
|
||||
def test_2_byte_mode_appends_two_bytes(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 2
|
||||
assert result.get_path_hash_size() == 2
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\x11\x22"
|
||||
assert hashes[1] == b"\xAB\xCD"
|
||||
|
||||
def test_3_byte_mode_appends_three_bytes(self):
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33", hash_size=3, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 2
|
||||
assert result.get_path_hash_size() == 3
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\x11\x22\x33"
|
||||
assert hashes[1] == b"\xAB\xCD\xEF"
|
||||
|
||||
def test_empty_path_gets_local_hash_appended(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"", hash_size=2, hash_count=0)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
hashes = result.get_path_hashes()
|
||||
assert hashes[0] == b"\xAB\xCD"
|
||||
|
||||
def test_path_len_re_encoded_after_forward(self):
|
||||
"""After appending, path_len byte should encode (hash_size, count+1)."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22\x33\x44", hash_size=2, hash_count=2)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
expected_path_len = PathUtils.encode_path_len(2, 3)
|
||||
assert result.path_len == expected_path_len
|
||||
|
||||
def test_forwarded_packet_serializes_correctly(self):
|
||||
"""The forwarded packet should serialize and deserialize cleanly."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_flood_packet(b"\x11\x22", hash_size=2, hash_count=1)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 2
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22", b"\xAB\xCD"]
|
||||
|
||||
def test_flood_rejects_at_max_hops_2_byte(self):
|
||||
"""At 32 hops (2-byte mode), flood_forward should drop the packet."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
path = bytes([0x00, 0x01] * 32) # 32 hops × 2 bytes = 64 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=2, hash_count=32)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert pkt.drop_reason is not None
|
||||
|
||||
def test_flood_rejects_at_max_hops_3_byte(self):
|
||||
"""At 21 hops (3-byte mode), adding one more would exceed MAX_PATH_SIZE."""
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
path = bytes([0x00, 0x01, 0x02] * 21) # 21 hops × 3 bytes = 63 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=3, hash_count=21)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert pkt.drop_reason is not None
|
||||
|
||||
def test_flood_allows_below_max_2_byte(self):
|
||||
"""At 31 hops (2-byte), one more should succeed."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
path = bytes(range(62)) # 31 hops × 2 bytes
|
||||
pkt = _make_flood_packet(path, hash_size=2, hash_count=31)
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 32
|
||||
|
||||
def test_flood_rejects_empty_payload(self):
|
||||
h = _make_handler(path_hash_mode=0)
|
||||
pkt = _make_flood_packet(b"", hash_size=1, hash_count=0, payload=b"")
|
||||
pkt.payload = bytearray()
|
||||
result = h.flood_forward(pkt)
|
||||
assert result is None
|
||||
assert "Empty payload" in (pkt.drop_reason or "")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 4. Engine direct_forward — real Packet objects
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestDirectForwardMultiByte:
|
||||
"""Test RepeaterHandler.direct_forward with real multi-byte Packet objects."""
|
||||
|
||||
def test_1_byte_match_strips_first_hop(self):
|
||||
h = _make_handler(path_hash_mode=0, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0xAB, 0x11] — first hop matches local_hash_bytes[0]
|
||||
pkt = _make_direct_packet(b"\xAB\x11", hash_size=1, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
assert result.get_path_hash_size() == 1
|
||||
assert result.get_path_hashes() == [b"\x11"]
|
||||
|
||||
def test_2_byte_match_strips_first_hop(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0xAB,0xCD, 0x11,0x22] — first 2-byte hop matches local_hash_bytes[:2]
|
||||
pkt = _make_direct_packet(b"\xAB\xCD\x11\x22", hash_size=2, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
assert result.get_path_hash_size() == 2
|
||||
assert result.get_path_hashes() == [b"\x11\x22"]
|
||||
|
||||
def test_3_byte_match_strips_first_hop(self):
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(
|
||||
b"\xAB\xCD\xEF\x11\x22\x33", hash_size=3, hash_count=2
|
||||
)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
assert result.get_path_hash_size() == 3
|
||||
assert result.get_path_hashes() == [b"\x11\x22\x33"]
|
||||
|
||||
def test_2_byte_mismatch_rejects(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
# Path: [0xFF,0xEE, ...] — first 2-byte hop doesn't match
|
||||
pkt = _make_direct_packet(b"\xFF\xEE\x11\x22", hash_size=2, hash_count=2)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is None
|
||||
assert "not for us" in (pkt.drop_reason or "")
|
||||
|
||||
def test_path_len_re_encoded_after_strip(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"\xAB\xCD\x11\x22\x33\x44", hash_size=2, hash_count=3)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
expected_path_len = PathUtils.encode_path_len(2, 2)
|
||||
assert result.path_len == expected_path_len
|
||||
|
||||
def test_last_hop_strips_to_empty(self):
|
||||
"""When only one hop remains and it matches, path becomes empty."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"\xAB\xCD", hash_size=2, hash_count=1)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 0
|
||||
assert bytes(result.path) == b""
|
||||
|
||||
def test_forwarded_direct_serializes_correctly(self):
|
||||
"""After stripping, the packet should serialize/deserialize cleanly."""
|
||||
h = _make_handler(path_hash_mode=2, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(
|
||||
b"\xAB\xCD\xEF\x11\x22\x33\x44\x55\x66", hash_size=3, hash_count=3
|
||||
)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is not None
|
||||
wire = result.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_path_hash_size() == 3
|
||||
assert pkt2.get_path_hash_count() == 2
|
||||
assert pkt2.get_path_hashes() == [b"\x11\x22\x33", b"\x44\x55\x66"]
|
||||
|
||||
def test_no_path_rejects(self):
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"", hash_size=2, hash_count=0)
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is None
|
||||
assert "no path" in (pkt.drop_reason or "").lower()
|
||||
|
||||
def test_path_too_short_for_hash_size(self):
|
||||
"""If path has fewer bytes than hash_size, reject."""
|
||||
h = _make_handler(path_hash_mode=1, local_hash_bytes=bytes([0xAB, 0xCD, 0xEF]))
|
||||
pkt = _make_direct_packet(b"\xAB", hash_size=2, hash_count=1)
|
||||
# path has 1 byte but hash_size is 2
|
||||
result = h.direct_forward(pkt)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 5. Flood → Direct — multi-hop forwarding chain
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestMultiHopForwardingChain:
|
||||
"""Simulate a multi-hop path being built and then consumed."""
|
||||
|
||||
def test_flood_chain_builds_path_then_direct_consumes(self):
|
||||
"""
|
||||
Simulate: node_A floods → repeater_1 forwards → repeater_2 forwards
|
||||
Then the return direct packet strips hops in reverse order.
|
||||
"""
|
||||
node_a_hash = bytes([0x11, 0x22, 0x33])
|
||||
rep1_hash = bytes([0xAA, 0xBB, 0xCC])
|
||||
rep2_hash = bytes([0xDD, 0xEE, 0xFF])
|
||||
|
||||
# Step 1: node_A creates a flood packet with 0 hops, 2-byte mode
|
||||
pkt = _make_flood_packet(b"", hash_size=2, hash_count=0)
|
||||
|
||||
# Step 2: repeater_1 flood_forward adds its 2-byte hash
|
||||
h1 = _make_handler(path_hash_mode=1, local_hash_bytes=rep1_hash)
|
||||
pkt = h1.flood_forward(pkt)
|
||||
assert pkt is not None
|
||||
assert pkt.get_path_hashes() == [rep1_hash[:2]]
|
||||
|
||||
# Step 3: repeater_2 flood_forward adds its 2-byte hash
|
||||
h2 = _make_handler(path_hash_mode=1, local_hash_bytes=rep2_hash)
|
||||
pkt = h2.flood_forward(pkt)
|
||||
assert pkt is not None
|
||||
assert pkt.get_path_hashes() == [rep1_hash[:2], rep2_hash[:2]]
|
||||
|
||||
# Verify the path serializes correctly
|
||||
wire = pkt.write_to()
|
||||
pkt_rx = Packet()
|
||||
pkt_rx.read_from(wire)
|
||||
assert pkt_rx.get_path_hash_size() == 2
|
||||
assert pkt_rx.get_path_hash_count() == 2
|
||||
|
||||
# Step 4: Now simulate a direct reply going back through the path
|
||||
# The path should be [rep1, rep2] — direct packet addressed to rep1 first
|
||||
# (Direct packets strip from the front)
|
||||
direct_pkt = _make_direct_packet(
|
||||
bytes(pkt_rx.path), hash_size=2, hash_count=2,
|
||||
payload=b"\xFE\xED"
|
||||
)
|
||||
|
||||
# repeater_1 strips its hop
|
||||
result = h1.direct_forward(direct_pkt)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 1
|
||||
assert result.get_path_hashes() == [rep2_hash[:2]]
|
||||
|
||||
# repeater_2 strips its hop
|
||||
result = h2.direct_forward(result)
|
||||
assert result is not None
|
||||
assert result.get_path_hash_count() == 0
|
||||
assert bytes(result.path) == b""
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 6. PacketBuilder.create_trace — payload structure
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestTracePacketStructure:
|
||||
"""Verify real trace packet creation and payload structure."""
|
||||
|
||||
def test_create_trace_basic(self):
|
||||
pkt = PacketBuilder.create_trace(tag=0x12345678, auth_code=0xDEADBEEF, flags=0x01)
|
||||
assert pkt.get_payload_type() == PAYLOAD_TYPE_TRACE
|
||||
assert pkt.path_len == 0
|
||||
assert len(pkt.path) == 0
|
||||
payload = pkt.get_payload()
|
||||
assert len(payload) == 9
|
||||
tag, auth_code, flags = struct.unpack("<IIB", payload[:9])
|
||||
assert tag == 0x12345678
|
||||
assert auth_code == 0xDEADBEEF
|
||||
assert flags == 0x01
|
||||
|
||||
def test_create_trace_with_path_bytes(self):
|
||||
"""Trace path goes into payload, not routing path."""
|
||||
path_bytes = [0xAA, 0xBB, 0xCC, 0xDD]
|
||||
pkt = PacketBuilder.create_trace(
|
||||
tag=1, auth_code=2, flags=0, path=path_bytes
|
||||
)
|
||||
payload = pkt.get_payload()
|
||||
assert len(payload) == 9 + 4
|
||||
# Routing path stays empty
|
||||
assert pkt.path_len == 0
|
||||
assert len(pkt.path) == 0
|
||||
# Path bytes are in the payload after the 9-byte header
|
||||
assert list(payload[9:]) == path_bytes
|
||||
|
||||
def test_trace_is_direct_route(self):
|
||||
pkt = PacketBuilder.create_trace(tag=0, auth_code=0, flags=0)
|
||||
assert pkt.is_route_direct()
|
||||
|
||||
def test_trace_apply_path_hash_mode_is_noop(self):
|
||||
"""apply_path_hash_mode should not alter trace packets."""
|
||||
pkt = PacketBuilder.create_trace(tag=0, auth_code=0, flags=0)
|
||||
pkt.apply_path_hash_mode(2)
|
||||
assert pkt.path_len == 0 # unchanged
|
||||
|
||||
def test_trace_serialization_round_trip(self):
|
||||
path_bytes = [0x11, 0x22, 0x33]
|
||||
pkt = PacketBuilder.create_trace(tag=42, auth_code=99, flags=3, path=path_bytes)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
assert pkt2.get_payload_type() == PAYLOAD_TYPE_TRACE
|
||||
payload = pkt2.get_payload()
|
||||
tag, auth_code, flags = struct.unpack("<IIB", payload[:9])
|
||||
assert tag == 42
|
||||
assert auth_code == 99
|
||||
assert flags == 3
|
||||
assert list(payload[9:]) == path_bytes
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 7. TraceHandler._parse_trace_payload — real parsing
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestTracePayloadParsing:
|
||||
"""Verify TraceHandler._parse_trace_payload with real trace payloads."""
|
||||
|
||||
def _make_trace_handler(self):
|
||||
handler = object.__new__(TraceHandler)
|
||||
return handler
|
||||
|
||||
def test_parse_basic_trace(self):
|
||||
th = self._make_trace_handler()
|
||||
payload = struct.pack("<IIB", 0x12345678, 0xAABBCCDD, 0x05)
|
||||
result = th._parse_trace_payload(payload)
|
||||
assert result["valid"]
|
||||
assert result["tag"] == 0x12345678
|
||||
assert result["auth_code"] == 0xAABBCCDD
|
||||
assert result["flags"] == 0x05
|
||||
assert result["trace_path"] == []
|
||||
|
||||
def test_parse_trace_with_1_byte_path(self):
|
||||
th = self._make_trace_handler()
|
||||
payload = struct.pack("<IIB", 1, 2, 0) + bytes([0xAA, 0xBB, 0xCC])
|
||||
result = th._parse_trace_payload(payload)
|
||||
assert result["valid"]
|
||||
assert result["trace_path"] == [0xAA, 0xBB, 0xCC]
|
||||
assert result["path_length"] == 3
|
||||
|
||||
def test_parse_trace_with_multibyte_path_is_flat(self):
|
||||
"""
|
||||
Trace path is raw bytes in payload — _parse_trace_payload returns it flat.
|
||||
Multi-byte grouping is NOT done at the trace parser level.
|
||||
"""
|
||||
th = self._make_trace_handler()
|
||||
# 2 hops of 2-byte hashes → 4 flat bytes in the payload
|
||||
path = bytes([0xAA, 0xBB, 0xCC, 0xDD])
|
||||
payload = struct.pack("<IIB", 10, 20, 0) + path
|
||||
result = th._parse_trace_payload(payload)
|
||||
assert result["valid"]
|
||||
# Returns flat list, not grouped
|
||||
assert result["trace_path"] == [0xAA, 0xBB, 0xCC, 0xDD]
|
||||
assert result["path_length"] == 4
|
||||
|
||||
def test_parse_from_real_packet(self):
|
||||
"""Create a trace with PacketBuilder, serialize, deserialize, then parse."""
|
||||
th = self._make_trace_handler()
|
||||
trace_path = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66]
|
||||
pkt = PacketBuilder.create_trace(
|
||||
tag=100, auth_code=200, flags=7, path=trace_path
|
||||
)
|
||||
wire = pkt.write_to()
|
||||
pkt2 = Packet()
|
||||
pkt2.read_from(wire)
|
||||
result = th._parse_trace_payload(pkt2.get_payload())
|
||||
assert result["valid"]
|
||||
assert result["tag"] == 100
|
||||
assert result["auth_code"] == 200
|
||||
assert result["flags"] == 7
|
||||
assert result["trace_path"] == trace_path
|
||||
|
||||
def test_parse_too_short_payload(self):
|
||||
th = self._make_trace_handler()
|
||||
result = th._parse_trace_payload(b"\x01\x02\x03")
|
||||
assert "error" in result
|
||||
assert "too short" in result["error"].lower()
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# 8. Wire-level verification — manual byte inspection
|
||||
# ===================================================================
|
||||
|
||||
|
||||
class TestWireLevelEncoding:
|
||||
"""Verify exact byte layout of serialized multi-byte path packets."""
|
||||
|
||||
def test_2_byte_mode_wire_format(self):
|
||||
"""
|
||||
ROUTE_TYPE_FLOOD (no transport codes):
|
||||
[header(1)] [path_len(1)] [path(N)] [payload(M)]
|
||||
"""
|
||||
pkt = _make_flood_packet(
|
||||
b"\xAA\xBB\xCC\xDD", hash_size=2, hash_count=2,
|
||||
payload=b"\xFE"
|
||||
)
|
||||
wire = pkt.write_to()
|
||||
assert wire[0] == ROUTE_TYPE_FLOOD # header
|
||||
path_len = wire[1]
|
||||
assert PathUtils.get_path_hash_size(path_len) == 2
|
||||
assert PathUtils.get_path_hash_count(path_len) == 2
|
||||
assert wire[2:6] == b"\xAA\xBB\xCC\xDD" # path bytes
|
||||
assert wire[6:] == b"\xFE" # payload
|
||||
|
||||
def test_3_byte_mode_wire_format(self):
|
||||
pkt = _make_flood_packet(
|
||||
b"\x11\x22\x33\x44\x55\x66", hash_size=3, hash_count=2,
|
||||
payload=b"\xAA"
|
||||
)
|
||||
wire = pkt.write_to()
|
||||
assert wire[0] == ROUTE_TYPE_FLOOD
|
||||
path_len = wire[1]
|
||||
assert PathUtils.get_path_hash_size(path_len) == 3
|
||||
assert PathUtils.get_path_hash_count(path_len) == 2
|
||||
assert wire[2:8] == b"\x11\x22\x33\x44\x55\x66"
|
||||
assert wire[8:] == b"\xAA"
|
||||
|
||||
def test_1_byte_mode_backward_compat_wire(self):
|
||||
"""1-byte mode: path_len byte on wire == hop count (legacy format)."""
|
||||
pkt = _make_flood_packet(b"\xAA\xBB", hash_size=1, hash_count=2)
|
||||
wire = pkt.write_to()
|
||||
assert wire[1] == 2 # path_len == hop_count for 1-byte mode
|
||||
|
||||
def test_read_from_2_byte_wire(self):
|
||||
"""Manually construct wire bytes and verify read_from parses correctly."""
|
||||
# header=ROUTE_TYPE_FLOOD, path_len=encode(2, 2), path=4 bytes, payload=2 bytes
|
||||
path_len = PathUtils.encode_path_len(2, 2)
|
||||
wire = bytes([ROUTE_TYPE_FLOOD, path_len]) + b"\xAA\xBB\xCC\xDD" + b"\xFE\xED"
|
||||
pkt = Packet()
|
||||
pkt.read_from(wire)
|
||||
assert pkt.get_path_hash_size() == 2
|
||||
assert pkt.get_path_hash_count() == 2
|
||||
assert pkt.get_path_hashes() == [b"\xAA\xBB", b"\xCC\xDD"]
|
||||
assert pkt.get_payload() == b"\xFE\xED"
|
||||
Reference in New Issue
Block a user