mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-06-13 09:44:48 +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:
+8
-1
@@ -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
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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{_};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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 _};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+3
-3
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -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
|
||||
*
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
@@ -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