@@ -1167,7 +1167,7 @@ mod tests {
11671167 use crate :: engine:: response:: * ;
11681168 use crate :: engine:: rules:: * ;
11691169 use crate :: matcher:: RuntimeResponseMatcherWithOp ;
1170- use hickory_proto:: op:: { Message , MessageType , OpCode , Query } ;
1170+ use hickory_proto:: op:: { Message , MessageType , OpCode , Query , UpdateMessage } ;
11711171 use hickory_proto:: rr:: RecordType ;
11721172 use hickory_proto:: rr:: { RData , Record } ;
11731173 use std:: net:: IpAddr ;
@@ -1321,6 +1321,130 @@ mod tests {
13211321 ) ;
13221322 }
13231323
1324+ /// Minimal UDP upstream that echoes each query back as a NOERROR response
1325+ /// with a single A record, so the forward path completes without network
1326+ /// access. / 最小 UDP 上游:把查询回显为带一条 A 记录的 NOERROR 响应。
1327+ async fn spawn_echo_upstream ( ) -> String {
1328+ let sock = tokio:: net:: UdpSocket :: bind ( "127.0.0.1:0" ) . await . unwrap ( ) ;
1329+ let addr = sock. local_addr ( ) . unwrap ( ) . to_string ( ) ;
1330+ tokio:: spawn ( async move {
1331+ let mut buf = [ 0u8 ; 1500 ] ;
1332+ while let Ok ( ( n, peer) ) = sock. recv_from ( & mut buf) . await {
1333+ let query = match Message :: from_bytes ( & buf[ ..n] ) {
1334+ Ok ( q) => q,
1335+ Err ( _) => continue ,
1336+ } ;
1337+ let Some ( question) = query. queries . first ( ) . cloned ( ) else {
1338+ continue ;
1339+ } ;
1340+ let mut resp = Message :: new ( query. id ( ) , MessageType :: Response , OpCode :: Query ) ;
1341+ resp. add_query ( question. clone ( ) ) ;
1342+ resp. add_answer ( Record :: from_rdata (
1343+ question. name ( ) . clone ( ) ,
1344+ 300 ,
1345+ RData :: A ( hickory_proto:: rr:: rdata:: A ( std:: net:: Ipv4Addr :: LOCALHOST ) ) ,
1346+ ) ) ;
1347+ let _ = sock. send_to ( & resp. to_vec ( ) . unwrap ( ) , peer) . await ;
1348+ }
1349+ } ) ;
1350+ addr
1351+ }
1352+
1353+ fn jump_config ( upstream : & str , target_ip : & str ) -> RuntimePipelineConfig {
1354+ let raw = serde_json:: json!( {
1355+ "settings" : { "default_upstream" : "1.1.1.1:53" , "min_ttl" : 60 } ,
1356+ "pipelines" : [
1357+ {
1358+ "id" : "main" ,
1359+ "rules" : [ {
1360+ "name" : "forward-and-jump" ,
1361+ "matchers" : [ { "type" : "any" } ] ,
1362+ "actions" : [ { "type" : "forward" , "upstream" : upstream } ] ,
1363+ "response_matchers" : [ { "type" : "upstream_equals" , "value" : upstream } ] ,
1364+ "response_actions_on_match" : [
1365+ { "type" : "jump_to_pipeline" , "pipeline" : "target" }
1366+ ]
1367+ } ]
1368+ } ,
1369+ {
1370+ "id" : "target" ,
1371+ "rules" : [ {
1372+ "name" : "static" ,
1373+ "matchers" : [ { "type" : "any" } ] ,
1374+ "actions" : [ { "type" : "static_ip_response" , "ip" : target_ip } ]
1375+ } ]
1376+ }
1377+ ]
1378+ } ) ;
1379+ let config: crate :: config:: PipelineConfig =
1380+ serde_json:: from_value ( raw) . expect ( "parse config" ) ;
1381+ RuntimePipelineConfig :: from_config ( config) . expect ( "build runtime config" )
1382+ }
1383+
1384+ #[ tokio:: test]
1385+ async fn response_jump_writes_only_into_its_own_generation ( ) {
1386+ let upstream = spawn_echo_upstream ( ) . await ;
1387+ let engine =
1388+ Engine :: new ( jump_config ( & upstream, "192.0.2.1" ) , "test" . to_string ( ) ) . expect ( "engine" ) ;
1389+ let old_state = engine. state . load_full ( ) ;
1390+ let old_target_key = Engine :: calculate_cache_hash_for_dedupe (
1391+ old_state. cache_namespace ( "target" ) ,
1392+ "target" ,
1393+ b"example.com" ,
1394+ RecordType :: A ,
1395+ DNSClass :: IN ,
1396+ None ,
1397+ ) ;
1398+
1399+ // Reload changes only the jump target, so "target" rotates its
1400+ // namespace while the entry pipeline stays warm.
1401+ engine. reload ( jump_config ( & upstream, "192.0.2.2" ) ) ;
1402+ let new_state = engine. state . load_full ( ) ;
1403+ let new_target_key = Engine :: calculate_cache_hash_for_dedupe (
1404+ new_state. cache_namespace ( "target" ) ,
1405+ "target" ,
1406+ b"example.com" ,
1407+ RecordType :: A ,
1408+ DNSClass :: IN ,
1409+ None ,
1410+ ) ;
1411+
1412+ let mut request = Message :: new ( 0xBEEF , MessageType :: Query , OpCode :: Query ) ;
1413+ request. add_query ( Query :: query (
1414+ Name :: from_str ( "example.com" ) . unwrap ( ) ,
1415+ RecordType :: A ,
1416+ ) ) ;
1417+ // No explicit cache hash: the jump-path key must be recomputed from
1418+ // the request snapshot, exercising the phases.rs threading.
1419+ let response = engine
1420+ . handle_packet_internal (
1421+ & request. to_vec ( ) . unwrap ( ) ,
1422+ "127.0.0.1:53000" . parse ( ) . unwrap ( ) ,
1423+ false ,
1424+ None ,
1425+ None ,
1426+ Some ( old_state) ,
1427+ )
1428+ . await
1429+ . expect ( "complete forwarded request with response jump" ) ;
1430+ let response = Message :: from_bytes ( & response) . expect ( "parse response" ) ;
1431+
1432+ // The jump must execute the target pipeline of the request snapshot
1433+ // (192.0.2.1), not the reloaded one (192.0.2.2).
1434+ assert ! ( matches!(
1435+ response. answers. first( ) . map( |record| & record. data) ,
1436+ Some ( RData :: A ( address) ) if address. 0 == "192.0.2.1" . parse:: <std:: net:: Ipv4Addr >( ) . unwrap( )
1437+ ) ) ;
1438+ assert ! (
1439+ engine. cache_get( & old_target_key) . is_some( ) ,
1440+ "the response jump must cache under the snapshot generation's key"
1441+ ) ;
1442+ assert ! (
1443+ engine. cache_get( & new_target_key) . is_none( ) ,
1444+ "the response jump must not populate the active generation"
1445+ ) ;
1446+ }
1447+
13241448 // ========================================================================
13251449 // Engine Helper Functions Unit Tests / 引擎辅助函数单元测试
13261450 // ========================================================================
0 commit comments